feat: split cli roles edit command into create and update commands (#17121)

Closes #14239
This commit is contained in:
brettkolodny
2025-04-04 14:04:20 -04:00
committed by GitHub
parent 53af7e1b90
commit ae7afd1aa0
9 changed files with 362 additions and 78 deletions

View File

@ -26,7 +26,8 @@ func (r *RootCmd) organizationRoles(orgContext *OrganizationContext) *serpent.Co
},
Children: []*serpent.Command{
r.showOrganizationRoles(orgContext),
r.editOrganizationRole(orgContext),
r.updateOrganizationRole(orgContext),
r.createOrganizationRole(orgContext),
},
}
return cmd
@ -99,7 +100,7 @@ func (r *RootCmd) showOrganizationRoles(orgContext *OrganizationContext) *serpen
return cmd
}
func (r *RootCmd) editOrganizationRole(orgContext *OrganizationContext) *serpent.Command {
func (r *RootCmd) createOrganizationRole(orgContext *OrganizationContext) *serpent.Command {
formatter := cliui.NewOutputFormatter(
cliui.ChangeFormatterData(
cliui.TableFormat([]roleTableRow{}, []string{"name", "display name", "site permissions", "organization permissions", "user permissions"}),
@ -118,12 +119,12 @@ func (r *RootCmd) editOrganizationRole(orgContext *OrganizationContext) *serpent
client := new(codersdk.Client)
cmd := &serpent.Command{
Use: "edit <role_name>",
Short: "Edit an organization custom role",
Use: "create <role_name>",
Short: "Create a new organization custom role",
Long: FormatExamples(
Example{
Description: "Run with an input.json file",
Command: "coder roles edit --stdin < role.json",
Command: "coder organization -O <organization_name> roles create --stidin < role.json",
},
),
Options: []serpent.Option{
@ -152,10 +153,13 @@ func (r *RootCmd) editOrganizationRole(orgContext *OrganizationContext) *serpent
return err
}
createNewRole := true
existingRoles, err := client.ListOrganizationRoles(ctx, org.ID)
if err != nil {
return xerrors.Errorf("listing existing roles: %w", err)
}
var customRole codersdk.Role
if jsonInput {
// JSON Upload mode
bytes, err := io.ReadAll(inv.Stdin)
if err != nil {
return xerrors.Errorf("reading stdin: %w", err)
@ -175,29 +179,148 @@ func (r *RootCmd) editOrganizationRole(orgContext *OrganizationContext) *serpent
return xerrors.Errorf("json input does not appear to be a valid role")
}
existingRoles, err := client.ListOrganizationRoles(ctx, org.ID)
if err != nil {
return xerrors.Errorf("listing existing roles: %w", err)
if role := existingRole(customRole.Name, existingRoles); role != nil {
return xerrors.Errorf("The role %s already exists. If you'd like to edit this role use the update command instead", customRole.Name)
}
for _, existingRole := range existingRoles {
if strings.EqualFold(customRole.Name, existingRole.Name) {
// Editing an existing role
createNewRole = false
break
} else {
if len(inv.Args) == 0 {
return xerrors.Errorf("missing role name argument, usage: \"coder organizations roles create <role_name>\"")
}
if role := existingRole(inv.Args[0], existingRoles); role != nil {
return xerrors.Errorf("The role %s already exists. If you'd like to edit this role use the update command instead", inv.Args[0])
}
interactiveRole, err := interactiveOrgRoleEdit(inv, org.ID, nil)
if err != nil {
return xerrors.Errorf("editing role: %w", err)
}
customRole = *interactiveRole
}
var updated codersdk.Role
if dryRun {
// Do not actually post
updated = customRole
} else {
updated, err = client.CreateOrganizationRole(ctx, customRole)
if err != nil {
return xerrors.Errorf("patch role: %w", err)
}
}
output, err := formatter.Format(ctx, updated)
if err != nil {
return xerrors.Errorf("formatting: %w", err)
}
_, err = fmt.Fprintln(inv.Stdout, output)
return err
},
}
return cmd
}
func (r *RootCmd) updateOrganizationRole(orgContext *OrganizationContext) *serpent.Command {
formatter := cliui.NewOutputFormatter(
cliui.ChangeFormatterData(
cliui.TableFormat([]roleTableRow{}, []string{"name", "display name", "site permissions", "organization permissions", "user permissions"}),
func(data any) (any, error) {
typed, _ := data.(codersdk.Role)
return []roleTableRow{roleToTableView(typed)}, nil
},
),
cliui.JSONFormat(),
)
var (
dryRun bool
jsonInput bool
)
client := new(codersdk.Client)
cmd := &serpent.Command{
Use: "update <role_name>",
Short: "Update an organization custom role",
Long: FormatExamples(
Example{
Description: "Run with an input.json file",
Command: "coder roles update --stdin < role.json",
},
),
Options: []serpent.Option{
cliui.SkipPromptOption(),
{
Name: "dry-run",
Description: "Does all the work, but does not submit the final updated role.",
Flag: "dry-run",
Value: serpent.BoolOf(&dryRun),
},
{
Name: "stdin",
Description: "Reads stdin for the json role definition to upload.",
Flag: "stdin",
Value: serpent.BoolOf(&jsonInput),
},
},
Middleware: serpent.Chain(
serpent.RequireRangeArgs(0, 1),
r.InitClient(client),
),
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
org, err := orgContext.Selected(inv, client)
if err != nil {
return err
}
existingRoles, err := client.ListOrganizationRoles(ctx, org.ID)
if err != nil {
return xerrors.Errorf("listing existing roles: %w", err)
}
var customRole codersdk.Role
if jsonInput {
bytes, err := io.ReadAll(inv.Stdin)
if err != nil {
return xerrors.Errorf("reading stdin: %w", err)
}
err = json.Unmarshal(bytes, &customRole)
if err != nil {
return xerrors.Errorf("parsing stdin json: %w", err)
}
if customRole.Name == "" {
arr := make([]json.RawMessage, 0)
err = json.Unmarshal(bytes, &arr)
if err == nil && len(arr) > 0 {
return xerrors.Errorf("only 1 role can be sent at a time")
}
return xerrors.Errorf("json input does not appear to be a valid role")
}
if role := existingRole(customRole.Name, existingRoles); role == nil {
return xerrors.Errorf("The role %s does not exist. If you'd like to create this role use the create command instead", customRole.Name)
}
} else {
if len(inv.Args) == 0 {
return xerrors.Errorf("missing role name argument, usage: \"coder organizations roles edit <role_name>\"")
}
interactiveRole, newRole, err := interactiveOrgRoleEdit(inv, org.ID, client)
role := existingRole(inv.Args[0], existingRoles)
if role == nil {
return xerrors.Errorf("The role %s does not exist. If you'd like to create this role use the create command instead", inv.Args[0])
}
interactiveRole, err := interactiveOrgRoleEdit(inv, org.ID, &role.Role)
if err != nil {
return xerrors.Errorf("editing role: %w", err)
}
customRole = *interactiveRole
createNewRole = newRole
preview := fmt.Sprintf("permissions: %d site, %d org, %d user",
len(customRole.SitePermissions), len(customRole.OrganizationPermissions), len(customRole.UserPermissions))
@ -216,12 +339,7 @@ func (r *RootCmd) editOrganizationRole(orgContext *OrganizationContext) *serpent
// Do not actually post
updated = customRole
} else {
switch createNewRole {
case true:
updated, err = client.CreateOrganizationRole(ctx, customRole)
default:
updated, err = client.UpdateOrganizationRole(ctx, customRole)
}
updated, err = client.UpdateOrganizationRole(ctx, customRole)
if err != nil {
return xerrors.Errorf("patch role: %w", err)
}
@ -241,50 +359,27 @@ func (r *RootCmd) editOrganizationRole(orgContext *OrganizationContext) *serpent
return cmd
}
func interactiveOrgRoleEdit(inv *serpent.Invocation, orgID uuid.UUID, client *codersdk.Client) (*codersdk.Role, bool, error) {
newRole := false
ctx := inv.Context()
roles, err := client.ListOrganizationRoles(ctx, orgID)
if err != nil {
return nil, newRole, xerrors.Errorf("listing roles: %w", err)
}
// Make sure the role actually exists first
var originalRole codersdk.AssignableRoles
for _, r := range roles {
if strings.EqualFold(inv.Args[0], r.Name) {
originalRole = r
break
}
}
if originalRole.Name == "" {
_, err = cliui.Prompt(inv, cliui.PromptOptions{
Text: "No organization role exists with that name, do you want to create one?",
Default: "yes",
IsConfirm: true,
})
if err != nil {
return nil, newRole, xerrors.Errorf("abort: %w", err)
}
originalRole.Role = codersdk.Role{
func interactiveOrgRoleEdit(inv *serpent.Invocation, orgID uuid.UUID, updateRole *codersdk.Role) (*codersdk.Role, error) {
var originalRole codersdk.Role
if updateRole == nil {
originalRole = codersdk.Role{
Name: inv.Args[0],
OrganizationID: orgID.String(),
}
newRole = true
} else {
originalRole = *updateRole
}
// Some checks since interactive mode is limited in what it currently sees
if len(originalRole.SitePermissions) > 0 {
return nil, newRole, xerrors.Errorf("unable to edit role in interactive mode, it contains site wide permissions")
return nil, xerrors.Errorf("unable to edit role in interactive mode, it contains site wide permissions")
}
if len(originalRole.UserPermissions) > 0 {
return nil, newRole, xerrors.Errorf("unable to edit role in interactive mode, it contains user permissions")
return nil, xerrors.Errorf("unable to edit role in interactive mode, it contains user permissions")
}
role := &originalRole.Role
role := &originalRole
allowedResources := []codersdk.RBACResource{
codersdk.ResourceTemplate,
codersdk.ResourceWorkspace,
@ -303,13 +398,13 @@ customRoleLoop:
Options: append(permissionPreviews(role, allowedResources), done, abort),
})
if err != nil {
return role, newRole, xerrors.Errorf("selecting resource: %w", err)
return role, xerrors.Errorf("selecting resource: %w", err)
}
switch selected {
case done:
break customRoleLoop
case abort:
return role, newRole, xerrors.Errorf("edit role %q aborted", role.Name)
return role, xerrors.Errorf("edit role %q aborted", role.Name)
default:
strs := strings.Split(selected, "::")
resource := strings.TrimSpace(strs[0])
@ -320,7 +415,7 @@ customRoleLoop:
Defaults: defaultActions(role, resource),
})
if err != nil {
return role, newRole, xerrors.Errorf("selecting actions for resource %q: %w", resource, err)
return role, xerrors.Errorf("selecting actions for resource %q: %w", resource, err)
}
applyOrgResourceActions(role, resource, actions)
// back to resources!
@ -329,7 +424,7 @@ customRoleLoop:
// This println is required because the prompt ends us on the same line as some text.
_, _ = fmt.Println()
return role, newRole, nil
return role, nil
}
func applyOrgResourceActions(role *codersdk.Role, resource string, actions []string) {
@ -405,6 +500,16 @@ func roleToTableView(role codersdk.Role) roleTableRow {
}
}
func existingRole(newRoleName string, existingRoles []codersdk.AssignableRoles) *codersdk.AssignableRoles {
for _, existingRole := range existingRoles {
if strings.EqualFold(newRoleName, existingRole.Name) {
return &existingRole
}
}
return nil
}
type roleTableRow struct {
Name string `table:"name,default_sort"`
DisplayName string `table:"display name"`

View File

@ -8,8 +8,9 @@ USAGE:
Aliases: role
SUBCOMMANDS:
edit Edit an organization custom role
show Show role(s)
create Create a new organization custom role
show Show role(s)
update Update an organization custom role
———
Run `coder --help` for a list of global options.

View File

@ -0,0 +1,24 @@
coder v0.0.0-devel
USAGE:
coder organizations roles create [flags] <role_name>
Create a new organization custom role
- Run with an input.json file:
$ coder organization -O <organization_name> roles create --stidin <
role.json
OPTIONS:
--dry-run bool
Does all the work, but does not submit the final updated role.
--stdin bool
Reads stdin for the json role definition to upload.
-y, --yes bool
Bypass prompts.
———
Run `coder --help` for a list of global options.

View File

@ -1,13 +1,13 @@
coder v0.0.0-devel
USAGE:
coder organizations roles edit [flags] <role_name>
coder organizations roles update [flags] <role_name>
Edit an organization custom role
Update an organization custom role
- Run with an input.json file:
$ coder roles edit --stdin < role.json
$ coder roles update --stdin < role.json
OPTIONS:
-c, --column [name|display name|organization id|site permissions|organization permissions|user permissions] (default: name,display name,site permissions,organization permissions,user permissions)