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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ A terminal UI for AWS resource management
## Features

- **Interactive TUI** - Navigate AWS resources with vim-style keybindings
- **69 services, 163 resources** - EC2, S3, Lambda, RDS, ECS, and more
- **69 services, 169 resources** - EC2, S3, Lambda, RDS, ECS, EKS, and more
- **Multi-profile & Multi-region** - Query multiple accounts/regions in parallel
- **Resource actions** - Start/stop instances, delete resources, tail logs
- **Cross-resource navigation** - Jump from VPC to subnets, Lambda to CloudWatch
Expand Down
57 changes: 45 additions & 12 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,6 @@ version: '3'
vars:
LOCALSTACK_CONTAINER: claws-localstack

# Shared environment for LocalStack tasks
env: &localstack-env
AWS_ENDPOINT_URL: http://localhost:4566
AWS_ACCESS_KEY_ID: test
AWS_SECRET_ACCESS_KEY: test
AWS_DEFAULT_REGION: us-east-1
AWS_EC2_METADATA_DISABLED: "true"

tasks:
build:
desc: Build the claws binary
Expand Down Expand Up @@ -74,20 +66,35 @@ tasks:
localstack:demo-setup:
desc: Create demo resources in LocalStack (VPCs, EC2, S3)
deps: [localstack:start]
env: *localstack-env
env:
AWS_ENDPOINT_URL: http://localhost:4566
AWS_ACCESS_KEY_ID: test
AWS_SECRET_ACCESS_KEY: test
AWS_DEFAULT_REGION: us-east-1
AWS_EC2_METADATA_DISABLED: "true"
cmds:
- ./scripts/localstack-demo-setup.sh

localstack:demo-cleanup:
desc: Cleanup demo resources from LocalStack
env: *localstack-env
env:
AWS_ENDPOINT_URL: http://localhost:4566
AWS_ACCESS_KEY_ID: test
AWS_SECRET_ACCESS_KEY: test
AWS_DEFAULT_REGION: us-east-1
AWS_EC2_METADATA_DISABLED: "true"
cmds:
- ./scripts/localstack-demo-cleanup.sh

localstack:demo:
desc: Run claws against LocalStack
deps: [build, localstack:start]
env: *localstack-env
env:
AWS_ENDPOINT_URL: http://localhost:4566
AWS_ACCESS_KEY_ID: test
AWS_SECRET_ACCESS_KEY: test
AWS_DEFAULT_REGION: us-east-1
AWS_EC2_METADATA_DISABLED: "true"
cmds:
- ./claws

Expand Down Expand Up @@ -150,7 +157,12 @@ tasks:
test-localstack:
desc: Run integration tests with LocalStack
deps: [localstack:start]
env: *localstack-env
env:
AWS_ENDPOINT_URL: http://localhost:4566
AWS_ACCESS_KEY_ID: test
AWS_SECRET_ACCESS_KEY: test
AWS_DEFAULT_REGION: us-east-1
AWS_EC2_METADATA_DISABLED: "true"
cmds:
- go test -v ./... -tags=integration -timeout 60s

Expand Down Expand Up @@ -198,3 +210,24 @@ tasks:
git push origin "$VERSION"
echo "Release $VERSION created and pushed!"
echo "GitHub Actions will build and publish the release."

# EKS Integration Test tasks
eks-test-up:
desc: Deploy EKS test stack for integration testing (~15-20 min)
dir: custom/eks/test-fixtures/cloudformation
cmds:
- ./deploy.sh

eks-test-down:
desc: Clean up EKS test stack (~10-15 min)
dir: custom/eks/test-fixtures/cloudformation
cmds:
- ./cleanup.sh

