Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ build: build-release
build-dev:
go build -o build/hawkling-dev ./cmd/hawkling

# リリースビルド(サイズ最適化)
# Release build (size optimization)
build-release:
CGO_ENABLED=0 go build -ldflags="-s -w" -o build/hawkling ./cmd/hawkling

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Hawkling is a command-line tool for managing AWS IAM roles, with a focus on iden
## Installation

```bash
curl -fsSL https://raw.githubusercontent.com/watany-dev/hawkling/main/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/watany-dev/hawkling/main/script/install.sh |sh
hawkling -h
```

Expand Down
30 changes: 29 additions & 1 deletion cmd/hawkling/commands/common.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,37 @@
package commands

import (
"bufio"
"fmt"
"os"
"strings"

"github.com/spf13/cobra"

"hawkling/pkg/errors"
)

// FilterOptions contains common filtering options used across commands
type FilterOptions struct {
Days int
OnlyUsed bool
OnlyUnused bool
}

// ConfirmAction prompts the user for confirmation and returns their response
func ConfirmAction(prompt string) (bool, error) {
fmt.Print(prompt)

reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil {
return false, errors.Wrap(err, "failed to read confirmation")
}

response = strings.TrimSpace(strings.ToLower(response))
return response == "y" || response == "yes", nil
}

