feat: implement organization context in the cli (#12259)

* feat: implement organization context in the cli

`coder org show current`
This commit is contained in:
Steven Masley 2024-02-26 10:03:49 -06:00 committed by GitHub
parent f44c89d200
commit d2998c6b7b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 290 additions and 52 deletions

View File

@ -70,6 +70,14 @@ func (r Root) PostgresPort() File {
// File provides convenience methods for interacting with *os.File.
type File string
func (f File) Exists() bool {
if f == "" {
return false
}
_, err := os.Stat(string(f))
return err == nil
}
// Delete deletes the file.
func (f File) Delete() error {
if f == "" {

View File

@ -43,7 +43,7 @@ func (r *RootCmd) create() *clibase.Cmd {
),
Middleware: clibase.Chain(r.InitClient(client)),
Handler: func(inv *clibase.Invocation) error {
organization, err := CurrentOrganization(inv, client)
organization, err := CurrentOrganization(r, inv, client)
if err != nil {
return err
}

131
cli/organization.go Normal file
View File

@ -0,0 +1,131 @@
package cli
import (
"fmt"
"strings"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
)
func (r *RootCmd) organizations() *clibase.Cmd {
cmd := &clibase.Cmd{
Annotations: workspaceCommand,
Use: "organizations [subcommand]",
Short: "Organization related commands",
Aliases: []string{"organization", "org", "orgs"},
Hidden: true, // Hidden until these commands are complete.
Handler: func(inv *clibase.Invocation) error {
return inv.Command.HelpHandler(inv)
},
Children: []*clibase.Cmd{
r.currentOrganization(),
},
}
cmd.Options = clibase.OptionSet{}
return cmd
}
func (r *RootCmd) currentOrganization() *clibase.Cmd {
var (
stringFormat func(orgs []codersdk.Organization) (string, error)
client = new(codersdk.Client)
formatter = cliui.NewOutputFormatter(
cliui.ChangeFormatterData(cliui.TextFormat(), func(data any) (any, error) {
typed, ok := data.([]codersdk.Organization)
if !ok {
// This should never happen
return "", fmt.Errorf("expected []Organization, got %T", data)
}
return stringFormat(typed)
}),
cliui.TableFormat([]codersdk.Organization{}, []string{"id", "name", "default"}),
cliui.JSONFormat(),
)
onlyID = false
)
cmd := &clibase.Cmd{
Use: "show [current|me|uuid]",
Short: "Show the organization, if no argument is given, the organization currently in use will be shown.",
Middleware: clibase.Chain(
r.InitClient(client),
clibase.RequireRangeArgs(0, 1),
),
Options: clibase.OptionSet{
{
Name: "only-id",
Description: "Only print the organization ID.",
Required: false,
Flag: "only-id",
Value: clibase.BoolOf(&onlyID),
},
},
Handler: func(inv *clibase.Invocation) error {
orgArg := "current"
if len(inv.Args) >= 1 {
orgArg = inv.Args[0]
}
var orgs []codersdk.Organization
var err error
switch strings.ToLower(orgArg) {
case "current":
stringFormat = func(orgs []codersdk.Organization) (string, error) {
if len(orgs) != 1 {
return "", fmt.Errorf("expected 1 organization, got %d", len(orgs))
}
return fmt.Sprintf("Current CLI Organization: %s (%s)\n", orgs[0].Name, orgs[0].ID.String()), nil
}
org, err := CurrentOrganization(r, inv, client)
if err != nil {
return err
}
orgs = []codersdk.Organization{org}
case "me":
stringFormat = func(orgs []codersdk.Organization) (string, error) {
var str strings.Builder
_, _ = fmt.Fprint(&str, "Organizations you are a member of:\n")
for _, org := range orgs {
_, _ = fmt.Fprintf(&str, "\t%s (%s)\n", org.Name, org.ID.String())
}
return str.String(), nil
}
orgs, err = client.OrganizationsByUser(inv.Context(), codersdk.Me)
if err != nil {
return err
}
default:
stringFormat = func(orgs []codersdk.Organization) (string, error) {
if len(orgs) != 1 {
return "", fmt.Errorf("expected 1 organization, got %d", len(orgs))
}
return fmt.Sprintf("Organization: %s (%s)\n", orgs[0].Name, orgs[0].ID.String()), nil
}
// This works for a uuid or a name
org, err := client.OrganizationByName(inv.Context(), orgArg)
if err != nil {
return err
}
orgs = []codersdk.Organization{org}
}
if onlyID {
for _, org := range orgs {
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", org.ID)
}
} else {
out, err := formatter.Format(inv.Context(), orgs)
if err != nil {
return err
}
_, _ = fmt.Fprint(inv.Stdout, out)
}
return nil
},
}
formatter.AttachOptions(&cmd.Options)
return cmd
}

45
cli/organization_test.go Normal file
View File

@ -0,0 +1,45 @@
package cli_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
)
func TestCurrentOrganization(t *testing.T) {
t.Parallel()
t.Run("OnlyID", func(t *testing.T) {
t.Parallel()
ownerClient := coderdtest.New(t, nil)
first := coderdtest.CreateFirstUser(t, ownerClient)
// Owner is required to make orgs
client, _ := coderdtest.CreateAnotherUser(t, ownerClient, first.OrganizationID, rbac.RoleOwner())
ctx := testutil.Context(t, testutil.WaitMedium)
orgs := []string{"foo", "bar"}
for _, orgName := range orgs {
_, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{
Name: orgName,
})
require.NoError(t, err)
}
inv, root := clitest.New(t, "organizations", "show", "--only-id")
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t).Attach(inv)
errC := make(chan error)
go func() {
errC <- inv.Run()
}()
require.NoError(t, <-errC)
pty.ExpectMatch(first.OrganizationID.String())
})
}

View File

@ -94,6 +94,7 @@ func (r *RootCmd) Core() []*clibase.Cmd {
r.tokens(),
r.users(),
r.version(defaultVersionInfo),
r.organizations(),
// Workspace Commands
r.autoupdate(),
@ -698,14 +699,44 @@ func (r *RootCmd) createAgentClient() (*agentsdk.Client, error) {
}
// CurrentOrganization returns the currently active organization for the authenticated user.
func CurrentOrganization(inv *clibase.Invocation, client *codersdk.Client) (codersdk.Organization, error) {
func CurrentOrganization(r *RootCmd, inv *clibase.Invocation, client *codersdk.Client) (codersdk.Organization, error) {
conf := r.createConfig()
selected := ""
if conf.Organization().Exists() {
org, err := conf.Organization().Read()
if err != nil {
return codersdk.Organization{}, fmt.Errorf("read selected organization from config file %q: %w", conf.Organization(), err)
}
selected = org
}
// Verify the org exists and the user is a member
orgs, err := client.OrganizationsByUser(inv.Context(), codersdk.Me)
if err != nil {
return codersdk.Organization{}, nil
return codersdk.Organization{}, err
}
// For now, we won't use the config to set this.
// Eventually, we will support changing using "coder switch <org>"
return orgs[0], nil
// User manually selected an organization
if selected != "" {
index := slices.IndexFunc(orgs, func(org codersdk.Organization) bool {
return org.Name == selected || org.ID.String() == selected
})
if index < 0 {
return codersdk.Organization{}, xerrors.Errorf("organization %q not found, are you sure you are a member of this organization?", selected)
}
return orgs[index], nil
}
// User did not select an organization, so use the default.
index := slices.IndexFunc(orgs, func(org codersdk.Organization) bool {
return org.IsDefault
})
if index < 0 {
return codersdk.Organization{}, xerrors.Errorf("unable to determine current organization. Use 'coder organizations switch <org>' to select an organization to use")
}
return orgs[index], nil
}
func splitNamedWorkspace(identifier string) (owner string, workspaceName string, err error) {

View File

@ -69,7 +69,7 @@ func (r *RootCmd) templateCreate() *clibase.Cmd {
}
}
organization, err := CurrentOrganization(inv, client)
organization, err := CurrentOrganization(r, inv, client)
if err != nil {
return err
}

View File

@ -243,19 +243,7 @@ func TestTemplateCreate(t *testing.T) {
assert.Error(t, err)
}()
matches := []struct {
match string
write string
}{
{match: "Upload", write: "yes"},
}
for _, m := range matches {
pty.ExpectMatch(m.match)
if len(m.write) > 0 {
pty.WriteLine(m.write)
}
}
pty.ExpectMatch("context canceled")
<-ctx.Done()
})

View File

@ -32,7 +32,7 @@ func (r *RootCmd) templateDelete() *clibase.Cmd {
templates = []codersdk.Template{}
)
organization, err := CurrentOrganization(inv, client)
organization, err := CurrentOrganization(r, inv, client)
if err != nil {
return err
}

View File

@ -79,7 +79,7 @@ func (r *RootCmd) templateEdit() *clibase.Cmd {
}
}
organization, err := CurrentOrganization(inv, client)
organization, err := CurrentOrganization(r, inv, client)
if err != nil {
return xerrors.Errorf("get current organization: %w", err)
}

View File

@ -25,7 +25,7 @@ func (r *RootCmd) templateList() *clibase.Cmd {
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) error {
organization, err := CurrentOrganization(inv, client)
organization, err := CurrentOrganization(r, inv, client)
if err != nil {
return err
}

View File

@ -44,7 +44,7 @@ func (r *RootCmd) templatePull() *clibase.Cmd {
return xerrors.Errorf("either tar or zip can be selected")
}
organization, err := CurrentOrganization(inv, client)
organization, err := CurrentOrganization(r, inv, client)
if err != nil {
return xerrors.Errorf("get current organization: %w", err)
}

View File

@ -46,7 +46,7 @@ func (r *RootCmd) templatePush() *clibase.Cmd {
Handler: func(inv *clibase.Invocation) error {
uploadFlags.setWorkdir(workdir)
organization, err := CurrentOrganization(inv, client)
organization, err := CurrentOrganization(r, inv, client)
if err != nil {
return err
}

View File

@ -47,7 +47,7 @@ func (r *RootCmd) setArchiveTemplateVersion(archive bool) *clibase.Cmd {
versions []codersdk.TemplateVersion
)
organization, err := CurrentOrganization(inv, client)
organization, err := CurrentOrganization(r, inv, client)
if err != nil {
return err
}
@ -121,7 +121,7 @@ func (r *RootCmd) archiveTemplateVersions() *clibase.Cmd {
templates = []codersdk.Template{}
)
organization, err := CurrentOrganization(inv, client)
organization, err := CurrentOrganization(r, inv, client)
if err != nil {
return err
}

View File

@ -93,7 +93,7 @@ func (r *RootCmd) templateVersionsList() *clibase.Cmd {
},
},
Handler: func(inv *clibase.Invocation) error {
organization, err := CurrentOrganization(inv, client)
organization, err := CurrentOrganization(r, inv, client)
if err != nil {
return xerrors.Errorf("get current organization: %w", err)
}

View File

@ -31,7 +31,7 @@ func (r *RootCmd) userCreate() *clibase.Cmd {
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) error {
organization, err := CurrentOrganization(inv, client)
organization, err := CurrentOrganization(r, inv, client)
if err != nil {
return err
}

View File

@ -2,8 +2,12 @@ package httpmw
import (
"context"
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/httpapi"
@ -40,19 +44,34 @@ func ExtractOrganizationParam(db database.Store) func(http.Handler) http.Handler
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
orgID, ok := ParseUUIDParam(rw, r, "organization")
if !ok {
arg := chi.URLParam(r, "organization")
if arg == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "\"organization\" must be provided.",
})
return
}
organization, err := db.GetOrganizationByID(ctx, orgID)
var organization database.Organization
var err error
// Try by name or uuid.
id, err := uuid.Parse(arg)
if err == nil {
organization, err = db.GetOrganizationByID(ctx, id)
} else {
organization, err = db.GetOrganizationByName(ctx, arg)
}
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: fmt.Sprintf("Organization %q not found.", arg),
Detail: "Provide either the organization id or name.",
})
return
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching organization.",
Message: fmt.Sprintf("Internal error fetching organization %q.", arg),
Detail: err.Error(),
})
return

