diff --git a/CHANGELOG.md b/CHANGELOG.md index a94ad12b..49f83de5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Features - iam: implement Org Policy management commands #553 +- iam: implement Role management commands #558 ### Improvements diff --git a/cmd/iam_role.go b/cmd/iam_role.go new file mode 100644 index 00000000..6471f8bf --- /dev/null +++ b/cmd/iam_role.go @@ -0,0 +1,14 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +var iamRoleCmd = &cobra.Command{ + Use: "role", + Short: "IAM Role management", +} + +func init() { + iamCmd.AddCommand(iamRoleCmd) +} diff --git a/cmd/iam_role_create.go b/cmd/iam_role_create.go new file mode 100644 index 00000000..67b39c3b --- /dev/null +++ b/cmd/iam_role_create.go @@ -0,0 +1,150 @@ +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + "github.com/spf13/cobra" + + "github.com/exoscale/cli/pkg/account" + "github.com/exoscale/cli/pkg/globalstate" + "github.com/exoscale/cli/pkg/output" + exoscale "github.com/exoscale/egoscale/v2" + exoapi "github.com/exoscale/egoscale/v2/api" +) + +type iamRoleCreateCmd struct { + cliCommandSettings `cli-cmd:"-"` + + _ bool `cli-cmd:"create"` + + Name string `cli-arg:"#" cli-usage:"NAME"` + Description string `cli-flag:"description" cli-usage:"Role description"` + Permissions []string `cli-flag:"permissions" cli-usage:"Role permissions"` + Editable bool `cli-flag:"editable" cli-usage:"Set --editable=false do prevent editing Policy after creation"` + Labels map[string]string `cli-flag:"label" cli-usage:"Role labels (format: key=value)"` + Policy string `cli-flag:"policy" cli-usage:"Role policy (use '-' to read from STDIN)"` +} + +func (c *iamRoleCreateCmd) cmdAliases() []string { return nil } + +func (c *iamRoleCreateCmd) cmdShort() string { + return "Create IAM Role" +} + +func (c *iamRoleCreateCmd) cmdLong() string { + return fmt.Sprintf(`This command creates a new IAM Role. +To read the Policy from STDIN, append '-' to the '--policy' flag. + +Pro Tip: you can reuse an existing role policy by providing the output of the show command as input: + + exo iam role show --policy --output-format json | exo iam role create --name --policy - + +Supported output template annotations: %s`, + strings.Join(output.TemplateAnnotations(&iamRoleShowOutput{}), ", ")) +} + +func (c *iamRoleCreateCmd) cmdPreRun(cmd *cobra.Command, args []string) error { + return cliCommandDefaultPreRun(c, cmd, args) +} + +func (c *iamRoleCreateCmd) cmdRun(cmd *cobra.Command, _ []string) error { + if c.Name == "" { + return errors.New("NAME not provided") + } + + zone := account.CurrentAccount.DefaultZone + ctx := exoapi.WithEndpoint( + gContext, + exoapi.NewReqEndpoint(account.CurrentAccount.Environment, zone), + ) + + if c.Policy == "-" { + inputReader := cmd.InOrStdin() + b, err := io.ReadAll(inputReader) + if err != nil { + return fmt.Errorf("failed to read policy from stdin: %w", err) + } + + c.Policy = string(b) + } + + var obj iamPolicyOutput + err := json.Unmarshal([]byte(c.Policy), &obj) + if err != nil { + return fmt.Errorf("failed to parse policy: %w", err) + } + + policy := &exoscale.IAMPolicy{ + DefaultServiceStrategy: obj.DefaultServiceStrategy, + Services: map[string]exoscale.IAMPolicyService{}, + } + + if len(obj.Services) > 0 { + for name, sv := range obj.Services { + service := exoscale.IAMPolicyService{ + Type: func() *string { + t := sv.Type + return &t + }(), + } + + if len(sv.Rules) > 0 { + service.Rules = []exoscale.IAMPolicyServiceRule{} + for _, rl := range sv.Rules { + + rule := exoscale.IAMPolicyServiceRule{ + Action: func() *string { + t := rl.Action + return &t + }(), + } + + if rl.Expression != "" { + rule.Expression = func() *string { + t := rl.Expression + return &t + }() + } + + service.Rules = append(service.Rules, rule) + } + } + + policy.Services[name] = service + } + } + + role := &exoscale.IAMRole{ + Name: &c.Name, + Description: &c.Description, + Editable: &c.Editable, + Labels: c.Labels, + Permissions: c.Permissions, + Policy: policy, + } + + r, err := globalstate.EgoscaleClient.CreateIAMRole(ctx, zone, role) + if err != nil { + return err + } + + if !globalstate.Quiet { + return (&iamRoleShowCmd{ + cliCommandSettings: c.cliCommandSettings, + Role: *r.ID, + }).cmdRun(nil, nil) + } + + return nil +} + +func init() { + cobra.CheckErr(registerCLICommand(iamRoleCmd, &iamRoleCreateCmd{ + cliCommandSettings: defaultCLICmdSettings(), + Editable: true, + })) +} diff --git a/cmd/iam_role_delete.go b/cmd/iam_role_delete.go new file mode 100644 index 00000000..6a51c36f --- /dev/null +++ b/cmd/iam_role_delete.go @@ -0,0 +1,86 @@ +package cmd + +import ( + "fmt" + + "github.com/google/uuid" + "github.com/spf13/cobra" + + "github.com/exoscale/cli/pkg/account" + "github.com/exoscale/cli/pkg/globalstate" + egoscale "github.com/exoscale/egoscale/v2" + exoapi "github.com/exoscale/egoscale/v2/api" +) + +type iamRoleDeleteCmd struct { + cliCommandSettings `cli-cmd:"-"` + + _ bool `cli-cmd:"delete"` + + Role string `cli-arg:"#" cli-usage:"ID|NAME"` + + Force bool `cli-short:"f" cli-usage:"don't prompt for confirmation"` +} + +func (c *iamRoleDeleteCmd) cmdAliases() []string { return gDeleteAlias } + +func (c *iamRoleDeleteCmd) cmdShort() string { + return "Delete IAM Role" +} + +func (c *iamRoleDeleteCmd) cmdLong() string { + return `This command deletes an existing IAM Role. +It will fail if the Role is attached to an IAM Key.` +} + +func (c *iamRoleDeleteCmd) cmdPreRun(cmd *cobra.Command, args []string) error { + return cliCommandDefaultPreRun(c, cmd, args) +} + +func (c *iamRoleDeleteCmd) cmdRun(_ *cobra.Command, _ []string) error { + zone := account.CurrentAccount.DefaultZone + ctx := exoapi.WithEndpoint(gContext, exoapi.NewReqEndpoint(account.CurrentAccount.Environment, zone)) + + if _, err := uuid.Parse(c.Role); err != nil { + roles, err := globalstate.EgoscaleClient.ListIAMRoles(ctx, zone) + if err != nil { + return err + } + + found := false + for _, role := range roles { + if role.Name != nil && *role.Name == c.Role { + c.Role = *role.ID + found = true + break + } + } + + if !found { + return fmt.Errorf("role with name %q not found", c.Role) + } + } + + if !c.Force { + if !askQuestion(fmt.Sprintf("Are you sure you want to delete IAM Role %s?", c.Role)) { + return nil + } + } + + var err error + decorateAsyncOperation(fmt.Sprintf("Deleting IAM role %s...", c.Role), func() { + + err = globalstate.EgoscaleClient.DeleteIAMRole(ctx, zone, &egoscale.IAMRole{ID: &c.Role}) + }) + if err != nil { + return err + } + + return nil +} + +func init() { + cobra.CheckErr(registerCLICommand(iamRoleCmd, &iamRoleDeleteCmd{ + cliCommandSettings: defaultCLICmdSettings(), + })) +} diff --git a/cmd/iam_role_list.go b/cmd/iam_role_list.go new file mode 100644 index 00000000..58f48e2c --- /dev/null +++ b/cmd/iam_role_list.go @@ -0,0 +1,76 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/exoscale/cli/pkg/account" + "github.com/exoscale/cli/pkg/globalstate" + "github.com/exoscale/cli/pkg/output" + "github.com/exoscale/cli/utils" + exoapi "github.com/exoscale/egoscale/v2/api" +) + +type iamRoleListItemOutput struct { + ID string `json:"key"` + Name string `json:"name"` + Editable bool `json:"type"` +} + +type iamRoleListOutput []iamRoleListItemOutput + +func (o *iamRoleListOutput) ToJSON() { output.JSON(o) } +func (o *iamRoleListOutput) ToText() { output.Text(o) } +func (o *iamRoleListOutput) ToTable() { output.Table(o) } + +type iamRoleListCmd struct { + cliCommandSettings `cli-cmd:"-"` + + _ bool `cli-cmd:"list"` +} + +func (c *iamRoleListCmd) cmdAliases() []string { return gListAlias } + +func (c *iamRoleListCmd) cmdShort() string { return "List IAM Roles" } + +func (c *iamRoleListCmd) cmdLong() string { + return fmt.Sprintf(`This command lists existing IAM Roles. + +Supported output template annotations: %s`, + strings.Join(output.TemplateAnnotations(&iamRoleListOutput{}), ", ")) +} + +func (c *iamRoleListCmd) cmdPreRun(cmd *cobra.Command, args []string) error { + return cliCommandDefaultPreRun(c, cmd, args) +} + +func (c *iamRoleListCmd) cmdRun(_ *cobra.Command, _ []string) error { + zone := account.CurrentAccount.DefaultZone + + ctx := exoapi.WithEndpoint(gContext, exoapi.NewReqEndpoint(account.CurrentAccount.Environment, zone)) + + iamRoles, err := globalstate.EgoscaleClient.ListIAMRoles(ctx, zone) + if err != nil { + return err + } + + out := make(iamRoleListOutput, 0) + + for _, role := range iamRoles { + out = append(out, iamRoleListItemOutput{ + ID: utils.DefaultString(role.ID, ""), + Name: utils.DefaultString(role.Name, ""), + Editable: utils.DefaultBool(role.Editable, false), + }) + } + + return c.outputFunc(&out, err) +} + +func init() { + cobra.CheckErr(registerCLICommand(iamRoleCmd, &iamRoleListCmd{ + cliCommandSettings: defaultCLICmdSettings(), + })) +} diff --git a/cmd/iam_role_show.go b/cmd/iam_role_show.go new file mode 100644 index 00000000..013bd726 --- /dev/null +++ b/cmd/iam_role_show.go @@ -0,0 +1,129 @@ +package cmd + +import ( + "errors" + "fmt" + "strings" + + "github.com/google/uuid" + "github.com/spf13/cobra" + + "github.com/exoscale/cli/pkg/account" + "github.com/exoscale/cli/pkg/globalstate" + "github.com/exoscale/cli/pkg/output" + "github.com/exoscale/cli/utils" + exoapi "github.com/exoscale/egoscale/v2/api" +) + +type iamRoleShowOutput struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Editable bool `json:"editable"` + Labels map[string]string `json:"labels"` + Permissions []string `json:"permission"` +} + +func (o *iamRoleShowOutput) ToJSON() { output.JSON(o) } +func (o *iamRoleShowOutput) ToText() { output.Text(o) } +func (o *iamRoleShowOutput) ToTable() { output.Table(o) } + +type iamRoleShowCmd struct { + cliCommandSettings `cli-cmd:"-"` + + _ bool `cli-cmd:"show"` + + Policy bool `cli-flag:"policy" cli-usage:"Print IAM Role policy"` + + Role string `cli-arg:"#" cli-usage:"ID|NAME"` +} + +func (c *iamRoleShowCmd) cmdAliases() []string { return gShowAlias } + +func (c *iamRoleShowCmd) cmdShort() string { + return "Show Role details" +} + +func (c *iamRoleShowCmd) cmdLong() string { + return fmt.Sprintf(`This command shows IAM Role details. + +Supported output template annotations: %s`, + strings.Join(output.TemplateAnnotations(&iamRoleShowOutput{}), ", ")) +} + +func (c *iamRoleShowCmd) cmdPreRun(cmd *cobra.Command, args []string) error { + return cliCommandDefaultPreRun(c, cmd, args) +} + +func (c *iamRoleShowCmd) cmdRun(_ *cobra.Command, _ []string) error { + if c.Role == "" { + return errors.New("Role ID not provided") + } + + zone := account.CurrentAccount.DefaultZone + ctx := exoapi.WithEndpoint(gContext, exoapi.NewReqEndpoint(account.CurrentAccount.Environment, zone)) + + if _, err := uuid.Parse(c.Role); err != nil { + roles, err := globalstate.EgoscaleClient.ListIAMRoles(ctx, zone) + if err != nil { + return err + } + + for _, role := range roles { + if role.Name != nil && *role.Name == c.Role { + c.Role = *role.ID + break + } + } + } + + role, err := globalstate.EgoscaleClient.GetIAMRole(ctx, zone, c.Role) + if err != nil { + return err + } + + if c.Policy { + policy := role.Policy + + out := iamPolicyOutput{ + DefaultServiceStrategy: policy.DefaultServiceStrategy, + Services: map[string]iamPolicyServiceOutput{}, + } + + for name, service := range policy.Services { + rules := []iamPolicyServiceRuleOutput{} + if service.Type != nil && *service.Type == "rules" { + for _, rule := range service.Rules { + rules = append(rules, iamPolicyServiceRuleOutput{ + Action: utils.DefaultString(rule.Action, ""), + Expression: utils.DefaultString(rule.Expression, ""), + }) + } + } + + out.Services[name] = iamPolicyServiceOutput{ + Type: utils.DefaultString(service.Type, ""), + Rules: rules, + } + } + + return c.outputFunc(&out, nil) + } + + out := iamRoleShowOutput{ + ID: utils.DefaultString(role.ID, ""), + Description: utils.DefaultString(role.Description, ""), + Editable: utils.DefaultBool(role.Editable, false), + Labels: role.Labels, + Name: utils.DefaultString(role.Name, ""), + Permissions: role.Permissions, + } + + return c.outputFunc(&out, nil) +} + +func init() { + cobra.CheckErr(registerCLICommand(iamRoleCmd, &iamRoleShowCmd{ + cliCommandSettings: defaultCLICmdSettings(), + })) +} diff --git a/cmd/iam_role_update.go b/cmd/iam_role_update.go new file mode 100644 index 00000000..f6403dbd --- /dev/null +++ b/cmd/iam_role_update.go @@ -0,0 +1,185 @@ +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + "github.com/google/uuid" + "github.com/spf13/cobra" + + "github.com/exoscale/cli/pkg/account" + "github.com/exoscale/cli/pkg/globalstate" + "github.com/exoscale/cli/pkg/output" + exoscale "github.com/exoscale/egoscale/v2" + exoapi "github.com/exoscale/egoscale/v2/api" +) + +type iamRoleUpdateCmd struct { + cliCommandSettings `cli-cmd:"-"` + + Role string `cli-arg:"#" cli-usage:"ID|NAME"` + + Description string `cli-flag:"description" cli-usage:"Role description"` + Permissions []string `cli-flag:"permissions" cli-usage:"Role permissions"` + Labels map[string]string `cli-flag:"label" cli-usage:"Role labels (format: key=value)"` + Policy string `cli-flag:"policy" cli-usage:"Role policy (use '-' to read from STDIN)"` + + _ bool `cli-cmd:"update"` +} + +func (c *iamRoleUpdateCmd) cmdAliases() []string { return nil } + +func (c *iamRoleUpdateCmd) cmdShort() string { + return "Update an IAM Role" +} + +func (c *iamRoleUpdateCmd) cmdLong() string { + return fmt.Sprintf(`This command updates an IAM Role. +When you supply '-' as a flag argument to '--policy', the new policy will be read from STDIN. + +Supported output template annotations: %s`, + strings.Join(output.TemplateAnnotations(&iamPolicyOutput{}), ", ")) +} + +func (c *iamRoleUpdateCmd) cmdPreRun(cmd *cobra.Command, args []string) error { + return cliCommandDefaultPreRun(c, cmd, args) +} + +func (c *iamRoleUpdateCmd) cmdRun(cmd *cobra.Command, _ []string) error { + if c.Role == "" { + return errors.New("Role not provided") + } + + zone := account.CurrentAccount.DefaultZone + ctx := exoapi.WithEndpoint( + gContext, + exoapi.NewReqEndpoint(account.CurrentAccount.Environment, zone), + ) + + if _, err := uuid.Parse(c.Role); err != nil { + roles, err := globalstate.EgoscaleClient.ListIAMRoles(ctx, zone) + if err != nil { + return err + } + + for _, role := range roles { + if role.Name != nil && *role.Name == c.Role { + c.Role = *role.ID + break + } + } + } + + role, err := globalstate.EgoscaleClient.GetIAMRole(ctx, zone, c.Role) + if err != nil { + return err + } + + if cmd.Flags().Changed(mustCLICommandFlagName(c, &c.Description)) { + role.Description = &c.Description + } + if cmd.Flags().Changed(mustCLICommandFlagName(c, &c.Labels)) { + role.Labels = c.Labels + } + if cmd.Flags().Changed(mustCLICommandFlagName(c, &c.Permissions)) { + role.Permissions = c.Permissions + } + + err = globalstate.EgoscaleClient.UpdateIAMRole(ctx, zone, role) + if err != nil { + return err + } + + // If we don't need to update Policy we can exit now + if c.Policy == "" { + if !globalstate.Quiet { + return (&iamRoleShowCmd{ + cliCommandSettings: c.cliCommandSettings, + Role: *role.ID, + }).cmdRun(nil, nil) + } + + return nil + } + + if c.Policy == "-" { + inputReader := cmd.InOrStdin() + b, err := io.ReadAll(inputReader) + if err != nil { + return fmt.Errorf("failed to read policy from stdin: %w", err) + } + + c.Policy = string(b) + } + + var obj iamPolicyOutput + err = json.Unmarshal([]byte(c.Policy), &obj) + if err != nil { + return fmt.Errorf("failed to parse policy: %w", err) + } + + policy := &exoscale.IAMPolicy{ + DefaultServiceStrategy: obj.DefaultServiceStrategy, + Services: map[string]exoscale.IAMPolicyService{}, + } + + if len(obj.Services) > 0 { + for name, sv := range obj.Services { + service := exoscale.IAMPolicyService{ + Type: func() *string { + t := sv.Type + return &t + }(), + } + + if len(sv.Rules) > 0 { + service.Rules = []exoscale.IAMPolicyServiceRule{} + for _, rl := range sv.Rules { + + rule := exoscale.IAMPolicyServiceRule{ + Action: func() *string { + t := rl.Action + return &t + }(), + } + + if rl.Expression != "" { + rule.Expression = func() *string { + t := rl.Expression + return &t + }() + } + + service.Rules = append(service.Rules, rule) + } + } + + policy.Services[name] = service + } + } + + role.Policy = policy + + err = globalstate.EgoscaleClient.UpdateIAMRolePolicy(ctx, zone, role) + if err != nil { + return err + } + + if !globalstate.Quiet { + return (&iamRoleShowCmd{ + cliCommandSettings: c.cliCommandSettings, + Role: *role.ID, + }).cmdRun(nil, nil) + } + + return nil +} + +func init() { + cobra.CheckErr(registerCLICommand(iamRoleCmd, &iamRoleUpdateCmd{ + cliCommandSettings: defaultCLICmdSettings(), + })) +} diff --git a/go.mod b/go.mod index 3fd5abf7..66099813 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/exoscale/egoscale v0.102.1 github.com/exoscale/openapi-cli-generator v1.1.0 github.com/fatih/camelcase v1.0.0 + github.com/google/uuid v1.3.1 github.com/hashicorp/go-multierror v1.1.0 github.com/iancoleman/strcase v0.2.0 github.com/izumin5210/gentleman-logger v1.0.0 @@ -82,7 +83,6 @@ require ( github.com/golang/snappy v0.0.4 // indirect github.com/gomarkdown/markdown v0.0.0-20230716120725-531d2d74bc12 // indirect github.com/google/shlex v0.0.0-20181106134648-c34317bd91bf // indirect - github.com/google/uuid v1.3.1 // indirect github.com/gordonklaus/ineffassign v0.0.0-20180909121442-1003c8bd00dc // indirect github.com/gorilla/css v1.0.0 // indirect github.com/hashicorp/errwrap v1.0.0 // indirect