eks-test:
desc: Full EKS integration test (deploy, test, cleanup)
cmds:
- task: eks-test-up
- |
echo "Stack deployed. Test with claws manually, then run 'task eks-test-down' to cleanup."
echo "Example: AWS_REGION=us-east-1 claws"
7 changes: 7 additions & 0 deletions cmd/claws/imports_custom.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions custom/autoscaling/groups/dao.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,20 @@ func NewAutoScalingGroupDAO(ctx context.Context) (dao.DAO, error) {

// List returns all Auto Scaling Groups
func (d *AutoScalingGroupDAO) List(ctx context.Context) ([]dao.Resource, error) {
// Check for AutoScalingGroupName filter (for navigation from child resources)
if asgName := dao.GetFilterFromContext(ctx, "AutoScalingGroupName"); asgName != "" {
// Direct lookup for specific ASG
asg, err := d.Get(ctx, asgName)
if err != nil {
// If not found, return empty list (not an error for filtering)
if apperrors.IsNotFound(err) {
return []dao.Resource{}, nil
}
return nil, err
}
return []dao.Resource{asg}, nil
}

asgs, err := appaws.Paginate(ctx, func(token *string) ([]types.AutoScalingGroup, *string, error) {
output, err := d.client.DescribeAutoScalingGroups(ctx, &autoscaling.DescribeAutoScalingGroupsInput{
NextToken: token,
Expand Down
2 changes: 1 addition & 1 deletion custom/autoscaling/groups/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ func (r *AutoScalingGroupRenderer) Navigations(resource dao.Resource) []render.N

return []render.Navigation{
{
Key: "a", Label: "Activities", Service: "autoscaling", Resource: "activities",
Key: "g", Label: "Activities", Service: "autoscaling", Resource: "activities",
FilterField: "AutoScalingGroupName", FilterValue: rr.AutoScalingGroupName(),
},
{
Expand Down
21 changes: 21 additions & 0 deletions custom/ec2/key-pairs/dao.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,27 @@ func NewKeyPairDAO(ctx context.Context) (dao.DAO, error) {
}

func (d *KeyPairDAO) List(ctx context.Context) ([]dao.Resource, error) {
// Check for KeyName filter (for navigation from child resources)
if keyName := dao.GetFilterFromContext(ctx, "KeyName"); keyName != "" {
// DescribeKeyPairs supports KeyNames filter directly
output, err := d.client.DescribeKeyPairs(ctx, &ec2.DescribeKeyPairsInput{
KeyNames: []string{keyName},
})
if err != nil {
// If not found, return empty list (not an error for filtering)
if apperrors.IsNotFound(err) {
return []dao.Resource{}, nil
}
return nil, apperrors.Wrap(err, "describe key pairs")
}

var resources []dao.Resource
for _, kp := range output.KeyPairs {
resources = append(resources, NewKeyPairResource(kp))
}
return resources, nil
}

output, err := d.client.DescribeKeyPairs(ctx, &ec2.DescribeKeyPairsInput{})
if err != nil {
return nil, apperrors.Wrap(err, "describe key pairs")
Expand Down
14 changes: 14 additions & 0 deletions custom/ec2/launch-templates/dao.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,20 @@ func NewLaunchTemplateDAO(ctx context.Context) (dao.DAO, error) {

// List returns all Launch Templates
func (d *LaunchTemplateDAO) List(ctx context.Context) ([]dao.Resource, error) {
// Check for LaunchTemplateId filter (for navigation from child resources)
if ltID := dao.GetFilterFromContext(ctx, "LaunchTemplateId"); ltID != "" {
// Direct lookup for specific launch template
lt, err := d.Get(ctx, ltID)
if err != nil {
// If not found, return empty list (not an error for filtering)
if apperrors.IsNotFound(err) {
return []dao.Resource{}, nil
}
return nil, err
}
return []dao.Resource{lt}, nil
}

templates, err := appaws.Paginate(ctx, func(token *string) ([]types.LaunchTemplate, *string, error) {
output, err := d.client.DescribeLaunchTemplates(ctx, &ec2.DescribeLaunchTemplatesInput{
NextToken: token,
Expand Down
2 changes: 1 addition & 1 deletion custom/ecs/services/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,7 @@ func (r *ServiceRenderer) Navigations(resource dao.Resource) []render.Navigation
FilterValue: svc.GetName(),
},
{
Key: "c",
Key: "p",
Label: "Cluster",
Service: "ecs",
Resource: "clusters",
Expand Down
2 changes: 1 addition & 1 deletion custom/ecs/tasks/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ func (r *TaskRenderer) Navigations(resource dao.Resource) []render.Navigation {

navs := []render.Navigation{
{
Key: "c",
Key: "p",
Label: "Cluster",
Service: "ecs",
Resource: "clusters",
Expand Down
7 changes: 7 additions & 0 deletions custom/eks/access-entries/constants.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

151 changes: 151 additions & 0 deletions custom/eks/access-entries/dao.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package accessentries

import (
"context"
"fmt"

"github.com/aws/aws-sdk-go-v2/service/eks"
"github.com/aws/aws-sdk-go-v2/service/eks/types"

appaws "github.com/clawscli/claws/internal/aws"
"github.com/clawscli/claws/internal/dao"
apperrors "github.com/clawscli/claws/internal/errors"
"github.com/clawscli/claws/internal/render"
)

// AccessEntryDAO provides data access for EKS access entries
type AccessEntryDAO struct {
dao.BaseDAO
client *eks.Client
}

// NewAccessEntryDAO creates a new AccessEntryDAO
func NewAccessEntryDAO(ctx context.Context) (dao.DAO, error) {
cfg, err := appaws.NewConfig(ctx)
if err != nil {
return nil, apperrors.Wrap(err, "new eks/access-entries dao")
}
return &AccessEntryDAO{
BaseDAO: dao.NewBaseDAO("eks", "access-entries"),
client: eks.NewFromConfig(cfg),
}, nil
}

func (d *AccessEntryDAO) List(ctx context.Context) ([]dao.Resource, error) {
clusterName := dao.GetFilterFromContext(ctx, "ClusterName")
if clusterName == "" {
return nil, fmt.Errorf("ClusterName filter required")
}

principalArns, err := appaws.Paginate(ctx, func(token *string) ([]string, *string, error) {
output, err := d.client.ListAccessEntries(ctx, &eks.ListAccessEntriesInput{
ClusterName: &clusterName,
NextToken: token,
})
if err != nil {
return nil, nil, apperrors.Wrap(err, "list access entries")
}
return output.AccessEntries, output.NextToken, nil
})
if err != nil {
return nil, err
}

if len(principalArns) == 0 {
return nil, nil
}

resources := make([]dao.Resource, 0, len(principalArns))
for _, arn := range principalArns {
output, err := d.client.DescribeAccessEntry(ctx, &eks.DescribeAccessEntryInput{
ClusterName: &clusterName,
PrincipalArn: &arn,
})
if err != nil {
if apperrors.IsNotFound(err) {
continue
}
return nil, apperrors.Wrapf(err, "describe access entry %s", arn)
}
if output.AccessEntry != nil {
resources = append(resources, NewAccessEntryResource(*output.AccessEntry))
}
}

return resources, nil
}

func (d *AccessEntryDAO) Get(ctx context.Context, id string) (dao.Resource, error) {
clusterName := dao.GetFilterFromContext(ctx, "ClusterName")
if clusterName == "" {
return nil, fmt.Errorf("ClusterName filter required")
}

output, err := d.client.DescribeAccessEntry(ctx, &eks.DescribeAccessEntryInput{
ClusterName: &clusterName,
PrincipalArn: &id,
})
if err != nil {
return nil, apperrors.Wrapf(err, "describe access entry %s", id)
}

if output.AccessEntry == nil {
return nil, fmt.Errorf("access entry not found: %s", id)
}

return NewAccessEntryResource(*output.AccessEntry), nil
}

func (d *AccessEntryDAO) Delete(ctx context.Context, id string) error {
clusterName := dao.GetFilterFromContext(ctx, "ClusterName")
if clusterName == "" {
return fmt.Errorf("ClusterName filter required")
}

_, err := d.client.DeleteAccessEntry(ctx, &eks.DeleteAccessEntryInput{
ClusterName: &clusterName,
PrincipalArn: &id,
})
if err != nil {
if apperrors.IsNotFound(err) {
return nil
}
return apperrors.Wrapf(err, "delete access entry %s", id)
}
return nil
}

// AccessEntryResource represents an EKS access entry resource
type AccessEntryResource struct {
dao.BaseResource
AccessEntry types.AccessEntry
}

// NewAccessEntryResource creates a new AccessEntryResource
func NewAccessEntryResource(ae types.AccessEntry) *AccessEntryResource {
principalArn := appaws.Str(ae.PrincipalArn)
return &AccessEntryResource{
BaseResource: dao.BaseResource{
ID: principalArn,
Name: principalArn,
ARN: appaws.Str(ae.AccessEntryArn),
Data: ae,
},
AccessEntry: ae,
}
}

// Type returns access entry type
func (r *AccessEntryResource) Type() string {
return appaws.Str(r.AccessEntry.Type)
}

// Username returns Kubernetes username
func (r *AccessEntryResource) Username() string {
return appaws.Str(r.AccessEntry.Username)
}

// CreatedAge returns age since creation
func (r *AccessEntryResource) CreatedAge() string {
return render.FormatAge(appaws.Time(r.AccessEntry.CreatedAt))
}
20 changes: 20 additions & 0 deletions custom/eks/access-entries/register.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package accessentries

import (
"context"

"github.com/clawscli/claws/internal/dao"
"github.com/clawscli/claws/internal/registry"
"github.com/clawscli/claws/internal/render"
)

func init() {
registry.Global.RegisterCustom("eks", "access-entries", registry.Entry{
DAOFactory: func(ctx context.Context) (dao.DAO, error) {
return NewAccessEntryDAO(ctx)
},
RendererFactory: func() render.Renderer {
return NewAccessEntryRenderer()
},
})
}
Loading
Loading