View File

@ -103,7 +103,7 @@ func TestOrganizationParam(t *testing.T) {
rtr.ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusBadRequest, res.StatusCode)
require.Equal(t, http.StatusNotFound, res.StatusCode)
})
t.Run("NotInOrganization", func(t *testing.T) {
@ -160,8 +160,6 @@ func TestOrganizationParam(t *testing.T) {
})
require.NoError(t, err)
chi.RouteContext(r.Context()).URLParams.Add("organization", organization.ID.String())
chi.RouteContext(r.Context()).URLParams.Add("user", user.ID.String())
rtr.Use(
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
@ -194,9 +192,21 @@ func TestOrganizationParam(t *testing.T) {
assert.NotEmpty(t, orgMem.OrganizationMember.UserID)
assert.NotEmpty(t, orgMem.OrganizationMember.Roles)
})
// Try by ID
chi.RouteContext(r.Context()).URLParams.Add("organization", organization.ID.String())
chi.RouteContext(r.Context()).URLParams.Add("user", user.ID.String())
rtr.ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)
require.Equal(t, http.StatusOK, res.StatusCode, "by id")
// Try by name
chi.RouteContext(r.Context()).URLParams.Add("organization", organization.Name)
chi.RouteContext(r.Context()).URLParams.Add("user", user.ID.String())
rtr.ServeHTTP(rw, r)
res = rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode, "by name")
})
}