// AddCommonFlags adds common flags to a command
func AddCommonFlags(cmd *cobra.Command, profile *string, region *string) {
cmd.PersistentFlags().StringVarP(profile, "profile", "p", "", "AWS profile to use")
Expand All @@ -12,7 +40,7 @@ func AddCommonFlags(cmd *cobra.Command, profile *string, region *string) {

// AddFilterFlags adds filtering flags to a command
func AddFilterFlags(cmd *cobra.Command, days *int, onlyUsed *bool, onlyUnused *bool) {
cmd.Flags().IntVarP(days, "days", "d", 90, "Number of days to consider for usage")
cmd.Flags().IntVarP(days, "days", "d", 0, "Number of days to consider for usage")
cmd.Flags().BoolVar(onlyUsed, "used", false, "Show only used roles")
cmd.Flags().BoolVar(onlyUnused, "unused", false, "Show only unused roles")
}
Expand Down
54 changes: 52 additions & 2 deletions cmd/hawkling/commands/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ package commands
import (
"context"
"fmt"

"hawkling/pkg/aws"
"hawkling/pkg/errors"
)

// DeleteOptions contains options for the delete command
Expand Down Expand Up @@ -31,7 +34,54 @@ func NewDeleteCommand(profile, region, roleName string, options DeleteOptions) *

// Execute runs the delete command
func (c *DeleteCommand) Execute(ctx context.Context) error {
// Implementation would go here
fmt.Printf("Deleting IAM role: %s\n", c.roleName)
client, err := aws.NewAWSClient(ctx, c.profile, c.region)
if err != nil {
return errors.Wrap(err, "failed to create AWS client")
}

// Get the role to ensure it exists
roles, err := client.ListRoles(ctx)
if err != nil {
return errors.Wrap(err, "failed to list roles")
}

var targetRole *aws.Role
for i, role := range roles {
if role.Name == c.roleName {
targetRole = &roles[i]
break
}
}

if targetRole == nil {
return errors.Errorf("role '%s' not found", c.roleName)
}

// If dry run, just show what would be deleted
if c.options.DryRun {
fmt.Printf("DRY RUN: Would delete IAM role: %s\n", c.roleName)
return nil
}

// Confirm deletion if force flag is not set
if !c.options.Force {
prompt := fmt.Sprintf("Are you sure you want to delete role '%s'? This cannot be undone. [y/N]: ", c.roleName)
confirmed, err := ConfirmAction(prompt)
if err != nil {
return errors.Wrap(err, "failed to read confirmation")
}

if !confirmed {
fmt.Println("Deletion cancelled")
return nil
}
}

// Delete the role
if err := client.DeleteRole(ctx, c.roleName); err != nil {
return errors.Wrap(err, "failed to delete role")
}

fmt.Printf("Successfully deleted IAM role: %s\n", c.roleName)
return nil
}
42 changes: 34 additions & 8 deletions cmd/hawkling/commands/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@ package commands

import (
"context"
"fmt"
"strings"

"hawkling/pkg/aws"
"hawkling/pkg/errors"
"hawkling/pkg/formatter"
)

// ListOptions contains options for the list command
type ListOptions struct {
Days int
Output string
ShowAll bool
OnlyUsed bool
OnlyUnused bool
FilterOptions
Output string
ShowAll bool
}

// ListCommand represents the list command
Expand All @@ -32,7 +34,31 @@ func NewListCommand(profile, region string, options ListOptions) *ListCommand {

// Execute runs the list command
func (c *ListCommand) Execute(ctx context.Context) error {
// Implementation would go here
fmt.Println("Listing IAM roles")
client, err := aws.NewAWSClient(ctx, c.profile, c.region)
if err != nil {
return errors.Wrap(err, "failed to create AWS client")
}

roles, err := client.ListRoles(ctx)
if err != nil {
return errors.Wrap(err, "failed to list roles")
}

// Filter roles if needed
filterOptions := aws.FilterOptions{
Days: c.options.FilterOptions.Days,
OnlyUsed: c.options.FilterOptions.OnlyUsed,
OnlyUnused: c.options.FilterOptions.OnlyUnused,
}

// Use unified filter implementation
roles = aws.FilterRoles(roles, filterOptions)

// Format output
format := formatter.Format(strings.ToLower(c.options.Output))
if err := formatter.FormatRoles(roles, format, c.options.ShowAll); err != nil {
return errors.Wrap(err, "failed to format output")
}

return nil
}
92 changes: 89 additions & 3 deletions cmd/hawkling/commands/prune.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ package commands
import (
"context"
"fmt"
"strings"

"hawkling/pkg/aws"
"hawkling/pkg/errors"
)

// PruneOptions contains options for the prune command
type PruneOptions struct {
Days int
FilterOptions
DryRun bool
Force bool
}
Expand All @@ -30,7 +34,89 @@ func NewPruneCommand(profile, region string, options PruneOptions) *PruneCommand

// Execute runs the prune command
func (c *PruneCommand) Execute(ctx context.Context) error {
// Implementation would go here
fmt.Println("Pruning unused IAM roles")
client, err := aws.NewAWSClient(ctx, c.profile, c.region)
if err != nil {
return errors.Wrap(err, "failed to create AWS client")
}

// Get all roles
roles, err := client.ListRoles(ctx)
if err != nil {
return errors.Wrap(err, "failed to list roles")
}

// Find roles based on the specified options
filterOptions := aws.FilterOptions{
Days: c.options.FilterOptions.Days,
OnlyUnused: c.options.FilterOptions.OnlyUnused,
OnlyUsed: c.options.FilterOptions.OnlyUsed,
}

filteredRoles := aws.FilterRoles(roles, filterOptions)

if len(filteredRoles) == 0 {
fmt.Println("No IAM roles found matching criteria")
return nil
}

// Show filtered roles
message := "Found %d IAM roles"
if c.options.FilterOptions.OnlyUnused {
message = "Found %d unused IAM roles (not used in the last %d days)"
} else if c.options.FilterOptions.OnlyUsed {
message = "Found %d used IAM roles"
if c.options.FilterOptions.Days > 0 {
message += " (not used in the last %d days)"
}
} else if c.options.FilterOptions.Days > 0 {
message = "Found %d IAM roles (not used in the last %d days)"
}

if strings.Contains(message, "%d days") {
fmt.Printf(message+":\n", len(filteredRoles), c.options.FilterOptions.Days)
} else {
fmt.Printf(message+":\n", len(filteredRoles))
}
for i, role := range filteredRoles {
fmt.Printf("%d. %s\n", i+1, role.Name)
}

// If dry run, stop here
if c.options.DryRun {
fmt.Println("\nDRY RUN: No roles were deleted")
return nil
}

// Confirm deletion if force flag is not set
if !c.options.Force {
prompt := fmt.Sprintf("\nAre you sure you want to delete %d roles? This cannot be undone. [y/N]: ", len(filteredRoles))
confirmed, err := ConfirmAction(prompt)
if err != nil {
return errors.Wrap(err, "failed to read confirmation")
}

if !confirmed {
fmt.Println("Deletion cancelled")
return nil
}
}

// Delete the filtered roles
var failedRoles []string
for _, role := range filteredRoles {
if err := client.DeleteRole(ctx, role.Name); err != nil {
failedRoles = append(failedRoles, role.Name)
fmt.Printf("Failed to delete role %s: %v\n", role.Name, err)
} else {
fmt.Printf("Deleted role: %s\n", role.Name)
}
}

if len(failedRoles) > 0 {
fmt.Printf("\nFailed to delete %d roles: %s\n", len(failedRoles), strings.Join(failedRoles, ", "))
return errors.Errorf("failed to delete %d roles", len(failedRoles))
}

fmt.Printf("\nSuccessfully deleted %d IAM roles\n", len(filteredRoles))
return nil
}
27 changes: 20 additions & 7 deletions cmd/hawkling/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,13 @@ Complete documentation is available at https://github.com/watany-dev/hawkling`,
Short: "List IAM roles, optionally filtering for unused roles",
RunE: func(cmd *cobra.Command, args []string) error {
listOptions := commands.ListOptions{
Days: listDays,
Output: output,
ShowAll: showAllInfo,
OnlyUsed: onlyUsed,
OnlyUnused: onlyUnused,
FilterOptions: commands.FilterOptions{
Days: listDays,
OnlyUsed: onlyUsed,
OnlyUnused: onlyUnused,
},
Output: output,
ShowAll: showAllInfo,
}

listCmd := commands.NewListCommand(profile, region, listOptions)
Expand Down Expand Up @@ -84,12 +86,18 @@ Complete documentation is available at https://github.com/watany-dev/hawkling`,

// Prune command
var pruneDays int
var pruneOnlyUnused bool
var pruneOnlyUsed bool
pruneCmd := &cobra.Command{
Use: "prune",
Short: "Delete all unused IAM roles",
Short: "Delete IAM roles based on specified criteria",
RunE: func(cmd *cobra.Command, args []string) error {
pruneOptions := commands.PruneOptions{
Days: pruneDays,
FilterOptions: commands.FilterOptions{
Days: pruneDays,
OnlyUnused: pruneOnlyUnused,
OnlyUsed: pruneOnlyUsed,
},
DryRun: dryRun,
Force: force,
}
Expand All @@ -99,6 +107,11 @@ Complete documentation is available at https://github.com/watany-dev/hawkling`,
},
}
commands.AddPruneFlags(pruneCmd, &pruneDays, &dryRun, &force)
pruneCmd.Flags().BoolVar(&pruneOnlyUnused, "unused", false, "Delete only unused roles")
pruneCmd.Flags().BoolVar(&pruneOnlyUsed, "used", false, "Delete only used roles")

// Add commands to root command
rootCmd.AddCommand(listCmd, deleteCmd, pruneCmd)

return rootCmd
}
20 changes: 19 additions & 1 deletion pkg/aws/awsclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,31 @@ import (
"github.com/schollz/progressbar/v3"
)

// For testing
var testClient IAMClient

// SetTestClient sets a test client for unit testing
func SetTestClient(client IAMClient) {
testClient = client
}

// ClearTestClient clears the test client after tests
func ClearTestClient() {
testClient = nil
}

// AWSclient implements the IAMClient interface
type AWSClient struct {
iamClient *iam.Client
}

// NewAWSClient creates a new AWS client with the specified profile and region
func NewAWSClient(ctx context.Context, profile, region string) (*AWSClient, error) {
func NewAWSClient(ctx context.Context, profile, region string) (IAMClient, error) {
// If we're in test mode, return the test client
if testClient != nil {
return testClient, nil
}

var cfg aws.Config
var err error

Expand Down
Loading