-
Notifications
You must be signed in to change notification settings - Fork 892
feat: implement organization context in the cli #12259
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
31a8541
1ac16cf
6a99a37
d88d7be
5df951b
a2f68ea
50cdb32
438e69d
f32c78c
cad75c2
f8b97d9
405afc9
a9da679
510dbb8
c74106b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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", | ||
Emyrk marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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 | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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()) | ||
}) | ||
} |
Original file line number | Diff line number | Diff line change | ||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -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 | ||||||||||||||||
Comment on lines
-704
to
+716
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @mafredri The unit test expecting Before, if you failed to fetch the orgs, we'd return a nil uuid. I made this api failure fail the command now. So the unit tests fails differently now. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wouldn't expect There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I didn't write the test, but there is a comment indicating this cli error is expected. We cancel the context before running the command. coder/cli/templatecreate_test.go Lines 238 to 244 in 98666fd
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we're testing against a cancelled context, that test is useless. I think it should either be removed or fixed. 😅 It's definitely not testing what the test name is indicating. |
||||||||||||||||
} | ||||||||||||||||
// 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) { | ||||||||||||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why this change? |
||
<-ctx.Done() | ||
}) | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I marked this as hidden, I think we can defer the actual command name discussion and get the code in. Don't want to bikeshed and prevent code progress.