View File

@ -66,7 +66,7 @@ func TestOrganizationByUserAndName(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
_, err := client.OrganizationByName(ctx, codersdk.Me, "nothing")
_, err := client.OrganizationByUserAndName(ctx, codersdk.Me, "nothing")
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
@ -85,7 +85,7 @@ func TestOrganizationByUserAndName(t *testing.T) {
Name: "another",
})
require.NoError(t, err)
_, err = other.OrganizationByName(ctx, codersdk.Me, org.Name)
_, err = other.OrganizationByUserAndName(ctx, codersdk.Me, org.Name)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
@ -101,7 +101,7 @@ func TestOrganizationByUserAndName(t *testing.T) {
org, err := client.Organization(ctx, user.OrganizationID)
require.NoError(t, err)
_, err = client.OrganizationByName(ctx, codersdk.Me, org.Name)
_, err = client.OrganizationByUserAndName(ctx, codersdk.Me, org.Name)
require.NoError(t, err)
})
}

View File

@ -26,11 +26,11 @@ const (
// Organization is the JSON representation of a Coder organization.
type Organization struct {
ID uuid.UUID `json:"id" validate:"required" format:"uuid"`
Name string `json:"name" validate:"required"`
CreatedAt time.Time `json:"created_at" validate:"required" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" validate:"required" format:"date-time"`
IsDefault bool `json:"is_default" validate:"required"`
ID uuid.UUID `table:"id" json:"id" validate:"required" format:"uuid"`
Name string `table:"name,default_sort" json:"name" validate:"required"`
CreatedAt time.Time `table:"created_at" json:"created_at" validate:"required" format:"date-time"`
UpdatedAt time.Time `table:"updated_at" json:"updated_at" validate:"required" format:"date-time"`
IsDefault bool `table:"default" json:"is_default" validate:"required"`
}
type OrganizationMember struct {
@ -153,8 +153,8 @@ type CreateWorkspaceRequest struct {
AutomaticUpdates AutomaticUpdates `json:"automatic_updates,omitempty"`
}
func (c *Client) Organization(ctx context.Context, id uuid.UUID) (Organization, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s", id.String()), nil)
func (c *Client) OrganizationByName(ctx context.Context, name string) (Organization, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s", name), nil)
if err != nil {
return Organization{}, xerrors.Errorf("execute request: %w", err)
}
@ -168,6 +168,12 @@ func (c *Client) Organization(ctx context.Context, id uuid.UUID) (Organization,
return organization, json.NewDecoder(res.Body).Decode(&organization)
}
func (c *Client) Organization(ctx context.Context, id uuid.UUID) (Organization, error) {
// OrganizationByName uses the exact same endpoint. It accepts a name or uuid.
// We just provide this function for type safety.
return c.OrganizationByName(ctx, id.String())
}
// ProvisionerDaemons returns provisioner daemons available.
func (c *Client) ProvisionerDaemons(ctx context.Context) ([]ProvisionerDaemon, error) {
res, err := c.Request(ctx, http.MethodGet,

View File

@ -573,7 +573,7 @@ func (c *Client) OrganizationsByUser(ctx context.Context, user string) ([]Organi
return orgs, json.NewDecoder(res.Body).Decode(&orgs)
}
func (c *Client) OrganizationByName(ctx context.Context, user string, name string) (Organization, error) {
func (c *Client) OrganizationByUserAndName(ctx context.Context, user string, name string) (Organization, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/organizations/%s", user, name), nil)
if err != nil {
return Organization{}, err

View File

@ -29,7 +29,7 @@ func (r *RootCmd) groupCreate() *clibase.Cmd {
Handler: func(inv *clibase.Invocation) error {
ctx := inv.Context()
org, err := agpl.CurrentOrganization(inv, client)
org, err := agpl.CurrentOrganization(&r.RootCmd, inv, client)
if err != nil {
return xerrors.Errorf("current organization: %w", err)
}

View File

@ -27,7 +27,7 @@ func (r *RootCmd) groupDelete() *clibase.Cmd {
groupName = inv.Args[0]
)
org, err := agpl.CurrentOrganization(inv, client)
org, err := agpl.CurrentOrganization(&r.RootCmd, inv, client)
if err != nil {
return xerrors.Errorf("current organization: %w", err)
}

View File

@ -37,7 +37,7 @@ func (r *RootCmd) groupEdit() *clibase.Cmd {
groupName = inv.Args[0]
)
org, err := agpl.CurrentOrganization(inv, client)
org, err := agpl.CurrentOrganization(&r.RootCmd, inv, client)
if err != nil {
return xerrors.Errorf("current organization: %w", err)
}

View File

@ -30,7 +30,7 @@ func (r *RootCmd) groupList() *clibase.Cmd {
Handler: func(inv *clibase.Invocation) error {
ctx := inv.Context()
org, err := agpl.CurrentOrganization(inv, client)
org, err := agpl.CurrentOrganization(&r.RootCmd, inv, client)
if err != nil {
return xerrors.Errorf("current organization: %w", err)
}