From b02abb8d496c6b99a5808aead826953f7b36e461 Mon Sep 17 00:00:00 2001 From: yimsk Date: Sat, 10 Jan 2026 16:13:05 +0900 Subject: [PATCH] Add EKS support (5 resources) + navigation fixes (#128) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add EKS support: 5 resources (clusters, node-groups, fargate-profiles, addons, access-entries) - clusters: standalone, 11 detail sections - node-groups: sub-resource, scaling/health info - fargate-profiles: sub-resource, selectors/subnets - addons: sub-resource, versions/config - access-entries: sub-resource, IAM→K8s mapping - registry: add EKS to Compute category - SDK: aws-sdk-go-v2/service/eks v1.76.3 Total: 20 files, ~1807 lines * Fix EKS code review issues - node-groups: Return all instance types (not just first) - registry: Add EKS default resource (clusters) * Add EKS navigation + test fixtures + fix global key conflicts - EKS: Full navigation for all 5 resources (clusters, node-groups, fargate-profiles, addons, access-entries) - Parent cluster (p), IAM roles (r), CloudWatch logs (l), VPC/SG (v/s), ASG/LaunchTemplate (g/t) - Add EKS test fixtures: CloudFormation template + deploy/cleanup scripts for integration testing - Add LocalStack tasks for EKS demo data - Fix global key conflicts: c→p (Clear filter), a→o/g/t (Actions menu), m→s (Mark resource) - Affected: ECS, RDS, autoscaling, stepfunctions, transit-gateways * Fix IAM role/user navigation filtering Add RoleName/UserName filter support to enable navigation from other resources (EKS, EC2, Lambda, ECS, etc). Changes: - roles: Check RoleName filter in ListPage(), direct Get() lookup - users: Check UserName filter in List(), direct Get() lookup - Return empty list if not found (not error, for filter behavior) Fixes: Navigation failure when pressing 'r' on EKS clusters/nodes/fargate/addons * Improve EKS test stack cleanup automation Enhance cleanup.sh reliability w/ auto-retry & auth fix: - Auto-retry on DELETE_FAILED (max 2 attempts) - Fix auth mode to API before deletion (resolves deletion blocks) - CI_MODE env var for non-interactive cleanup - Update README: document retry behavior, env vars (CI_MODE, MAX_RETRY) Production-grade improvements for CI/CD integration. * Fix navigation filters: EKS parent nav + filter clear reload - EKS clusters: Add ClusterName filter for child->parent nav - IAM roles: Move filter check List()->consistent w/ users/clusters - Resource browser: Reload on 'c' (fix filtered DAO cache) * Fix deprecated strings.Title in EKS access-entries * Fix navigation: unwrap resource for type assertion Navigation broken - Navigations() returned empty array due to failed type assertion. Root cause: handleNavigation() used wrapped resource from contextForResource(), but Navigations() methods expect unwrapped concrete types (e.g. *ClusterResource). Fix: Use dao.UnwrapResource() like getNavigationShortcuts() does. Test: All resource navigation keys (n/f/a/e/r/l/v/s/g/t/o/p) work. * Add filter support for ASG/LaunchTemplate/KeyPair navigation Enables filtered navigation from EKS node-groups. DAOs now check GetFilterFromContext for AutoScalingGroupName, LaunchTemplateId, KeyName filters, returning single resource instead of full list. * Fix IsNotFound(): match lowercase 'not found' from DAO errors 79 DAOs use fmt.Errorf('resource not found: %s', id) (lowercase). IsNotFound() only checked AWS SDK codes + 'NotFound' (capital N). Filter navigation (ASG/EC2/IAM/EKS) relies on IsNotFound() -> failed on non-existent resources. Add 'not found' to hasErrorCode() -> 1 line fix, no user-facing change. * Fix ResourceBrowser: unwrap resources for ActionMenu/DiffView * Update docs: reflect 169 resources (EKS support) - Update README.md: 163→169 resources, add EKS - Regenerate imports_custom.go via task gen-imports * Update services.md: add EKS and ECS task-definitions - Update resource count: 163→169 - Add EKS to Containers & ML section (5 resources) - Add Task Definitions to ECS - Add eks alias * Fix DiffView AI chat: preserve region/profile metadata Problem: commit 2780b20 broke AI chat in diff mode by unwrapping resources before passing to DiffView, losing region/profile info. Solution: DiffView now stores both wrapped (for metadata) and unwrapped (for rendering) resources. buildAIContext() gets correct region/profile from wrapped resources via buildResourceRef(). Files: internal/view/{diff_view,resource_browser_input}.go --- README.md | 2 +- Taskfile.yml | 57 ++- cmd/claws/imports_custom.go | 7 + custom/autoscaling/groups/dao.go | 14 + custom/autoscaling/groups/render.go | 2 +- custom/ec2/key-pairs/dao.go | 21 + custom/ec2/launch-templates/dao.go | 14 + custom/ecs/services/render.go | 2 +- custom/ecs/tasks/render.go | 2 +- custom/eks/access-entries/constants.go | 7 + custom/eks/access-entries/dao.go | 151 +++++++ custom/eks/access-entries/register.go | 20 + custom/eks/access-entries/render.go | 184 ++++++++ custom/eks/addons/constants.go | 7 + custom/eks/addons/dao.go | 150 +++++++ custom/eks/addons/register.go | 20 + custom/eks/addons/render.go | 198 +++++++++ custom/eks/clusters/constants.go | 7 + custom/eks/clusters/dao.go | 157 +++++++ custom/eks/clusters/register.go | 20 + custom/eks/clusters/render.go | 341 ++++++++++++++ custom/eks/fargate-profiles/constants.go | 7 + custom/eks/fargate-profiles/dao.go | 145 ++++++ custom/eks/fargate-profiles/register.go | 20 + custom/eks/fargate-profiles/render.go | 171 +++++++ custom/eks/node-groups/constants.go | 7 + custom/eks/node-groups/dao.go | 188 ++++++++ custom/eks/node-groups/register.go | 20 + custom/eks/node-groups/render.go | 326 ++++++++++++++ custom/eks/test-fixtures/README.md | 214 +++++++++ .../test-fixtures/cloudformation/cleanup.sh | 188 ++++++++ .../test-fixtures/cloudformation/deploy.sh | 114 +++++ .../cloudformation/eks-test-stack.yaml | 420 ++++++++++++++++++ custom/iam/roles/dao.go | 14 + custom/iam/users/dao.go | 14 + custom/rds/instances/render.go | 2 +- custom/stepfunctions/executions/render.go | 2 +- custom/vpc/transit-gateways/render.go | 2 +- docs/services.md | 6 +- go.mod | 1 + go.sum | 2 + internal/aws/helpers.go | 1 + internal/errors/errors.go | 1 + internal/registry/registry.go | 8 +- internal/view/diff_view.go | 23 +- internal/view/resource_browser_input.go | 8 +- internal/view/resource_browser_nav.go | 3 +- 47 files changed, 3254 insertions(+), 36 deletions(-) create mode 100644 custom/eks/access-entries/constants.go create mode 100644 custom/eks/access-entries/dao.go create mode 100644 custom/eks/access-entries/register.go create mode 100644 custom/eks/access-entries/render.go create mode 100644 custom/eks/addons/constants.go create mode 100644 custom/eks/addons/dao.go create mode 100644 custom/eks/addons/register.go create mode 100644 custom/eks/addons/render.go create mode 100644 custom/eks/clusters/constants.go create mode 100644 custom/eks/clusters/dao.go create mode 100644 custom/eks/clusters/register.go create mode 100644 custom/eks/clusters/render.go create mode 100644 custom/eks/fargate-profiles/constants.go create mode 100644 custom/eks/fargate-profiles/dao.go create mode 100644 custom/eks/fargate-profiles/register.go create mode 100644 custom/eks/fargate-profiles/render.go create mode 100644 custom/eks/node-groups/constants.go create mode 100644 custom/eks/node-groups/dao.go create mode 100644 custom/eks/node-groups/register.go create mode 100644 custom/eks/node-groups/render.go create mode 100644 custom/eks/test-fixtures/README.md create mode 100755 custom/eks/test-fixtures/cloudformation/cleanup.sh create mode 100755 custom/eks/test-fixtures/cloudformation/deploy.sh create mode 100644 custom/eks/test-fixtures/cloudformation/eks-test-stack.yaml diff --git a/README.md b/README.md index f6e7fcf9..fa1a8cc1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Taskfile.yml b/Taskfile.yml index c31b5055..f73547c7 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -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 @@ -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 @@ -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 @@ -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" diff --git a/cmd/claws/imports_custom.go b/cmd/claws/imports_custom.go index c716c173..7801152d 100644 --- a/cmd/claws/imports_custom.go +++ b/cmd/claws/imports_custom.go @@ -153,6 +153,13 @@ import ( _ "github.com/clawscli/claws/custom/ecs/task-definitions" _ "github.com/clawscli/claws/custom/ecs/tasks" + // EKS + _ "github.com/clawscli/claws/custom/eks/access-entries" + _ "github.com/clawscli/claws/custom/eks/addons" + _ "github.com/clawscli/claws/custom/eks/clusters" + _ "github.com/clawscli/claws/custom/eks/fargate-profiles" + _ "github.com/clawscli/claws/custom/eks/node-groups" + // ElastiCache _ "github.com/clawscli/claws/custom/elasticache/clusters" diff --git a/custom/autoscaling/groups/dao.go b/custom/autoscaling/groups/dao.go index 2cc15bb5..f8dc09cc 100644 --- a/custom/autoscaling/groups/dao.go +++ b/custom/autoscaling/groups/dao.go @@ -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, diff --git a/custom/autoscaling/groups/render.go b/custom/autoscaling/groups/render.go index 89b889dd..fea6544e 100644 --- a/custom/autoscaling/groups/render.go +++ b/custom/autoscaling/groups/render.go @@ -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(), }, { diff --git a/custom/ec2/key-pairs/dao.go b/custom/ec2/key-pairs/dao.go index 2689b293..3e6c33bb 100644 --- a/custom/ec2/key-pairs/dao.go +++ b/custom/ec2/key-pairs/dao.go @@ -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") diff --git a/custom/ec2/launch-templates/dao.go b/custom/ec2/launch-templates/dao.go index 1062f569..1bae9b13 100644 --- a/custom/ec2/launch-templates/dao.go +++ b/custom/ec2/launch-templates/dao.go @@ -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, diff --git a/custom/ecs/services/render.go b/custom/ecs/services/render.go index 70e90835..ca52c374 100644 --- a/custom/ecs/services/render.go +++ b/custom/ecs/services/render.go @@ -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", diff --git a/custom/ecs/tasks/render.go b/custom/ecs/tasks/render.go index 96c78cf3..97a0e3fa 100644 --- a/custom/ecs/tasks/render.go +++ b/custom/ecs/tasks/render.go @@ -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", diff --git a/custom/eks/access-entries/constants.go b/custom/eks/access-entries/constants.go new file mode 100644 index 00000000..91b4e8e6 --- /dev/null +++ b/custom/eks/access-entries/constants.go @@ -0,0 +1,7 @@ +// Code generated by go generate; DO NOT EDIT. +// To regenerate: task gen-imports + +package accessentries + +// ServiceResourcePath is the canonical path for this resource type. +const ServiceResourcePath = "eks/access-entries" diff --git a/custom/eks/access-entries/dao.go b/custom/eks/access-entries/dao.go new file mode 100644 index 00000000..8c596104 --- /dev/null +++ b/custom/eks/access-entries/dao.go @@ -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)) +} diff --git a/custom/eks/access-entries/register.go b/custom/eks/access-entries/register.go new file mode 100644 index 00000000..df21fc53 --- /dev/null +++ b/custom/eks/access-entries/register.go @@ -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() + }, + }) +} diff --git a/custom/eks/access-entries/render.go b/custom/eks/access-entries/render.go new file mode 100644 index 00000000..54d4b6c5 --- /dev/null +++ b/custom/eks/access-entries/render.go @@ -0,0 +1,184 @@ +package accessentries + +import ( + "encoding/json" + "strings" + + appaws "github.com/clawscli/claws/internal/aws" + "github.com/clawscli/claws/internal/dao" + "github.com/clawscli/claws/internal/render" +) + +// AccessEntryRenderer renders EKS access entry resources +type AccessEntryRenderer struct { + render.BaseRenderer +} + +var _ render.Navigator = (*AccessEntryRenderer)(nil) + +// NewAccessEntryRenderer creates a new AccessEntryRenderer +func NewAccessEntryRenderer() render.Renderer { + return &AccessEntryRenderer{ + BaseRenderer: render.BaseRenderer{ + Service: "eks", + Resource: "access-entries", + Cols: []render.Column{ + { + Name: "PRINCIPAL ARN", + Width: 60, + Priority: 0, + Getter: func(r dao.Resource) string { + return r.GetName() + }, + }, + { + Name: "TYPE", + Width: 20, + Priority: 1, + Getter: func(r dao.Resource) string { + if aer, ok := r.(*AccessEntryResource); ok { + return aer.Type() + } + return "" + }, + }, + { + Name: "USERNAME", + Width: 30, + Priority: 2, + Getter: func(r dao.Resource) string { + if aer, ok := r.(*AccessEntryResource); ok { + return aer.Username() + } + return "" + }, + }, + { + Name: "AGE", + Width: 10, + Priority: 3, + Getter: func(r dao.Resource) string { + if aer, ok := r.(*AccessEntryResource); ok { + return aer.CreatedAge() + } + return "" + }, + }, + }, + }, + } +} + +func (rnd *AccessEntryRenderer) RenderDetail(resource dao.Resource) string { + aer, ok := resource.(*AccessEntryResource) + if !ok { + return "" + } + + d := render.NewDetailBuilder() + d.Title("EKS Access Entry", aer.GetName()) + + // Basic Information + d.Section("Basic Information") + d.Field("Principal ARN", aer.GetName()) + d.Field("Access Entry ARN", aer.GetARN()) + d.Field("Cluster", appaws.Str(aer.AccessEntry.ClusterName)) + d.Field("Type", aer.Type()) + d.Field("Created", aer.CreatedAge()) + if modified := appaws.Time(aer.AccessEntry.ModifiedAt); !modified.IsZero() { + d.Field("Modified", render.FormatAge(modified)) + } + + // Kubernetes Identity + d.Section("Kubernetes Identity") + d.Field("Username", aer.Username()) + if len(aer.AccessEntry.KubernetesGroups) > 0 { + d.Field("Groups", strings.Join(aer.AccessEntry.KubernetesGroups, ", ")) + } else { + d.Field("Groups", render.Empty) + } + + // Tags + if len(aer.AccessEntry.Tags) > 0 { + d.Section("Tags") + d.Tags(aer.AccessEntry.Tags) + } + + // Full Details + d.Section("Full Details") + if jsonBytes, err := json.MarshalIndent(aer.AccessEntry, "", " "); err == nil { + d.Line(string(jsonBytes)) + } + + return d.String() +} + +func (rnd *AccessEntryRenderer) RenderSummary(resource dao.Resource) []render.SummaryField { + aer, ok := resource.(*AccessEntryResource) + if !ok { + return nil + } + + return []render.SummaryField{ + {Label: "Principal", Value: aer.GetName()}, + {Label: "Type", Value: aer.Type()}, + {Label: "Username", Value: aer.Username()}, + } +} + +func (rnd *AccessEntryRenderer) Navigations(resource dao.Resource) []render.Navigation { + aer, ok := resource.(*AccessEntryResource) + if !ok { + return nil + } + + var navs []render.Navigation + + // Parent cluster (always present) + if clusterName := appaws.Str(aer.AccessEntry.ClusterName); clusterName != "" { + navs = append(navs, render.Navigation{ + Key: "p", + Label: "Cluster", + Service: "eks", + Resource: "clusters", + FilterField: "ClusterName", + FilterValue: clusterName, + }) + } + + // IAM Principal (User or Role) + if principalArn := appaws.Str(aer.AccessEntry.PrincipalArn); principalArn != "" { + principalName := appaws.ExtractResourceName(principalArn) + + // Determine if it's a role or user from ARN + var service, resource string + if strings.Contains(principalArn, ":user/") { + service = "iam" + resource = "users" + } else if strings.Contains(principalArn, ":role/") { + service = "iam" + resource = "roles" + } + + if service != "" { + // Determine filter field name based on resource type + var filterField string + if resource == "users" { + filterField = "UserName" + } else { + filterField = "RoleName" + } + + navs = append(navs, render.Navigation{ + Key: "i", + Label: "IAM Principal", + Service: service, + Resource: resource, + FilterField: filterField, + FilterValue: principalName, + }) + } + } + + return navs +} diff --git a/custom/eks/addons/constants.go b/custom/eks/addons/constants.go new file mode 100644 index 00000000..25eaf5af --- /dev/null +++ b/custom/eks/addons/constants.go @@ -0,0 +1,7 @@ +// Code generated by go generate; DO NOT EDIT. +// To regenerate: task gen-imports + +package addons + +// ServiceResourcePath is the canonical path for this resource type. +const ServiceResourcePath = "eks/addons" diff --git a/custom/eks/addons/dao.go b/custom/eks/addons/dao.go new file mode 100644 index 00000000..17383f01 --- /dev/null +++ b/custom/eks/addons/dao.go @@ -0,0 +1,150 @@ +package addons + +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" +) + +// AddonDAO provides data access for EKS add-ons +type AddonDAO struct { + dao.BaseDAO + client *eks.Client +} + +// NewAddonDAO creates a new AddonDAO +func NewAddonDAO(ctx context.Context) (dao.DAO, error) { + cfg, err := appaws.NewConfig(ctx) + if err != nil { + return nil, apperrors.Wrap(err, "new eks/addons dao") + } + return &AddonDAO{ + BaseDAO: dao.NewBaseDAO("eks", "addons"), + client: eks.NewFromConfig(cfg), + }, nil +} + +func (d *AddonDAO) List(ctx context.Context) ([]dao.Resource, error) { + clusterName := dao.GetFilterFromContext(ctx, "ClusterName") + if clusterName == "" { + return nil, fmt.Errorf("ClusterName filter required") + } + + addonNames, err := appaws.Paginate(ctx, func(token *string) ([]string, *string, error) { + output, err := d.client.ListAddons(ctx, &eks.ListAddonsInput{ + ClusterName: &clusterName, + NextToken: token, + }) + if err != nil { + return nil, nil, apperrors.Wrap(err, "list addons") + } + return output.Addons, output.NextToken, nil + }) + if err != nil { + return nil, err + } + + if len(addonNames) == 0 { + return nil, nil + } + + resources := make([]dao.Resource, 0, len(addonNames)) + for _, name := range addonNames { + output, err := d.client.DescribeAddon(ctx, &eks.DescribeAddonInput{ + ClusterName: &clusterName, + AddonName: &name, + }) + if err != nil { + if apperrors.IsNotFound(err) { + continue + } + return nil, apperrors.Wrapf(err, "describe addon %s", name) + } + if output.Addon != nil { + resources = append(resources, NewAddonResource(*output.Addon)) + } + } + + return resources, nil +} + +func (d *AddonDAO) 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.DescribeAddon(ctx, &eks.DescribeAddonInput{ + ClusterName: &clusterName, + AddonName: &id, + }) + if err != nil { + return nil, apperrors.Wrapf(err, "describe addon %s", id) + } + + if output.Addon == nil { + return nil, fmt.Errorf("addon not found: %s", id) + } + + return NewAddonResource(*output.Addon), nil +} + +func (d *AddonDAO) Delete(ctx context.Context, id string) error { + clusterName := dao.GetFilterFromContext(ctx, "ClusterName") + if clusterName == "" { + return fmt.Errorf("ClusterName filter required") + } + + _, err := d.client.DeleteAddon(ctx, &eks.DeleteAddonInput{ + ClusterName: &clusterName, + AddonName: &id, + }) + if err != nil { + if apperrors.IsNotFound(err) { + return nil + } + return apperrors.Wrapf(err, "delete addon %s", id) + } + return nil +} + +// AddonResource represents an EKS add-on resource +type AddonResource struct { + dao.BaseResource + Addon types.Addon +} + +// NewAddonResource creates a new AddonResource +func NewAddonResource(addon types.Addon) *AddonResource { + return &AddonResource{ + BaseResource: dao.BaseResource{ + ID: appaws.Str(addon.AddonName), + Name: appaws.Str(addon.AddonName), + ARN: appaws.Str(addon.AddonArn), + Data: addon, + }, + Addon: addon, + } +} + +// Status returns addon status +func (r *AddonResource) Status() string { + return string(r.Addon.Status) +} + +// Version returns addon version +func (r *AddonResource) Version() string { + return appaws.Str(r.Addon.AddonVersion) +} + +// CreatedAge returns age since creation +func (r *AddonResource) CreatedAge() string { + return render.FormatAge(appaws.Time(r.Addon.CreatedAt)) +} diff --git a/custom/eks/addons/register.go b/custom/eks/addons/register.go new file mode 100644 index 00000000..b852fc9b --- /dev/null +++ b/custom/eks/addons/register.go @@ -0,0 +1,20 @@ +package addons + +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", "addons", registry.Entry{ + DAOFactory: func(ctx context.Context) (dao.DAO, error) { + return NewAddonDAO(ctx) + }, + RendererFactory: func() render.Renderer { + return NewAddonRenderer() + }, + }) +} diff --git a/custom/eks/addons/render.go b/custom/eks/addons/render.go new file mode 100644 index 00000000..0d4a7124 --- /dev/null +++ b/custom/eks/addons/render.go @@ -0,0 +1,198 @@ +package addons + +import ( + "encoding/json" + "fmt" + "strings" + + appaws "github.com/clawscli/claws/internal/aws" + "github.com/clawscli/claws/internal/dao" + "github.com/clawscli/claws/internal/render" +) + +// AddonRenderer renders EKS add-on resources +type AddonRenderer struct { + render.BaseRenderer +} + +var _ render.Navigator = (*AddonRenderer)(nil) + +// NewAddonRenderer creates a new AddonRenderer +func NewAddonRenderer() render.Renderer { + return &AddonRenderer{ + BaseRenderer: render.BaseRenderer{ + Service: "eks", + Resource: "addons", + Cols: []render.Column{ + { + Name: "NAME", + Width: 30, + Priority: 0, + Getter: func(r dao.Resource) string { + return r.GetName() + }, + }, + { + Name: "VERSION", + Width: 20, + Priority: 1, + Getter: func(r dao.Resource) string { + if ar, ok := r.(*AddonResource); ok { + return ar.Version() + } + return "" + }, + }, + { + Name: "STATUS", + Width: 15, + Priority: 2, + Getter: func(r dao.Resource) string { + if ar, ok := r.(*AddonResource); ok { + return ar.Status() + } + return "" + }, + }, + { + Name: "AGE", + Width: 10, + Priority: 3, + Getter: func(r dao.Resource) string { + if ar, ok := r.(*AddonResource); ok { + return ar.CreatedAge() + } + return "" + }, + }, + }, + }, + } +} + +func (rnd *AddonRenderer) RenderDetail(resource dao.Resource) string { + ar, ok := resource.(*AddonResource) + if !ok { + return "" + } + + d := render.NewDetailBuilder() + d.Title("EKS Add-on", ar.GetName()) + + // Basic Information + d.Section("Basic Information") + d.Field("Name", ar.GetName()) + d.Field("ARN", ar.GetARN()) + d.Field("Cluster", appaws.Str(ar.Addon.ClusterName)) + d.Field("Status", ar.Status()) + d.Field("Version", ar.Version()) + d.Field("Created", ar.CreatedAge()) + if modified := appaws.Time(ar.Addon.ModifiedAt); !modified.IsZero() { + d.Field("Modified", render.FormatAge(modified)) + } + + // Configuration + d.Section("Configuration") + d.Field("Service Account Role", appaws.Str(ar.Addon.ServiceAccountRoleArn)) + if publisher := appaws.Str(ar.Addon.Publisher); publisher != "" { + d.Field("Publisher", publisher) + } + if owner := appaws.Str(ar.Addon.Owner); owner != "" { + d.Field("Owner", owner) + } + if mi := ar.Addon.MarketplaceInformation; mi != nil { + d.Field("Product ID", appaws.Str(mi.ProductId)) + d.Field("Product URL", appaws.Str(mi.ProductUrl)) + } + + // Configuration Values + if config := appaws.Str(ar.Addon.ConfigurationValues); config != "" { + d.Section("Configuration Values") + d.Line(config) + } + + // Pod Identity Associations + if len(ar.Addon.PodIdentityAssociations) > 0 { + d.Section("Pod Identity Associations") + for i, pia := range ar.Addon.PodIdentityAssociations { + d.Field(fmt.Sprintf("Association #%d", i+1), pia) + } + } + + // Health Issues + if health := ar.Addon.Health; health != nil && len(health.Issues) > 0 { + d.Section("Health Issues") + for i, issue := range health.Issues { + d.Field(fmt.Sprintf("Issue #%d Code", i+1), string(issue.Code)) + if msg := appaws.Str(issue.Message); msg != "" { + d.Field(fmt.Sprintf("Issue #%d Message", i+1), msg) + } + if len(issue.ResourceIds) > 0 { + d.Field(fmt.Sprintf("Issue #%d Resources", i+1), strings.Join(issue.ResourceIds, ", ")) + } + } + } + + // Tags + if len(ar.Addon.Tags) > 0 { + d.Section("Tags") + d.Tags(ar.Addon.Tags) + } + + // Full Details + d.Section("Full Details") + if jsonBytes, err := json.MarshalIndent(ar.Addon, "", " "); err == nil { + d.Line(string(jsonBytes)) + } + + return d.String() +} + +func (rnd *AddonRenderer) RenderSummary(resource dao.Resource) []render.SummaryField { + ar, ok := resource.(*AddonResource) + if !ok { + return nil + } + + return []render.SummaryField{ + {Label: "Name", Value: ar.GetName()}, + {Label: "Version", Value: ar.Version()}, + {Label: "Status", Value: ar.Status()}, + } +} + +func (rnd *AddonRenderer) Navigations(resource dao.Resource) []render.Navigation { + ar, ok := resource.(*AddonResource) + if !ok { + return nil + } + + var navs []render.Navigation + + // Parent cluster (always present) + if clusterName := appaws.Str(ar.Addon.ClusterName); clusterName != "" { + navs = append(navs, render.Navigation{ + Key: "p", + Label: "Cluster", + Service: "eks", + Resource: "clusters", + FilterField: "ClusterName", + FilterValue: clusterName, + }) + } + + // Service Account Role (if configured) + if roleArn := appaws.Str(ar.Addon.ServiceAccountRoleArn); roleArn != "" { + roleName := appaws.ExtractResourceName(roleArn) + navs = append(navs, render.Navigation{ + Key: "r", + Label: "Service Account Role", + Service: "iam", + Resource: "roles", + FilterField: "RoleName", + FilterValue: roleName, + }) + } + + return navs +} diff --git a/custom/eks/clusters/constants.go b/custom/eks/clusters/constants.go new file mode 100644 index 00000000..781525dd --- /dev/null +++ b/custom/eks/clusters/constants.go @@ -0,0 +1,7 @@ +// Code generated by go generate; DO NOT EDIT. +// To regenerate: task gen-imports + +package clusters + +// ServiceResourcePath is the canonical path for this resource type. +const ServiceResourcePath = "eks/clusters" diff --git a/custom/eks/clusters/dao.go b/custom/eks/clusters/dao.go new file mode 100644 index 00000000..4824ba16 --- /dev/null +++ b/custom/eks/clusters/dao.go @@ -0,0 +1,157 @@ +package clusters + +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" +) + +// ClusterDAO provides data access for EKS clusters +type ClusterDAO struct { + dao.BaseDAO + client *eks.Client +} + +// NewClusterDAO creates a new ClusterDAO +func NewClusterDAO(ctx context.Context) (dao.DAO, error) { + cfg, err := appaws.NewConfig(ctx) + if err != nil { + return nil, apperrors.Wrap(err, "new eks/clusters dao") + } + return &ClusterDAO{ + BaseDAO: dao.NewBaseDAO("eks", "clusters"), + client: eks.NewFromConfig(cfg), + }, nil +} + +func (d *ClusterDAO) List(ctx context.Context) ([]dao.Resource, error) { + // Check for ClusterName filter (for navigation from child resources) + if clusterName := dao.GetFilterFromContext(ctx, "ClusterName"); clusterName != "" { + // Direct lookup for specific cluster + cluster, err := d.Get(ctx, clusterName) + 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{cluster}, nil + } + + // List cluster names + clusterNames, err := appaws.Paginate(ctx, func(token *string) ([]string, *string, error) { + output, err := d.client.ListClusters(ctx, &eks.ListClustersInput{ + NextToken: token, + }) + if err != nil { + return nil, nil, apperrors.Wrap(err, "list clusters") + } + return output.Clusters, output.NextToken, nil + }) + if err != nil { + return nil, err + } + + if len(clusterNames) == 0 { + return nil, nil + } + + // Describe each cluster to get details + resources := make([]dao.Resource, 0, len(clusterNames)) + for _, name := range clusterNames { + output, err := d.client.DescribeCluster(ctx, &eks.DescribeClusterInput{ + Name: &name, + }) + if err != nil { + if apperrors.IsNotFound(err) { + continue + } + return nil, apperrors.Wrapf(err, "describe cluster %s", name) + } + if output.Cluster != nil { + resources = append(resources, NewClusterResource(*output.Cluster)) + } + } + + return resources, nil +} + +func (d *ClusterDAO) Get(ctx context.Context, id string) (dao.Resource, error) { + output, err := d.client.DescribeCluster(ctx, &eks.DescribeClusterInput{ + Name: &id, + }) + if err != nil { + return nil, apperrors.Wrapf(err, "describe cluster %s", id) + } + + if output.Cluster == nil { + return nil, fmt.Errorf("cluster not found: %s", id) + } + + return NewClusterResource(*output.Cluster), nil +} + +func (d *ClusterDAO) Delete(ctx context.Context, id string) error { + _, err := d.client.DeleteCluster(ctx, &eks.DeleteClusterInput{ + Name: &id, + }) + if err != nil { + if apperrors.IsNotFound(err) { + return nil // Already deleted + } + return apperrors.Wrapf(err, "delete cluster %s", id) + } + return nil +} + +// ClusterResource represents an EKS cluster resource +type ClusterResource struct { + dao.BaseResource + Cluster types.Cluster +} + +// NewClusterResource creates a new ClusterResource +func NewClusterResource(cluster types.Cluster) *ClusterResource { + return &ClusterResource{ + BaseResource: dao.BaseResource{ + ID: appaws.Str(cluster.Name), + Name: appaws.Str(cluster.Name), + ARN: appaws.Str(cluster.Arn), + Data: cluster, + }, + Cluster: cluster, + } +} + +// Status returns cluster status +func (r *ClusterResource) Status() string { + return string(r.Cluster.Status) +} + +// Version returns Kubernetes version +func (r *ClusterResource) Version() string { + return appaws.Str(r.Cluster.Version) +} + +// Endpoint returns API server endpoint +func (r *ClusterResource) Endpoint() string { + return appaws.Str(r.Cluster.Endpoint) +} + +// CreatedAge returns age since creation +func (r *ClusterResource) CreatedAge() string { + return render.FormatAge(appaws.Time(r.Cluster.CreatedAt)) +} + +// PlatformVersion returns platform version +func (r *ClusterResource) PlatformVersion() string { + return appaws.Str(r.Cluster.PlatformVersion) +} diff --git a/custom/eks/clusters/register.go b/custom/eks/clusters/register.go new file mode 100644 index 00000000..bec7db0b --- /dev/null +++ b/custom/eks/clusters/register.go @@ -0,0 +1,20 @@ +package clusters + +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", "clusters", registry.Entry{ + DAOFactory: func(ctx context.Context) (dao.DAO, error) { + return NewClusterDAO(ctx) + }, + RendererFactory: func() render.Renderer { + return NewClusterRenderer() + }, + }) +} diff --git a/custom/eks/clusters/render.go b/custom/eks/clusters/render.go new file mode 100644 index 00000000..07140b20 --- /dev/null +++ b/custom/eks/clusters/render.go @@ -0,0 +1,341 @@ +package clusters + +import ( + "encoding/json" + "fmt" + "strings" + + appaws "github.com/clawscli/claws/internal/aws" + "github.com/clawscli/claws/internal/dao" + "github.com/clawscli/claws/internal/render" +) + +var _ render.Navigator = (*ClusterRenderer)(nil) + +// formatBool converts bool to Yes/No string +func formatBool(b bool) string { + if b { + return "Yes" + } + return "No" +} + +// ClusterRenderer renders EKS cluster resources +type ClusterRenderer struct { + render.BaseRenderer +} + +// NewClusterRenderer creates a new ClusterRenderer +func NewClusterRenderer() render.Renderer { + return &ClusterRenderer{ + BaseRenderer: render.BaseRenderer{ + Service: "eks", + Resource: "clusters", + Cols: []render.Column{ + { + Name: "NAME", + Width: 30, + Priority: 0, + Getter: func(r dao.Resource) string { + return r.GetName() + }, + }, + { + Name: "VERSION", + Width: 10, + Priority: 1, + Getter: func(r dao.Resource) string { + if cr, ok := r.(*ClusterResource); ok { + return cr.Version() + } + return "" + }, + }, + { + Name: "STATUS", + Width: 12, + Priority: 2, + Getter: func(r dao.Resource) string { + if cr, ok := r.(*ClusterResource); ok { + return cr.Status() + } + return "" + }, + }, + { + Name: "ENDPOINT", + Width: 50, + Priority: 3, + Getter: func(r dao.Resource) string { + if cr, ok := r.(*ClusterResource); ok { + return cr.Endpoint() + } + return "" + }, + }, + { + Name: "AGE", + Width: 10, + Priority: 4, + Getter: func(r dao.Resource) string { + if cr, ok := r.(*ClusterResource); ok { + return cr.CreatedAge() + } + return "" + }, + }, + }, + }, + } +} + +func (rnd *ClusterRenderer) RenderDetail(resource dao.Resource) string { + cr, ok := resource.(*ClusterResource) + if !ok { + return "" + } + + d := render.NewDetailBuilder() + d.Title("EKS Cluster", cr.GetName()) + + // Basic Information + d.Section("Basic Information") + d.Field("Name", cr.GetName()) + d.Field("ARN", cr.GetARN()) + d.Field("Version", cr.Version()) + d.Field("Status", cr.Status()) + d.Field("Platform Version", cr.PlatformVersion()) + d.Field("Created", cr.CreatedAge()) + d.Field("Role ARN", appaws.Str(cr.Cluster.RoleArn)) + + // Endpoint & Certificate Authority + d.Section("Endpoint & Certificate") + if endpoint := appaws.Str(cr.Cluster.Endpoint); endpoint != "" { + d.Field("API Server Endpoint", endpoint) + } else { + d.Field("API Server Endpoint", render.NotConfigured) + } + if cr.Cluster.CertificateAuthority != nil && cr.Cluster.CertificateAuthority.Data != nil { + d.Field("Certificate Authority", "Present") + } else { + d.Field("Certificate Authority", render.NotConfigured) + } + + // VPC Configuration + d.Section("VPC Configuration") + if vpc := cr.Cluster.ResourcesVpcConfig; vpc != nil { + d.Field("VPC ID", appaws.Str(vpc.VpcId)) + if len(vpc.SubnetIds) > 0 { + d.Field("Subnets", strings.Join(vpc.SubnetIds, ", ")) + } else { + d.Field("Subnets", render.Empty) + } + if len(vpc.SecurityGroupIds) > 0 { + d.Field("Security Groups", strings.Join(vpc.SecurityGroupIds, ", ")) + } else { + d.Field("Security Groups", render.Empty) + } + d.Field("Cluster Security Group", appaws.Str(vpc.ClusterSecurityGroupId)) + d.Field("Endpoint Public Access", formatBool(vpc.EndpointPublicAccess)) + d.Field("Endpoint Private Access", formatBool(vpc.EndpointPrivateAccess)) + if len(vpc.PublicAccessCidrs) > 0 { + d.Field("Public Access CIDRs", strings.Join(vpc.PublicAccessCidrs, ", ")) + } + } + + // Kubernetes Network Configuration + d.Section("Kubernetes Network") + if netCfg := cr.Cluster.KubernetesNetworkConfig; netCfg != nil { + d.Field("IP Family", string(netCfg.IpFamily)) + d.Field("Service IPv4 CIDR", appaws.Str(netCfg.ServiceIpv4Cidr)) + if cidr := appaws.Str(netCfg.ServiceIpv6Cidr); cidr != "" { + d.Field("Service IPv6 CIDR", cidr) + } + if lb := netCfg.ElasticLoadBalancing; lb != nil { + d.Field("Elastic Load Balancing", formatBool(appaws.Bool(lb.Enabled))) + } + } + + // Logging + d.Section("Logging") + if logging := cr.Cluster.Logging; logging != nil && len(logging.ClusterLogging) > 0 { + for _, log := range logging.ClusterLogging { + if log.Enabled != nil && *log.Enabled { + types := make([]string, len(log.Types)) + for i, t := range log.Types { + types[i] = string(t) + } + d.Field("Enabled Log Types", strings.Join(types, ", ")) + } else { + d.Field("Logging", "Disabled") + } + } + } else { + d.Field("Logging", "Disabled") + } + + // Identity + d.Section("Identity") + if identity := cr.Cluster.Identity; identity != nil && identity.Oidc != nil { + d.Field("OIDC Issuer", appaws.Str(identity.Oidc.Issuer)) + } + + // Access Configuration + if accessCfg := cr.Cluster.AccessConfig; accessCfg != nil { + d.Section("Access Configuration") + d.Field("Authentication Mode", string(accessCfg.AuthenticationMode)) + if accessCfg.BootstrapClusterCreatorAdminPermissions != nil { + d.Field("Bootstrap Creator Admin", formatBool(appaws.Bool(accessCfg.BootstrapClusterCreatorAdminPermissions))) + } + } + + // Encryption + if len(cr.Cluster.EncryptionConfig) > 0 { + d.Section("Encryption") + for i, enc := range cr.Cluster.EncryptionConfig { + if enc.Provider != nil && enc.Provider.KeyArn != nil { + d.Field(fmt.Sprintf("Key ARN #%d", i+1), *enc.Provider.KeyArn) + if len(enc.Resources) > 0 { + d.Field(fmt.Sprintf("Resources #%d", i+1), strings.Join(enc.Resources, ", ")) + } + } + } + } + + // Health + if health := cr.Cluster.Health; health != nil && len(health.Issues) > 0 { + d.Section("Health Issues") + for i, issue := range health.Issues { + d.Field(fmt.Sprintf("Issue #%d Code", i+1), string(issue.Code)) + if msg := appaws.Str(issue.Message); msg != "" { + d.Field(fmt.Sprintf("Issue #%d Message", i+1), msg) + } + if len(issue.ResourceIds) > 0 { + d.Field(fmt.Sprintf("Issue #%d Resources", i+1), strings.Join(issue.ResourceIds, ", ")) + } + } + } + + // Tags + if len(cr.Cluster.Tags) > 0 { + d.Section("Tags") + d.Tags(cr.Cluster.Tags) + } + + // Full Details + d.Section("Full Details") + if jsonBytes, err := json.MarshalIndent(cr.Cluster, "", " "); err == nil { + d.Line(string(jsonBytes)) + } + + return d.String() +} + +func (rnd *ClusterRenderer) RenderSummary(resource dao.Resource) []render.SummaryField { + cr, ok := resource.(*ClusterResource) + if !ok { + return nil + } + + return []render.SummaryField{ + {Label: "Name", Value: cr.GetName()}, + {Label: "Version", Value: cr.Version()}, + {Label: "Status", Value: cr.Status()}, + {Label: "Created", Value: cr.CreatedAge()}, + } +} + +func (rnd *ClusterRenderer) Navigations(resource dao.Resource) []render.Navigation { + cr, ok := resource.(*ClusterResource) + if !ok { + return nil + } + + navs := []render.Navigation{ + { + Key: "n", + Label: "Node Groups", + Service: "eks", + Resource: "node-groups", + FilterField: "ClusterName", + FilterValue: cr.GetName(), + }, + { + Key: "f", + Label: "Fargate Profiles", + Service: "eks", + Resource: "fargate-profiles", + FilterField: "ClusterName", + FilterValue: cr.GetName(), + }, + { + Key: "o", + Label: "Add-ons", + Service: "eks", + Resource: "addons", + FilterField: "ClusterName", + FilterValue: cr.GetName(), + }, + { + Key: "e", + Label: "Access Entries", + Service: "eks", + Resource: "access-entries", + FilterField: "ClusterName", + FilterValue: cr.GetName(), + }, + } + + // CloudWatch Logs (Control Plane) + navs = append(navs, render.Navigation{ + Key: "l", + Label: "Control Plane Logs", + Service: "cloudwatch", + Resource: "log-groups", + FilterField: "LogGroupPrefix", + FilterValue: "/aws/eks/" + cr.GetName() + "/cluster", + }) + + // IAM Cluster Role + if roleArn := appaws.Str(cr.Cluster.RoleArn); roleArn != "" { + roleName := appaws.ExtractResourceName(roleArn) + navs = append(navs, render.Navigation{ + Key: "r", + Label: "Cluster Role", + Service: "iam", + Resource: "roles", + FilterField: "RoleName", + FilterValue: roleName, + }) + } + + // VPC + if vpc := cr.Cluster.ResourcesVpcConfig; vpc != nil { + if vpcId := appaws.Str(vpc.VpcId); vpcId != "" { + navs = append(navs, render.Navigation{ + Key: "v", + Label: "VPC", + Service: "vpc", + Resource: "vpcs", + FilterField: "VpcId", + FilterValue: vpcId, + }) + } + } + + // Cluster Security Group + if vpc := cr.Cluster.ResourcesVpcConfig; vpc != nil { + if sgId := appaws.Str(vpc.ClusterSecurityGroupId); sgId != "" { + navs = append(navs, render.Navigation{ + Key: "s", + Label: "Cluster Security Group", + Service: "ec2", + Resource: "security-groups", + FilterField: "GroupId", + FilterValue: sgId, + }) + } + } + + return navs +} diff --git a/custom/eks/fargate-profiles/constants.go b/custom/eks/fargate-profiles/constants.go new file mode 100644 index 00000000..d55d86c6 --- /dev/null +++ b/custom/eks/fargate-profiles/constants.go @@ -0,0 +1,7 @@ +// Code generated by go generate; DO NOT EDIT. +// To regenerate: task gen-imports + +package fargateprofiles + +// ServiceResourcePath is the canonical path for this resource type. +const ServiceResourcePath = "eks/fargate-profiles" diff --git a/custom/eks/fargate-profiles/dao.go b/custom/eks/fargate-profiles/dao.go new file mode 100644 index 00000000..f116d93c --- /dev/null +++ b/custom/eks/fargate-profiles/dao.go @@ -0,0 +1,145 @@ +package fargateprofiles + +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" +) + +// FargateProfileDAO provides data access for EKS Fargate profiles +type FargateProfileDAO struct { + dao.BaseDAO + client *eks.Client +} + +// NewFargateProfileDAO creates a new FargateProfileDAO +func NewFargateProfileDAO(ctx context.Context) (dao.DAO, error) { + cfg, err := appaws.NewConfig(ctx) + if err != nil { + return nil, apperrors.Wrap(err, "new eks/fargate-profiles dao") + } + return &FargateProfileDAO{ + BaseDAO: dao.NewBaseDAO("eks", "fargate-profiles"), + client: eks.NewFromConfig(cfg), + }, nil +} + +func (d *FargateProfileDAO) List(ctx context.Context) ([]dao.Resource, error) { + clusterName := dao.GetFilterFromContext(ctx, "ClusterName") + if clusterName == "" { + return nil, fmt.Errorf("ClusterName filter required") + } + + profileNames, err := appaws.Paginate(ctx, func(token *string) ([]string, *string, error) { + output, err := d.client.ListFargateProfiles(ctx, &eks.ListFargateProfilesInput{ + ClusterName: &clusterName, + NextToken: token, + }) + if err != nil { + return nil, nil, apperrors.Wrap(err, "list fargate profiles") + } + return output.FargateProfileNames, output.NextToken, nil + }) + if err != nil { + return nil, err + } + + if len(profileNames) == 0 { + return nil, nil + } + + resources := make([]dao.Resource, 0, len(profileNames)) + for _, name := range profileNames { + output, err := d.client.DescribeFargateProfile(ctx, &eks.DescribeFargateProfileInput{ + ClusterName: &clusterName, + FargateProfileName: &name, + }) + if err != nil { + if apperrors.IsNotFound(err) { + continue + } + return nil, apperrors.Wrapf(err, "describe fargate profile %s", name) + } + if output.FargateProfile != nil { + resources = append(resources, NewFargateProfileResource(*output.FargateProfile)) + } + } + + return resources, nil +} + +func (d *FargateProfileDAO) 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.DescribeFargateProfile(ctx, &eks.DescribeFargateProfileInput{ + ClusterName: &clusterName, + FargateProfileName: &id, + }) + if err != nil { + return nil, apperrors.Wrapf(err, "describe fargate profile %s", id) + } + + if output.FargateProfile == nil { + return nil, fmt.Errorf("fargate profile not found: %s", id) + } + + return NewFargateProfileResource(*output.FargateProfile), nil +} + +func (d *FargateProfileDAO) Delete(ctx context.Context, id string) error { + clusterName := dao.GetFilterFromContext(ctx, "ClusterName") + if clusterName == "" { + return fmt.Errorf("ClusterName filter required") + } + + _, err := d.client.DeleteFargateProfile(ctx, &eks.DeleteFargateProfileInput{ + ClusterName: &clusterName, + FargateProfileName: &id, + }) + if err != nil { + if apperrors.IsNotFound(err) { + return nil + } + return apperrors.Wrapf(err, "delete fargate profile %s", id) + } + return nil +} + +// FargateProfileResource represents an EKS Fargate profile resource +type FargateProfileResource struct { + dao.BaseResource + FargateProfile types.FargateProfile +} + +// NewFargateProfileResource creates a new FargateProfileResource +func NewFargateProfileResource(fp types.FargateProfile) *FargateProfileResource { + return &FargateProfileResource{ + BaseResource: dao.BaseResource{ + ID: appaws.Str(fp.FargateProfileName), + Name: appaws.Str(fp.FargateProfileName), + ARN: appaws.Str(fp.FargateProfileArn), + Data: fp, + }, + FargateProfile: fp, + } +} + +// Status returns fargate profile status +func (r *FargateProfileResource) Status() string { + return string(r.FargateProfile.Status) +} + +// CreatedAge returns age since creation +func (r *FargateProfileResource) CreatedAge() string { + return render.FormatAge(appaws.Time(r.FargateProfile.CreatedAt)) +} diff --git a/custom/eks/fargate-profiles/register.go b/custom/eks/fargate-profiles/register.go new file mode 100644 index 00000000..cd1c8524 --- /dev/null +++ b/custom/eks/fargate-profiles/register.go @@ -0,0 +1,20 @@ +package fargateprofiles + +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", "fargate-profiles", registry.Entry{ + DAOFactory: func(ctx context.Context) (dao.DAO, error) { + return NewFargateProfileDAO(ctx) + }, + RendererFactory: func() render.Renderer { + return NewFargateProfileRenderer() + }, + }) +} diff --git a/custom/eks/fargate-profiles/render.go b/custom/eks/fargate-profiles/render.go new file mode 100644 index 00000000..65fa6770 --- /dev/null +++ b/custom/eks/fargate-profiles/render.go @@ -0,0 +1,171 @@ +package fargateprofiles + +import ( + "encoding/json" + "fmt" + "strings" + + appaws "github.com/clawscli/claws/internal/aws" + "github.com/clawscli/claws/internal/dao" + "github.com/clawscli/claws/internal/render" +) + +// FargateProfileRenderer renders EKS Fargate profile resources +type FargateProfileRenderer struct { + render.BaseRenderer +} + +var _ render.Navigator = (*FargateProfileRenderer)(nil) + +// NewFargateProfileRenderer creates a new FargateProfileRenderer +func NewFargateProfileRenderer() render.Renderer { + return &FargateProfileRenderer{ + BaseRenderer: render.BaseRenderer{ + Service: "eks", + Resource: "fargate-profiles", + Cols: []render.Column{ + { + Name: "NAME", + Width: 30, + Priority: 0, + Getter: func(r dao.Resource) string { + return r.GetName() + }, + }, + { + Name: "STATUS", + Width: 12, + Priority: 1, + Getter: func(r dao.Resource) string { + if fpr, ok := r.(*FargateProfileResource); ok { + return fpr.Status() + } + return "" + }, + }, + { + Name: "POD EXECUTION ROLE", + Width: 50, + Priority: 2, + Getter: func(r dao.Resource) string { + if fpr, ok := r.(*FargateProfileResource); ok { + return appaws.Str(fpr.FargateProfile.PodExecutionRoleArn) + } + return "" + }, + }, + { + Name: "AGE", + Width: 10, + Priority: 3, + Getter: func(r dao.Resource) string { + if fpr, ok := r.(*FargateProfileResource); ok { + return fpr.CreatedAge() + } + return "" + }, + }, + }, + }, + } +} + +func (rnd *FargateProfileRenderer) RenderDetail(resource dao.Resource) string { + fpr, ok := resource.(*FargateProfileResource) + if !ok { + return "" + } + + d := render.NewDetailBuilder() + d.Title("EKS Fargate Profile", fpr.GetName()) + + // Basic Information + d.Section("Basic Information") + d.Field("Name", fpr.GetName()) + d.Field("ARN", fpr.GetARN()) + d.Field("Cluster", appaws.Str(fpr.FargateProfile.ClusterName)) + d.Field("Status", fpr.Status()) + d.Field("Created", fpr.CreatedAge()) + d.Field("Pod Execution Role", appaws.Str(fpr.FargateProfile.PodExecutionRoleArn)) + + // Selectors + if len(fpr.FargateProfile.Selectors) > 0 { + d.Section("Selectors") + for i, sel := range fpr.FargateProfile.Selectors { + d.Field(fmt.Sprintf("Selector #%d Namespace", i+1), appaws.Str(sel.Namespace)) + if len(sel.Labels) > 0 { + d.Field(fmt.Sprintf("Selector #%d Labels", i+1), "") + d.Tags(sel.Labels) + } + } + } + + // Subnets + if len(fpr.FargateProfile.Subnets) > 0 { + d.Section("Subnets") + d.Field("Subnet IDs", strings.Join(fpr.FargateProfile.Subnets, ", ")) + } + + // Tags + if len(fpr.FargateProfile.Tags) > 0 { + d.Section("Tags") + d.Tags(fpr.FargateProfile.Tags) + } + + // Full Details + d.Section("Full Details") + if jsonBytes, err := json.MarshalIndent(fpr.FargateProfile, "", " "); err == nil { + d.Line(string(jsonBytes)) + } + + return d.String() +} + +func (rnd *FargateProfileRenderer) RenderSummary(resource dao.Resource) []render.SummaryField { + fpr, ok := resource.(*FargateProfileResource) + if !ok { + return nil + } + + return []render.SummaryField{ + {Label: "Name", Value: fpr.GetName()}, + {Label: "Status", Value: fpr.Status()}, + {Label: "Created", Value: fpr.CreatedAge()}, + } +} + +func (rnd *FargateProfileRenderer) Navigations(resource dao.Resource) []render.Navigation { + fpr, ok := resource.(*FargateProfileResource) + if !ok { + return nil + } + + var navs []render.Navigation + + // Parent cluster (always present) + if clusterName := appaws.Str(fpr.FargateProfile.ClusterName); clusterName != "" { + navs = append(navs, render.Navigation{ + Key: "p", + Label: "Cluster", + Service: "eks", + Resource: "clusters", + FilterField: "ClusterName", + FilterValue: clusterName, + }) + } + + // Pod Execution Role + if roleArn := appaws.Str(fpr.FargateProfile.PodExecutionRoleArn); roleArn != "" { + roleName := appaws.ExtractResourceName(roleArn) + navs = append(navs, render.Navigation{ + Key: "r", + Label: "Pod Execution Role", + Service: "iam", + Resource: "roles", + FilterField: "RoleName", + FilterValue: roleName, + }) + } + + return navs +} diff --git a/custom/eks/node-groups/constants.go b/custom/eks/node-groups/constants.go new file mode 100644 index 00000000..89f555ae --- /dev/null +++ b/custom/eks/node-groups/constants.go @@ -0,0 +1,7 @@ +// Code generated by go generate; DO NOT EDIT. +// To regenerate: task gen-imports + +package nodegroups + +// ServiceResourcePath is the canonical path for this resource type. +const ServiceResourcePath = "eks/node-groups" diff --git a/custom/eks/node-groups/dao.go b/custom/eks/node-groups/dao.go new file mode 100644 index 00000000..5db5f632 --- /dev/null +++ b/custom/eks/node-groups/dao.go @@ -0,0 +1,188 @@ +package nodegroups + +import ( + "context" + "fmt" + "strings" + + "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" +) + +// NodeGroupDAO provides data access for EKS node groups +type NodeGroupDAO struct { + dao.BaseDAO + client *eks.Client +} + +// NewNodeGroupDAO creates a new NodeGroupDAO +func NewNodeGroupDAO(ctx context.Context) (dao.DAO, error) { + cfg, err := appaws.NewConfig(ctx) + if err != nil { + return nil, apperrors.Wrap(err, "new eks/node-groups dao") + } + return &NodeGroupDAO{ + BaseDAO: dao.NewBaseDAO("eks", "node-groups"), + client: eks.NewFromConfig(cfg), + }, nil +} + +func (d *NodeGroupDAO) List(ctx context.Context) ([]dao.Resource, error) { + // Get cluster name from context filter + clusterName := dao.GetFilterFromContext(ctx, "ClusterName") + if clusterName == "" { + return nil, fmt.Errorf("ClusterName filter required") + } + + // List node group names + nodegroupNames, err := appaws.Paginate(ctx, func(token *string) ([]string, *string, error) { + output, err := d.client.ListNodegroups(ctx, &eks.ListNodegroupsInput{ + ClusterName: &clusterName, + NextToken: token, + }) + if err != nil { + return nil, nil, apperrors.Wrap(err, "list node groups") + } + return output.Nodegroups, output.NextToken, nil + }) + if err != nil { + return nil, err + } + + if len(nodegroupNames) == 0 { + return nil, nil + } + + // Describe each node group to get details + resources := make([]dao.Resource, 0, len(nodegroupNames)) + for _, name := range nodegroupNames { + output, err := d.client.DescribeNodegroup(ctx, &eks.DescribeNodegroupInput{ + ClusterName: &clusterName, + NodegroupName: &name, + }) + if err != nil { + if apperrors.IsNotFound(err) { + continue + } + return nil, apperrors.Wrapf(err, "describe node group %s", name) + } + if output.Nodegroup != nil { + resources = append(resources, NewNodeGroupResource(*output.Nodegroup)) + } + } + + return resources, nil +} + +func (d *NodeGroupDAO) Get(ctx context.Context, id string) (dao.Resource, error) { + // Get cluster name from context filter + clusterName := dao.GetFilterFromContext(ctx, "ClusterName") + if clusterName == "" { + return nil, fmt.Errorf("ClusterName filter required") + } + + output, err := d.client.DescribeNodegroup(ctx, &eks.DescribeNodegroupInput{ + ClusterName: &clusterName, + NodegroupName: &id, + }) + if err != nil { + return nil, apperrors.Wrapf(err, "describe node group %s", id) + } + + if output.Nodegroup == nil { + return nil, fmt.Errorf("node group not found: %s", id) + } + + return NewNodeGroupResource(*output.Nodegroup), nil +} + +func (d *NodeGroupDAO) Delete(ctx context.Context, id string) error { + // Get cluster name from context filter + clusterName := dao.GetFilterFromContext(ctx, "ClusterName") + if clusterName == "" { + return fmt.Errorf("ClusterName filter required") + } + + _, err := d.client.DeleteNodegroup(ctx, &eks.DeleteNodegroupInput{ + ClusterName: &clusterName, + NodegroupName: &id, + }) + if err != nil { + if apperrors.IsNotFound(err) { + return nil // Already deleted + } + return apperrors.Wrapf(err, "delete node group %s", id) + } + return nil +} + +// NodeGroupResource represents an EKS node group resource +type NodeGroupResource struct { + dao.BaseResource + NodeGroup types.Nodegroup +} + +// NewNodeGroupResource creates a new NodeGroupResource +func NewNodeGroupResource(ng types.Nodegroup) *NodeGroupResource { + return &NodeGroupResource{ + BaseResource: dao.BaseResource{ + ID: appaws.Str(ng.NodegroupName), + Name: appaws.Str(ng.NodegroupName), + ARN: appaws.Str(ng.NodegroupArn), + Data: ng, + }, + NodeGroup: ng, + } +} + +// Status returns node group status +func (r *NodeGroupResource) Status() string { + return string(r.NodeGroup.Status) +} + +// Version returns Kubernetes version +func (r *NodeGroupResource) Version() string { + return appaws.Str(r.NodeGroup.Version) +} + +// InstanceTypes returns comma-separated instance types +func (r *NodeGroupResource) InstanceTypes() string { + if len(r.NodeGroup.InstanceTypes) == 0 { + return "" + } + return strings.Join(r.NodeGroup.InstanceTypes, ", ") +} + +// DesiredSize returns desired node count +func (r *NodeGroupResource) DesiredSize() int32 { + if r.NodeGroup.ScalingConfig == nil { + return 0 + } + return appaws.Int32(r.NodeGroup.ScalingConfig.DesiredSize) +} + +// MinSize returns minimum node count +func (r *NodeGroupResource) MinSize() int32 { + if r.NodeGroup.ScalingConfig == nil { + return 0 + } + return appaws.Int32(r.NodeGroup.ScalingConfig.MinSize) +} + +// MaxSize returns maximum node count +func (r *NodeGroupResource) MaxSize() int32 { + if r.NodeGroup.ScalingConfig == nil { + return 0 + } + return appaws.Int32(r.NodeGroup.ScalingConfig.MaxSize) +} + +// CreatedAge returns age since creation +func (r *NodeGroupResource) CreatedAge() string { + return render.FormatAge(appaws.Time(r.NodeGroup.CreatedAt)) +} diff --git a/custom/eks/node-groups/register.go b/custom/eks/node-groups/register.go new file mode 100644 index 00000000..af93f717 --- /dev/null +++ b/custom/eks/node-groups/register.go @@ -0,0 +1,20 @@ +package nodegroups + +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", "node-groups", registry.Entry{ + DAOFactory: func(ctx context.Context) (dao.DAO, error) { + return NewNodeGroupDAO(ctx) + }, + RendererFactory: func() render.Renderer { + return NewNodeGroupRenderer() + }, + }) +} diff --git a/custom/eks/node-groups/render.go b/custom/eks/node-groups/render.go new file mode 100644 index 00000000..854b1a1b --- /dev/null +++ b/custom/eks/node-groups/render.go @@ -0,0 +1,326 @@ +package nodegroups + +import ( + "encoding/json" + "fmt" + "strings" + + appaws "github.com/clawscli/claws/internal/aws" + "github.com/clawscli/claws/internal/dao" + "github.com/clawscli/claws/internal/render" +) + +// NodeGroupRenderer renders EKS node group resources +type NodeGroupRenderer struct { + render.BaseRenderer +} + +var _ render.Navigator = (*NodeGroupRenderer)(nil) + +// NewNodeGroupRenderer creates a new NodeGroupRenderer +func NewNodeGroupRenderer() render.Renderer { + return &NodeGroupRenderer{ + BaseRenderer: render.BaseRenderer{ + Service: "eks", + Resource: "node-groups", + Cols: []render.Column{ + { + Name: "NAME", + Width: 30, + Priority: 0, + Getter: func(r dao.Resource) string { + return r.GetName() + }, + }, + { + Name: "STATUS", + Width: 12, + Priority: 1, + Getter: func(r dao.Resource) string { + if ngr, ok := r.(*NodeGroupResource); ok { + return ngr.Status() + } + return "" + }, + }, + { + Name: "VERSION", + Width: 10, + Priority: 2, + Getter: func(r dao.Resource) string { + if ngr, ok := r.(*NodeGroupResource); ok { + return ngr.Version() + } + return "" + }, + }, + { + Name: "INSTANCE TYPE", + Width: 15, + Priority: 3, + Getter: func(r dao.Resource) string { + if ngr, ok := r.(*NodeGroupResource); ok { + return ngr.InstanceTypes() + } + return "" + }, + }, + { + Name: "DESIRED/MIN/MAX", + Width: 15, + Priority: 4, + Getter: func(r dao.Resource) string { + if ngr, ok := r.(*NodeGroupResource); ok { + return fmt.Sprintf("%d/%d/%d", ngr.DesiredSize(), ngr.MinSize(), ngr.MaxSize()) + } + return "" + }, + }, + { + Name: "AGE", + Width: 10, + Priority: 5, + Getter: func(r dao.Resource) string { + if ngr, ok := r.(*NodeGroupResource); ok { + return ngr.CreatedAge() + } + return "" + }, + }, + }, + }, + } +} + +func (rnd *NodeGroupRenderer) RenderDetail(resource dao.Resource) string { + ngr, ok := resource.(*NodeGroupResource) + if !ok { + return "" + } + + d := render.NewDetailBuilder() + d.Title("EKS Node Group", ngr.GetName()) + + // Basic Information + d.Section("Basic Information") + d.Field("Name", ngr.GetName()) + d.Field("ARN", ngr.GetARN()) + d.Field("Cluster", appaws.Str(ngr.NodeGroup.ClusterName)) + d.Field("Status", ngr.Status()) + d.Field("Version", ngr.Version()) + d.Field("Release Version", appaws.Str(ngr.NodeGroup.ReleaseVersion)) + d.Field("Created", ngr.CreatedAge()) + if modified := appaws.Time(ngr.NodeGroup.ModifiedAt); !modified.IsZero() { + d.Field("Modified", render.FormatAge(modified)) + } + + // Instance Configuration + d.Section("Instance Configuration") + d.Field("AMI Type", string(ngr.NodeGroup.AmiType)) + d.Field("Capacity Type", string(ngr.NodeGroup.CapacityType)) + if len(ngr.NodeGroup.InstanceTypes) > 0 { + d.Field("Instance Types", strings.Join(ngr.NodeGroup.InstanceTypes, ", ")) + } else { + d.Field("Instance Types", render.Empty) + } + if diskSize := appaws.Int32(ngr.NodeGroup.DiskSize); diskSize > 0 { + d.Field("Disk Size (GB)", fmt.Sprintf("%d", diskSize)) + } + d.Field("Node Role", appaws.Str(ngr.NodeGroup.NodeRole)) + + // Scaling Configuration + d.Section("Scaling Configuration") + if sc := ngr.NodeGroup.ScalingConfig; sc != nil { + d.Field("Desired Size", fmt.Sprintf("%d", appaws.Int32(sc.DesiredSize))) + d.Field("Min Size", fmt.Sprintf("%d", appaws.Int32(sc.MinSize))) + d.Field("Max Size", fmt.Sprintf("%d", appaws.Int32(sc.MaxSize))) + } + + // Update Configuration + if uc := ngr.NodeGroup.UpdateConfig; uc != nil { + d.Section("Update Configuration") + if uc.UpdateStrategy != "" { + d.Field("Update Strategy", string(uc.UpdateStrategy)) + } + if maxUnavail := appaws.Int32(uc.MaxUnavailable); maxUnavail > 0 { + d.Field("Max Unavailable", fmt.Sprintf("%d", maxUnavail)) + } + if maxUnavailPct := appaws.Int32(uc.MaxUnavailablePercentage); maxUnavailPct > 0 { + d.Field("Max Unavailable %", fmt.Sprintf("%d%%", maxUnavailPct)) + } + } + + // Launch Template + if lt := ngr.NodeGroup.LaunchTemplate; lt != nil { + d.Section("Launch Template") + d.Field("Name", appaws.Str(lt.Name)) + d.Field("ID", appaws.Str(lt.Id)) + d.Field("Version", appaws.Str(lt.Version)) + } + + // Remote Access + if ra := ngr.NodeGroup.RemoteAccess; ra != nil { + d.Section("Remote Access") + d.Field("EC2 SSH Key", appaws.Str(ra.Ec2SshKey)) + if len(ra.SourceSecurityGroups) > 0 { + d.Field("Source Security Groups", strings.Join(ra.SourceSecurityGroups, ", ")) + } + } + + // Network + d.Section("Network") + if len(ngr.NodeGroup.Subnets) > 0 { + d.Field("Subnets", strings.Join(ngr.NodeGroup.Subnets, ", ")) + } + + // Resources + if res := ngr.NodeGroup.Resources; res != nil { + d.Section("Resources") + if len(res.AutoScalingGroups) > 0 { + for i, asg := range res.AutoScalingGroups { + d.Field(fmt.Sprintf("Auto Scaling Group #%d", i+1), appaws.Str(asg.Name)) + } + } + if rsg := appaws.Str(res.RemoteAccessSecurityGroup); rsg != "" { + d.Field("Remote Access Security Group", rsg) + } + } + + // Labels + if len(ngr.NodeGroup.Labels) > 0 { + d.Section("Labels") + d.Tags(ngr.NodeGroup.Labels) + } + + // Taints + if len(ngr.NodeGroup.Taints) > 0 { + d.Section("Taints") + for i, taint := range ngr.NodeGroup.Taints { + d.Field(fmt.Sprintf("Taint #%d", i+1), fmt.Sprintf("%s=%s:%s", + appaws.Str(taint.Key), + appaws.Str(taint.Value), + string(taint.Effect))) + } + } + + // Health Issues + if health := ngr.NodeGroup.Health; health != nil && len(health.Issues) > 0 { + d.Section("Health Issues") + for i, issue := range health.Issues { + d.Field(fmt.Sprintf("Issue #%d Code", i+1), string(issue.Code)) + if msg := appaws.Str(issue.Message); msg != "" { + d.Field(fmt.Sprintf("Issue #%d Message", i+1), msg) + } + if len(issue.ResourceIds) > 0 { + d.Field(fmt.Sprintf("Issue #%d Resources", i+1), strings.Join(issue.ResourceIds, ", ")) + } + } + } + + // Tags + if len(ngr.NodeGroup.Tags) > 0 { + d.Section("Tags") + d.Tags(ngr.NodeGroup.Tags) + } + + // Full Details + d.Section("Full Details") + if jsonBytes, err := json.MarshalIndent(ngr.NodeGroup, "", " "); err == nil { + d.Line(string(jsonBytes)) + } + + return d.String() +} + +func (rnd *NodeGroupRenderer) RenderSummary(resource dao.Resource) []render.SummaryField { + ngr, ok := resource.(*NodeGroupResource) + if !ok { + return nil + } + + return []render.SummaryField{ + {Label: "Name", Value: ngr.GetName()}, + {Label: "Status", Value: ngr.Status()}, + {Label: "Version", Value: ngr.Version()}, + {Label: "Desired", Value: fmt.Sprintf("%d", ngr.DesiredSize())}, + } +} + +func (rnd *NodeGroupRenderer) Navigations(resource dao.Resource) []render.Navigation { + ngr, ok := resource.(*NodeGroupResource) + if !ok { + return nil + } + + var navs []render.Navigation + + // Parent cluster (always present) + if clusterName := appaws.Str(ngr.NodeGroup.ClusterName); clusterName != "" { + navs = append(navs, render.Navigation{ + Key: "p", + Label: "Cluster", + Service: "eks", + Resource: "clusters", + FilterField: "ClusterName", + FilterValue: clusterName, + }) + } + + // IAM Node Role + if nodeRole := appaws.Str(ngr.NodeGroup.NodeRole); nodeRole != "" { + roleName := appaws.ExtractResourceName(nodeRole) + navs = append(navs, render.Navigation{ + Key: "r", + Label: "Node Role", + Service: "iam", + Resource: "roles", + FilterField: "RoleName", + FilterValue: roleName, + }) + } + + // Auto Scaling Group (if exists) + if res := ngr.NodeGroup.Resources; res != nil && len(res.AutoScalingGroups) > 0 { + asgName := appaws.Str(res.AutoScalingGroups[0].Name) + if asgName != "" { + navs = append(navs, render.Navigation{ + Key: "g", + Label: "Auto Scaling Group", + Service: "autoscaling", + Resource: "groups", + FilterField: "AutoScalingGroupName", + FilterValue: asgName, + }) + } + } + + // Launch Template (if exists) + if lt := ngr.NodeGroup.LaunchTemplate; lt != nil { + if ltId := appaws.Str(lt.Id); ltId != "" { + navs = append(navs, render.Navigation{ + Key: "t", + Label: "Launch Template", + Service: "ec2", + Resource: "launch-templates", + FilterField: "LaunchTemplateId", + FilterValue: ltId, + }) + } + } + + // SSH Key Pair (if remote access configured) + if ra := ngr.NodeGroup.RemoteAccess; ra != nil { + if keyName := appaws.Str(ra.Ec2SshKey); keyName != "" { + navs = append(navs, render.Navigation{ + Key: "k", + Label: "SSH Key Pair", + Service: "ec2", + Resource: "key-pairs", + FilterField: "KeyName", + FilterValue: keyName, + }) + } + } + + return navs +} diff --git a/custom/eks/test-fixtures/README.md b/custom/eks/test-fixtures/README.md new file mode 100644 index 00000000..3f7f687e --- /dev/null +++ b/custom/eks/test-fixtures/README.md @@ -0,0 +1,214 @@ +# EKS Test Fixtures + +CloudFormation template for creating a minimal EKS cluster for integration testing with claws. + +## Resources Created + +- **VPC** (10.0.0.0/16) +- **2 Public Subnets** (10.0.1.0/24, 10.0.2.0/24) - for NAT Gateway +- **2 Private Subnets** (10.0.11.0/24, 10.0.12.0/24) - for worker nodes and Fargate +- **Internet Gateway** +- **NAT Gateway** (single instance for cost optimization) + Elastic IP +- **Route Tables** (Public + Private) +- **Security Groups** (Cluster SG + Node SG with proper ingress/egress rules) +- **EKS Cluster** (v1.31 with API_AND_CONFIG_MAP authentication mode) +- **Node Group** (1x t3.small instance in private subnets with Launch Template) +- **Fargate Profile** (fargate-test namespace in private subnets) +- **Addon** (vpc-cni) +- **Access Entry** (for testing) + +All 5 EKS resource types supported by claws are included: +- clusters +- node-groups +- fargate-profiles +- addons +- access-entries + +## Cost Estimate + +- EKS Cluster: $0.10/hour ($73/month) +- t3.small instance: ~$0.021/hour (~$15/month) +- NAT Gateway: $0.045/hour (~$33/month) +- NAT Gateway data transfer: ~$0.045/GB (~$5-10/month estimated) +- **Total: ~$0.17/hour** (~$125/month if running 24/7) + +**Recommended:** Deploy only when testing, then clean up immediately. +**Example:** 3 hours/day × 20 days = **~$10/month** + +**Note:** Single NAT Gateway configuration (cost-optimized). For high availability, use 2 NAT Gateways (+$33/month). + +## Prerequisites + +- AWS CLI configured (`aws configure`) +- IAM permissions for: + - CloudFormation + - EKS + - EC2 (VPC, Subnets, Internet Gateway) + - IAM (Roles, Policies) + +## Quick Start + +### Using Task (Recommended) + +```bash +# Deploy test stack +task eks-test-up + +# Use claws to test +AWS_REGION=us-east-1 claws + +# Clean up when done (auto-retries on DELETE_FAILED) +task eks-test-down +``` + +### Using Scripts Directly + +```bash +cd custom/eks/test-fixtures/cloudformation + +# Deploy (takes ~15-20 minutes) +./deploy.sh + +# Configure kubectl (optional) +aws eks update-kubeconfig --name claws-test-cluster --region us-east-1 + +# Verify with kubectl (optional) +kubectl get nodes + +# Test with claws +AWS_REGION=us-east-1 claws + +# Clean up (takes ~10-15 minutes) +./cleanup.sh +``` + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `STACK_NAME` | claws-eks-test | CloudFormation stack name | +| `CLUSTER_NAME` | claws-test-cluster | EKS cluster name | +| `AWS_REGION` | us-east-1 | AWS region | +| `CI_MODE` | false | Skip interactive prompts (cleanup.sh) | +| `MAX_RETRY` | 2 | Max deletion retry attempts (cleanup.sh) | + +Example with custom values: + +```bash +STACK_NAME=my-test \ +CLUSTER_NAME=my-cluster \ +AWS_REGION=ap-northeast-1 \ +./deploy.sh +``` + +## Testing with claws + +After deployment: + +```bash +# Set region +export AWS_REGION=us-east-1 + +# Run claws +claws + +# Navigate to EKS resources: +# - EKS > clusters +# - EKS > node-groups +# - EKS > fargate-profiles +# - EKS > addons +# - EKS > access-entries +``` + +## Troubleshooting + +### Stack already exists + +If you see "Stack already exists", the script will update the existing stack instead of creating a new one. + +### Deployment timeout + +EKS cluster creation typically takes 15-20 minutes. If it times out, check the CloudFormation console for detailed error messages: + +```bash +aws cloudformation describe-stack-events \ + --stack-name claws-eks-test \ + --region us-east-1 \ + --max-items 20 +``` + +### Cleanup fails + +**NEW:** cleanup.sh now auto-retries on DELETE_FAILED and fixes auth mode issues automatically. + +If cleanup still fails after retries: + +```bash +# Check failure reason +aws cloudformation describe-stack-events \ + --stack-name claws-eks-test \ + --region us-east-1 \ + --query 'StackEvents[?ResourceStatus==`DELETE_FAILED`]' \ + --max-items 10 + +# For LoadBalancers created by k8s services: +aws elbv2 describe-load-balancers --region us-east-1 \ + --query 'LoadBalancers[?VpcId==``]' + +# Delete manually, then retry +CI_MODE=true ./cleanup.sh # Skip confirmation prompt +``` + +## Security + +### Security Groups + +The template creates secure security groups with minimal required access: + +**Cluster Security Group:** +- Allows communication from worker nodes on port 443 + +**Node Security Group:** +- Allows inbound from control plane (ports 1025-65535, 443) +- Allows all traffic between nodes (pod-to-pod communication) +- Allows all outbound traffic (for ECR, AWS APIs, package updates) +- **Blocks all other inbound traffic from the internet** + +### Network Architecture + +**Production-grade private subnet architecture:** + +``` +VPC (10.0.0.0/16) +├── Public Subnets (10.0.1.0/24, 10.0.2.0/24) +│ └── NAT Gateway + Elastic IP +└── Private Subnets (10.0.11.0/24, 10.0.12.0/24) + ├── EKS Worker Nodes (no public IPs) + └── Fargate Pods (no public IPs) +``` + +**Traffic Flow:** +- Worker nodes/Fargate → NAT Gateway → Internet Gateway → AWS Services (ECR, EKS API) +- No direct internet access for worker nodes +- Security groups enforce strict ingress/egress rules + +**Why this architecture?** +- **Fargate requirement**: Fargate profiles only support private subnets +- **Access Entry requirement**: Cluster must have API or API_AND_CONFIG_MAP authentication mode +- **Security**: Worker nodes have no public IPs, cannot be directly accessed from internet +- **AWS best practices**: Follows EKS production deployment patterns + +### Additional Security Features + +- **IMDSv2 enforced** on worker nodes (metadata token required) +- **No SSH access** configured (can be added if needed) +- **IAM roles** follow AWS best practices + +## Notes + +- **Do not commit AWS credentials** - Use IAM roles or AWS CLI profiles +- **Remember to clean up** - NAT Gateway costs add up quickly ($0.045/hour) +- **Test environment** - Single NAT Gateway (no HA), minimal node count +- **Production-ready architecture** - Private subnets, proper security groups, follows AWS best practices +- **Fargate compatible** - All 5 EKS resource types can be tested including Fargate profiles +- **Access Entry enabled** - Cluster configured with API_AND_CONFIG_MAP authentication mode diff --git a/custom/eks/test-fixtures/cloudformation/cleanup.sh b/custom/eks/test-fixtures/cloudformation/cleanup.sh new file mode 100755 index 00000000..2326aa33 --- /dev/null +++ b/custom/eks/test-fixtures/cloudformation/cleanup.sh @@ -0,0 +1,188 @@ +#!/bin/bash +set -e + +STACK_NAME="${STACK_NAME:-claws-eks-test}" +CLUSTER_NAME="${CLUSTER_NAME:-claws-test-cluster}" +REGION="${AWS_REGION:-us-east-1}" +CI_MODE="${CI_MODE:-false}" +MAX_RETRY=2 + +echo "=== Cleaning up EKS Test Stack ===" +echo "Stack Name: $STACK_NAME" +echo "Cluster Name: $CLUSTER_NAME" +echo "Region: $REGION" +echo "" + +# Check if stack exists +if ! aws cloudformation describe-stacks --stack-name "$STACK_NAME" --region "$REGION" &>/dev/null; then + echo "ℹ️ Stack '$STACK_NAME' does not exist. Nothing to clean up." + exit 0 +fi + +# Get current stack status +STACK_STATUS=$(aws cloudformation describe-stacks --stack-name "$STACK_NAME" --region "$REGION" --query 'Stacks[0].StackStatus' --output text) +echo "Stack Status: $STACK_STATUS" + +# Confirm deletion (skip in CI mode) +if [ "$CI_MODE" != "true" ]; then + read -p "⚠️ Delete entire EKS cluster and resources? (yes/no): " confirm + if [[ "$confirm" != "yes" ]]; then + echo "Aborted." + exit 0 + fi +fi + +# Function to check and fix cluster auth mode if needed +fix_auth_mode_if_needed() { + local cluster_name=$1 + local region=$2 + + # Check if cluster exists + if ! aws eks describe-cluster --name "$cluster_name" --region "$region" &>/dev/null; then + echo "ℹ️ Cluster already deleted or not found" + return 0 + fi + + # Check auth mode + AUTH_MODE=$(aws eks describe-cluster --name "$cluster_name" --region "$region" \ + --query 'cluster.accessConfig.authenticationMode' --output text 2>/dev/null || echo "CONFIG_MAP") + + echo "Current auth mode: $AUTH_MODE" + + if [ "$AUTH_MODE" = "CONFIG_MAP" ]; then + echo "⚠️ Auth mode is CONFIG_MAP. Updating to API_AND_CONFIG_MAP..." + + UPDATE_ID=$(aws eks update-cluster-config \ + --name "$cluster_name" \ + --access-config authenticationMode=API_AND_CONFIG_MAP \ + --region "$region" \ + --query 'update.id' --output text) + + echo "Update ID: $UPDATE_ID" + echo "Waiting for auth mode update (~2-3 min)..." + + # Wait for update to complete + for i in {1..40}; do + STATUS=$(aws eks describe-update \ + --name "$cluster_name" \ + --update-id "$UPDATE_ID" \ + --region "$region" \ + --query 'update.status' --output text 2>/dev/null || echo "Failed") + + echo " [$i/40] Update status: $STATUS" + + if [ "$STATUS" = "Successful" ]; then + echo "✓ Auth mode updated successfully" + return 0 + elif [ "$STATUS" = "Failed" ]; then + echo "❌ Auth mode update failed" + return 1 + fi + + sleep 5 + done + + echo "⚠️ Auth mode update timeout" + return 1 + fi + + return 0 +} + +# Delete stack with retry logic +delete_stack_with_retry() { + local attempt=1 + + while [ $attempt -le $MAX_RETRY ]; do + echo "" + echo "=== Deletion attempt $attempt/$MAX_RETRY ===" + + # Delete stack + aws cloudformation delete-stack \ + --stack-name "$STACK_NAME" \ + --region "$REGION" + + echo "Waiting for deletion (~10-15 min)..." + + # Wait for deletion with timeout + if aws cloudformation wait stack-delete-complete \ + --stack-name "$STACK_NAME" \ + --region "$REGION" 2>&1; then + echo "✅ Stack deleted successfully" + return 0 + fi + + # Check if stack still exists + if ! aws cloudformation describe-stacks --stack-name "$STACK_NAME" --region "$REGION" &>/dev/null; then + echo "✅ Stack deleted successfully" + return 0 + fi + + # Get current status + CURRENT_STATUS=$(aws cloudformation describe-stacks --stack-name "$STACK_NAME" --region "$REGION" \ + --query 'Stacks[0].StackStatus' --output text) + + echo "Current status: $CURRENT_STATUS" + + if [ "$CURRENT_STATUS" = "DELETE_FAILED" ]; then + echo "⚠️ Deletion failed. Checking for issues..." + + # Show failed resources + echo "Failed resources:" + aws cloudformation describe-stack-resources \ + --stack-name "$STACK_NAME" \ + --region "$REGION" \ + --query 'StackResources[?ResourceStatus==`DELETE_FAILED`].[LogicalResourceId,ResourceType,ResourceStatusReason]' \ + --output table + + # Check for EKSAccessEntry issues + FAILED_RESOURCE=$(aws cloudformation describe-stack-resources \ + --stack-name "$STACK_NAME" \ + --region "$REGION" \ + --query 'StackResources[?ResourceStatus==`DELETE_FAILED` && ResourceType==`AWS::EKS::AccessEntry`].LogicalResourceId' \ + --output text) + + if [ -n "$FAILED_RESOURCE" ]; then + echo "EKSAccessEntry deletion failed. Fixing auth mode..." + + if fix_auth_mode_if_needed "$CLUSTER_NAME" "$REGION"; then + echo "Retrying deletion..." + attempt=$((attempt + 1)) + sleep 5 + continue + else + echo "❌ Cannot fix auth mode. Manual intervention required." + return 1 + fi + fi + + # Other failures - retry anyway + echo "Retrying deletion..." + attempt=$((attempt + 1)) + sleep 5 + else + echo "❌ Unexpected status: $CURRENT_STATUS" + return 1 + fi + done + + echo "❌ Max retries exceeded" + return 1 +} + +# Execute deletion +if delete_stack_with_retry; then + echo "" + echo "✅ Cleanup complete!" + echo "" +else + echo "" + echo "❌ Cleanup failed after $MAX_RETRY attempts" + echo "" + echo "Manual cleanup required:" + echo " 1. Check CloudFormation console for detailed errors" + echo " 2. Delete stuck resources manually" + echo " 3. Run: aws cloudformation delete-stack --stack-name $STACK_NAME --region $REGION" + echo "" + exit 1 +fi diff --git a/custom/eks/test-fixtures/cloudformation/deploy.sh b/custom/eks/test-fixtures/cloudformation/deploy.sh new file mode 100755 index 00000000..462e81de --- /dev/null +++ b/custom/eks/test-fixtures/cloudformation/deploy.sh @@ -0,0 +1,114 @@ +#!/bin/bash +set -e + +STACK_NAME="${STACK_NAME:-claws-eks-test}" +CLUSTER_NAME="${CLUSTER_NAME:-claws-test-cluster}" +REGION="${AWS_REGION:-us-east-1}" + +echo "=== Deploying EKS Test Stack ===" +echo "Stack Name: $STACK_NAME" +echo "Cluster Name: $CLUSTER_NAME" +echo "Region: $REGION" +echo "" + +# Check if stack exists +STACK_EXISTS=false +if aws cloudformation describe-stacks --stack-name "$STACK_NAME" --region "$REGION" &>/dev/null; then + STACK_EXISTS=true + STACK_STATUS=$(aws cloudformation describe-stacks --stack-name "$STACK_NAME" --region "$REGION" --query 'Stacks[0].StackStatus' --output text) + echo "Stack Status: $STACK_STATUS" +fi + +# Handle existing stack in failed state +if [ "$STACK_EXISTS" = true ]; then + case "$STACK_STATUS" in + *ROLLBACK_COMPLETE|*FAILED) + echo "⚠️ Stack in failed state. Deleting before recreate..." + aws cloudformation delete-stack --stack-name "$STACK_NAME" --region "$REGION" + echo "Waiting for deletion..." + aws cloudformation wait stack-delete-complete --stack-name "$STACK_NAME" --region "$REGION" || true + STACK_EXISTS=false + ;; + *IN_PROGRESS) + echo "❌ Stack operation in progress. Wait or cancel existing operation." + exit 1 + ;; + esac +fi + +# Deploy stack +if [ "$STACK_EXISTS" = true ]; then + echo "⚠️ Stack exists. Updating..." + + UPDATE_OUTPUT=$(aws cloudformation update-stack \ + --stack-name "$STACK_NAME" \ + --template-body file://eks-test-stack.yaml \ + --parameters \ + ParameterKey=ClusterName,ParameterValue="$CLUSTER_NAME" \ + --capabilities CAPABILITY_IAM \ + --region "$REGION" 2>&1) || UPDATE_EXIT=$? + + if [ ${UPDATE_EXIT:-0} -ne 0 ]; then + if echo "$UPDATE_OUTPUT" | grep -q "No updates are to be performed"; then + echo "ℹ️ No updates needed" + else + echo "❌ Update failed: $UPDATE_OUTPUT" + exit 1 + fi + else + echo "Waiting for update (~15-20 min)..." + aws cloudformation wait stack-update-complete \ + --stack-name "$STACK_NAME" \ + --region "$REGION" + fi +else + echo "Creating stack..." + + aws cloudformation create-stack \ + --stack-name "$STACK_NAME" \ + --template-body file://eks-test-stack.yaml \ + --parameters \ + ParameterKey=ClusterName,ParameterValue="$CLUSTER_NAME" \ + --capabilities CAPABILITY_IAM \ + --region "$REGION" + + echo "Waiting for creation (~15-20 min)..." + aws cloudformation wait stack-create-complete \ + --stack-name "$STACK_NAME" \ + --region "$REGION" +fi + +# Verify deployment +echo "" +echo "✅ Stack deployed!" +echo "" + +# Verify cluster is active +CLUSTER_STATUS=$(aws eks describe-cluster --name "$CLUSTER_NAME" --region "$REGION" --query 'cluster.status' --output text 2>/dev/null || echo "NOT_FOUND") +if [ "$CLUSTER_STATUS" = "ACTIVE" ]; then + echo "✓ EKS Cluster: ACTIVE" +else + echo "⚠️ EKS Cluster: $CLUSTER_STATUS" +fi + +# Verify auth mode +AUTH_MODE=$(aws eks describe-cluster --name "$CLUSTER_NAME" --region "$REGION" --query 'cluster.accessConfig.authenticationMode' --output text 2>/dev/null || echo "UNKNOWN") +echo "✓ Auth Mode: $AUTH_MODE" + +# Show outputs +echo "" +echo "Stack Outputs:" +aws cloudformation describe-stacks \ + --stack-name "$STACK_NAME" \ + --region "$REGION" \ + --query 'Stacks[0].Outputs[?OutputKey==`ClusterName` || OutputKey==`ClusterEndpoint`].[OutputKey,OutputValue]' \ + --output table + +echo "" +echo "Next steps:" +echo " # Configure kubectl:" +echo " aws eks update-kubeconfig --name $CLUSTER_NAME --region $REGION" +echo "" +echo " # Test with claws:" +echo " AWS_REGION=$REGION claws" +echo "" diff --git a/custom/eks/test-fixtures/cloudformation/eks-test-stack.yaml b/custom/eks/test-fixtures/cloudformation/eks-test-stack.yaml new file mode 100644 index 00000000..eca541ab --- /dev/null +++ b/custom/eks/test-fixtures/cloudformation/eks-test-stack.yaml @@ -0,0 +1,420 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: 'EKS Test Stack for claws integration testing - Minimal cost configuration' + +Parameters: + ClusterName: + Type: String + Default: claws-test-cluster + Description: EKS cluster name + + NodeInstanceType: + Type: String + Default: t3.small + Description: EC2 instance type for worker nodes + +Resources: + # VPC + VPC: + Type: AWS::EC2::VPC + Properties: + CidrBlock: 10.0.0.0/16 + EnableDnsHostnames: true + EnableDnsSupport: true + Tags: + - Key: Name + Value: !Sub ${ClusterName}-vpc + + # Internet Gateway + InternetGateway: + Type: AWS::EC2::InternetGateway + Properties: + Tags: + - Key: Name + Value: !Sub ${ClusterName}-igw + + AttachGateway: + Type: AWS::EC2::VPCGatewayAttachment + Properties: + VpcId: !Ref VPC + InternetGatewayId: !Ref InternetGateway + + # Public Subnets (2 AZs required for EKS) + PublicSubnet1: + Type: AWS::EC2::Subnet + Properties: + VpcId: !Ref VPC + CidrBlock: 10.0.1.0/24 + AvailabilityZone: !Select [0, !GetAZs ''] + MapPublicIpOnLaunch: true + Tags: + - Key: Name + Value: !Sub ${ClusterName}-public-1 + - Key: kubernetes.io/role/elb + Value: 1 + + PublicSubnet2: + Type: AWS::EC2::Subnet + Properties: + VpcId: !Ref VPC + CidrBlock: 10.0.2.0/24 + AvailabilityZone: !Select [1, !GetAZs ''] + MapPublicIpOnLaunch: true + Tags: + - Key: Name + Value: !Sub ${ClusterName}-public-2 + - Key: kubernetes.io/role/elb + Value: 1 + + # Private Subnets (for worker nodes and Fargate) + PrivateSubnet1: + Type: AWS::EC2::Subnet + Properties: + VpcId: !Ref VPC + CidrBlock: 10.0.11.0/24 + AvailabilityZone: !Select [0, !GetAZs ''] + Tags: + - Key: Name + Value: !Sub ${ClusterName}-private-1 + - Key: kubernetes.io/role/internal-elb + Value: 1 + + PrivateSubnet2: + Type: AWS::EC2::Subnet + Properties: + VpcId: !Ref VPC + CidrBlock: 10.0.12.0/24 + AvailabilityZone: !Select [1, !GetAZs ''] + Tags: + - Key: Name + Value: !Sub ${ClusterName}-private-2 + - Key: kubernetes.io/role/internal-elb + Value: 1 + + # Elastic IP for NAT Gateway + NatGatewayEIP: + Type: AWS::EC2::EIP + DependsOn: AttachGateway + Properties: + Domain: vpc + Tags: + - Key: Name + Value: !Sub ${ClusterName}-nat-eip + + # NAT Gateway (single for cost optimization) + NatGateway: + Type: AWS::EC2::NatGateway + Properties: + AllocationId: !GetAtt NatGatewayEIP.AllocationId + SubnetId: !Ref PublicSubnet1 + Tags: + - Key: Name + Value: !Sub ${ClusterName}-nat + + # Public Route Table + PublicRouteTable: + Type: AWS::EC2::RouteTable + Properties: + VpcId: !Ref VPC + Tags: + - Key: Name + Value: !Sub ${ClusterName}-public-rt + + PublicRoute: + Type: AWS::EC2::Route + DependsOn: AttachGateway + Properties: + RouteTableId: !Ref PublicRouteTable + DestinationCidrBlock: 0.0.0.0/0 + GatewayId: !Ref InternetGateway + + SubnetRouteTableAssociation1: + Type: AWS::EC2::SubnetRouteTableAssociation + Properties: + SubnetId: !Ref PublicSubnet1 + RouteTableId: !Ref PublicRouteTable + + SubnetRouteTableAssociation2: + Type: AWS::EC2::SubnetRouteTableAssociation + Properties: + SubnetId: !Ref PublicSubnet2 + RouteTableId: !Ref PublicRouteTable + + # Private Route Table + PrivateRouteTable: + Type: AWS::EC2::RouteTable + Properties: + VpcId: !Ref VPC + Tags: + - Key: Name + Value: !Sub ${ClusterName}-private-rt + + PrivateRoute: + Type: AWS::EC2::Route + Properties: + RouteTableId: !Ref PrivateRouteTable + DestinationCidrBlock: 0.0.0.0/0 + NatGatewayId: !Ref NatGateway + + PrivateSubnetRouteTableAssociation1: + Type: AWS::EC2::SubnetRouteTableAssociation + Properties: + SubnetId: !Ref PrivateSubnet1 + RouteTableId: !Ref PrivateRouteTable + + PrivateSubnetRouteTableAssociation2: + Type: AWS::EC2::SubnetRouteTableAssociation + Properties: + SubnetId: !Ref PrivateSubnet2 + RouteTableId: !Ref PrivateRouteTable + + # IAM Role for EKS Cluster + EKSClusterRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: eks.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/AmazonEKSClusterPolicy + + # IAM Role for Node Group + EKSNodeRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: ec2.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy + - arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy + - arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly + + # IAM Role for Fargate + EKSFargateRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: eks-fargate-pods.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/AmazonEKSFargatePodExecutionRolePolicy + + # Security Groups + ClusterSecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: Security group for EKS control plane communication with worker nodes + VpcId: !Ref VPC + Tags: + - Key: Name + Value: !Sub ${ClusterName}-cluster-sg + + NodeSecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: Security group for all nodes in the EKS cluster + VpcId: !Ref VPC + Tags: + - Key: Name + Value: !Sub ${ClusterName}-node-sg + - Key: !Sub kubernetes.io/cluster/${ClusterName} + Value: owned + + # Control Plane -> Node + NodeSecurityGroupIngressFromControlPlane: + Type: AWS::EC2::SecurityGroupIngress + Properties: + Description: Allow kubelet and pods to receive communication from the cluster control plane + GroupId: !Ref NodeSecurityGroup + SourceSecurityGroupId: !Ref ClusterSecurityGroup + IpProtocol: tcp + FromPort: 1025 + ToPort: 65535 + + NodeSecurityGroupIngressFromControlPlaneHttps: + Type: AWS::EC2::SecurityGroupIngress + Properties: + Description: Allow pods to communicate with the cluster API Server + GroupId: !Ref NodeSecurityGroup + SourceSecurityGroupId: !Ref ClusterSecurityGroup + IpProtocol: tcp + FromPort: 443 + ToPort: 443 + + # Node -> Control Plane + ClusterSecurityGroupIngressFromNode: + Type: AWS::EC2::SecurityGroupIngress + Properties: + Description: Allow nodes to communicate with control plane + GroupId: !Ref ClusterSecurityGroup + SourceSecurityGroupId: !Ref NodeSecurityGroup + IpProtocol: tcp + FromPort: 443 + ToPort: 443 + + # Node -> Node (all traffic for pod-to-pod communication) + NodeSecurityGroupIngressFromSelf: + Type: AWS::EC2::SecurityGroupIngress + Properties: + Description: Allow nodes to communicate with each other (all protocols) + GroupId: !Ref NodeSecurityGroup + SourceSecurityGroupId: !Ref NodeSecurityGroup + IpProtocol: -1 + + # Node -> Internet (outbound for ECR, AWS APIs, etc.) + NodeSecurityGroupEgress: + Type: AWS::EC2::SecurityGroupEgress + Properties: + Description: Allow all outbound traffic (ECR, AWS APIs, etc.) + GroupId: !Ref NodeSecurityGroup + CidrIp: 0.0.0.0/0 + IpProtocol: -1 + + # EKS Cluster + EKSCluster: + Type: AWS::EKS::Cluster + Properties: + Name: !Ref ClusterName + Version: '1.31' + RoleArn: !GetAtt EKSClusterRole.Arn + AccessConfig: + AuthenticationMode: API_AND_CONFIG_MAP + ResourcesVpcConfig: + SecurityGroupIds: + - !Ref ClusterSecurityGroup + SubnetIds: + - !Ref PrivateSubnet1 + - !Ref PrivateSubnet2 + - !Ref PublicSubnet1 + - !Ref PublicSubnet2 + + # Launch Template for Node Group + NodeLaunchTemplate: + Type: AWS::EC2::LaunchTemplate + Properties: + LaunchTemplateName: !Sub ${ClusterName}-node-lt + LaunchTemplateData: + MetadataOptions: + HttpPutResponseHopLimit: 2 + HttpTokens: required + SecurityGroupIds: + - !Ref NodeSecurityGroup + TagSpecifications: + - ResourceType: instance + Tags: + - Key: Name + Value: !Sub ${ClusterName}-node + + # Node Group + EKSNodeGroup: + Type: AWS::EKS::Nodegroup + DependsOn: EKSCluster + Properties: + ClusterName: !Ref ClusterName + NodegroupName: !Sub ${ClusterName}-ng-1 + NodeRole: !GetAtt EKSNodeRole.Arn + Subnets: + - !Ref PrivateSubnet1 + - !Ref PrivateSubnet2 + ScalingConfig: + MinSize: 1 + DesiredSize: 1 + MaxSize: 2 + LaunchTemplate: + Id: !Ref NodeLaunchTemplate + Version: !GetAtt NodeLaunchTemplate.LatestVersionNumber + + # Fargate Profile + EKSFargateProfile: + Type: AWS::EKS::FargateProfile + DependsOn: EKSCluster + Properties: + ClusterName: !Ref ClusterName + FargateProfileName: !Sub ${ClusterName}-fargate + PodExecutionRoleArn: !GetAtt EKSFargateRole.Arn + Subnets: + - !Ref PrivateSubnet1 + - !Ref PrivateSubnet2 + Selectors: + - Namespace: fargate-test + + # Addon - VPC CNI + EKSAddonVpcCni: + Type: AWS::EKS::Addon + DependsOn: + - EKSCluster + - EKSNodeGroup + Properties: + ClusterName: !Ref ClusterName + AddonName: vpc-cni + ResolveConflicts: OVERWRITE + + # Access Entry (for testing) + EKSAccessEntry: + Type: AWS::EKS::AccessEntry + DependsOn: EKSCluster + Properties: + ClusterName: !Ref ClusterName + PrincipalArn: !GetAtt EKSNodeRole.Arn + Type: EC2_LINUX + +Outputs: + ClusterName: + Description: EKS Cluster Name + Value: !Ref ClusterName + Export: + Name: !Sub ${AWS::StackName}-ClusterName + + ClusterArn: + Description: EKS Cluster ARN + Value: !GetAtt EKSCluster.Arn + Export: + Name: !Sub ${AWS::StackName}-ClusterArn + + ClusterEndpoint: + Description: EKS Cluster Endpoint + Value: !GetAtt EKSCluster.Endpoint + Export: + Name: !Sub ${AWS::StackName}-ClusterEndpoint + + NodeGroupName: + Description: EKS Node Group Name + Value: !Ref EKSNodeGroup + Export: + Name: !Sub ${AWS::StackName}-NodeGroupName + + FargateProfileName: + Description: EKS Fargate Profile Name + Value: !Ref EKSFargateProfile + Export: + Name: !Sub ${AWS::StackName}-FargateProfileName + + VpcId: + Description: VPC ID + Value: !Ref VPC + Export: + Name: !Sub ${AWS::StackName}-VpcId + + ClusterSecurityGroupId: + Description: Security group for EKS control plane + Value: !Ref ClusterSecurityGroup + Export: + Name: !Sub ${AWS::StackName}-ClusterSecurityGroupId + + NodeSecurityGroupId: + Description: Security group for EKS worker nodes + Value: !Ref NodeSecurityGroup + Export: + Name: !Sub ${AWS::StackName}-NodeSecurityGroupId diff --git a/custom/iam/roles/dao.go b/custom/iam/roles/dao.go index 59810038..8f3f14c0 100644 --- a/custom/iam/roles/dao.go +++ b/custom/iam/roles/dao.go @@ -39,6 +39,20 @@ func (d *RoleDAO) List(ctx context.Context) ([]dao.Resource, error) { // ListPage returns a page of IAM roles. // Implements dao.PaginatedDAO interface. func (d *RoleDAO) ListPage(ctx context.Context, pageSize int, pageToken string) ([]dao.Resource, string, error) { + // Check for RoleName filter (for navigation from child resources) + if roleName := dao.GetFilterFromContext(ctx, "RoleName"); roleName != "" { + // Direct lookup for specific role + role, err := d.Get(ctx, roleName) + 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{role}, "", nil + } + maxItems := int32(pageSize) if maxItems > 1000 { maxItems = 1000 // AWS API max diff --git a/custom/iam/users/dao.go b/custom/iam/users/dao.go index eb93711e..6ee3bc9d 100644 --- a/custom/iam/users/dao.go +++ b/custom/iam/users/dao.go @@ -40,6 +40,20 @@ func NewUserDAO(ctx context.Context) (dao.DAO, error) { } func (d *UserDAO) List(ctx context.Context) ([]dao.Resource, error) { + // Check for UserName filter + if userName := dao.GetFilterFromContext(ctx, "UserName"); userName != "" { + // Direct lookup for specific user + user, err := d.Get(ctx, userName) + 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{user}, nil + } + paginator := iam.NewListUsersPaginator(d.client, &iam.ListUsersInput{}) var resources []dao.Resource diff --git a/custom/rds/instances/render.go b/custom/rds/instances/render.go index ef3740c0..6d0cbea3 100644 --- a/custom/rds/instances/render.go +++ b/custom/rds/instances/render.go @@ -298,7 +298,7 @@ func (r *InstanceRenderer) Navigations(resource dao.Resource) []render.Navigatio // Cluster navigation (for Aurora instances) if ir.Item.DBClusterIdentifier != nil { navs = append(navs, render.Navigation{ - Key: "c", Label: "Cluster", Service: "rds", Resource: "clusters", + Key: "p", Label: "Cluster", Service: "rds", Resource: "clusters", FilterField: "DBClusterIdentifier", FilterValue: *ir.Item.DBClusterIdentifier, }) } diff --git a/custom/stepfunctions/executions/render.go b/custom/stepfunctions/executions/render.go index 81a0f792..022902fc 100644 --- a/custom/stepfunctions/executions/render.go +++ b/custom/stepfunctions/executions/render.go @@ -200,7 +200,7 @@ func (r *ExecutionRenderer) Navigations(resource dao.Resource) []render.Navigati // State Machine navigation navs = append(navs, render.Navigation{ - Key: "m", Label: "State Machine", Service: "stepfunctions", Resource: "state-machines", + Key: "s", Label: "State Machine", Service: "stepfunctions", Resource: "state-machines", FilterField: "StateMachineArn", FilterValue: er.StateMachineARN(), }) diff --git a/custom/vpc/transit-gateways/render.go b/custom/vpc/transit-gateways/render.go index 38cbf084..1deb00ed 100644 --- a/custom/vpc/transit-gateways/render.go +++ b/custom/vpc/transit-gateways/render.go @@ -192,7 +192,7 @@ func (r *TransitGatewayRenderer) Navigations(resource dao.Resource) []render.Nav } return []render.Navigation{ { - Key: "a", + Key: "t", Label: "Attachments", Service: "vpc", Resource: "tgw-attachments", diff --git a/docs/services.md b/docs/services.md index c37a3552..40fdb646 100644 --- a/docs/services.md +++ b/docs/services.md @@ -1,6 +1,6 @@ # Supported Services -claws supports **69 services** with **163 resources**. +claws supports **69 services** with **169 resources**. ## Compute @@ -8,7 +8,7 @@ claws supports **69 services** with **163 resources**. |---------|-----------| | EC2 | Instances, Volumes, Security Groups, Elastic IPs, Key Pairs, AMIs, Snapshots, Launch Templates, Capacity Reservations | | Lambda | Functions | -| ECS | Clusters, Services, Tasks | +| ECS | Clusters, Services, Tasks, Task Definitions | | Auto Scaling | Groups, Activities | | App Runner | Services, Operations | | Batch | Job Queues, Compute Environments, Jobs, Job Definitions | @@ -39,6 +39,7 @@ claws supports **69 services** with **163 resources**. | Service | Resources | |---------|-----------| | ECR | Repositories, Images | +| EKS | Clusters, Node Groups, Fargate Profiles, Addons, Access Entries | | Bedrock | Foundation Models, Guardrails, Inference Profiles | | Bedrock Agent | Agents, Knowledge Bases, Data Sources, Prompts, Flows | | Bedrock AgentCore | Runtimes, Endpoints, Versions | @@ -155,3 +156,4 @@ Quick shortcuts for common services: | `agent` | Bedrock Agent Agents | | `models` | Bedrock Foundation Models | | `guardrail` | Bedrock Guardrails | +| `eks` | EKS | diff --git a/go.mod b/go.mod index 317e021b..a63d49ae 100644 --- a/go.mod +++ b/go.mod @@ -42,6 +42,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/ec2 v1.276.1 github.com/aws/aws-sdk-go-v2/service/ecr v1.54.4 github.com/aws/aws-sdk-go-v2/service/ecs v1.69.5 + github.com/aws/aws-sdk-go-v2/service/eks v1.76.3 github.com/aws/aws-sdk-go-v2/service/elasticache v1.51.8 github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.54.5 github.com/aws/aws-sdk-go-v2/service/emr v1.57.4 diff --git a/go.sum b/go.sum index a293a418..a55513e7 100644 --- a/go.sum +++ b/go.sum @@ -90,6 +90,8 @@ github.com/aws/aws-sdk-go-v2/service/ecr v1.54.4 h1:4THfkydiKvFeOFlfY1ABHe4Nsj+J github.com/aws/aws-sdk-go-v2/service/ecr v1.54.4/go.mod h1:8n8vVvu7LzveA0or4iWQwNndJStpKOX4HiVHM5jax2U= github.com/aws/aws-sdk-go-v2/service/ecs v1.69.5 h1:5nkhwt0d/gjuT3AQ2LUK0aFRNB3MGlzB2elqy/ZsKP4= github.com/aws/aws-sdk-go-v2/service/ecs v1.69.5/go.mod h1:LQMlcWBoiFVD3vUVEz42ST0yTiaDujv2dRE6sXt1yPE= +github.com/aws/aws-sdk-go-v2/service/eks v1.76.3 h1:840uwcJTIwrMPLuEUQVFKZbPgwnYzc5WDyXMiMYm5Ts= +github.com/aws/aws-sdk-go-v2/service/eks v1.76.3/go.mod h1:7IU8o/Snul26xioEWN5tgoOas1ISPGsiq5gME5rPh3o= github.com/aws/aws-sdk-go-v2/service/elasticache v1.51.8 h1:LiAvvvkFFhvL0AKbsDwEFLC6w4jLOd6r/eNk/b7ZvL4= github.com/aws/aws-sdk-go-v2/service/elasticache v1.51.8/go.mod h1:QMDpBJOUoPTE4u4IJjbbmrY9ky+yFe6rU1FdKQtvc30= github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.54.5 h1:JjKuK9zbAVv6X44ia/OZrRS8ngOx3QfvtQTN0poJdPw= diff --git a/internal/aws/helpers.go b/internal/aws/helpers.go index 4d09bdc3..2daa9db2 100644 --- a/internal/aws/helpers.go +++ b/internal/aws/helpers.go @@ -60,6 +60,7 @@ func Int64Ptr(i int64) *int64 { // ExtractResourceName extracts the resource name from an AWS ARN. // e.g., "arn:aws:iam::123456789012:role/MyRole" -> "MyRole" +// e.g., "arn:aws:iam::123456789012:role/service-role/MyRole" -> "MyRole" // e.g., "arn:aws:ecs:us-east-1:123456789012:cluster/my-cluster" -> "my-cluster" // e.g., "arn:aws:s3:::my-bucket" -> "my-bucket" func ExtractResourceName(arn string) string { diff --git a/internal/errors/errors.go b/internal/errors/errors.go index fe0e4498..05e9217e 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -90,6 +90,7 @@ func IsNotFound(err error) bool { "NoSuchKey", "NotFoundException", "ResourceNotFoundFault", + "not found", // Match lowercase pattern from DAO Get() methods ) } diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 15704c38..ebdc2d09 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -173,6 +173,7 @@ func defaultDisplayNames() map[string]string { "ecr": "ECR", "elasticache": "ElastiCache", "ecs": "ECS", + "eks": "EKS", "elbv2": "Elastic Load Balancing", "emr": "EMR", "events": "EventBridge", @@ -220,7 +221,7 @@ func defaultCategories() []ServiceCategory { return []ServiceCategory{ { Name: "Compute", - Services: []string{"ec2", "lambda", "ecs", "autoscaling", "apprunner", "batch", "emr"}, + Services: []string{"ec2", "lambda", "ecs", "eks", "autoscaling", "apprunner", "batch", "emr"}, }, { Name: "Storage & Database", @@ -546,6 +547,7 @@ var defaultResources = map[string]string{ "ec2": "instances", "ecr": "repositories", "ecs": "clusters", + "eks": "clusters", "elbv2": "load-balancers", "emr": "clusters", "events": "rules", @@ -646,6 +648,10 @@ var subResourceSet = map[string]struct{}{ "organizations/ous": {}, "license-manager/grants": {}, "appsync/data-sources": {}, + "eks/node-groups": {}, + "eks/fargate-profiles": {}, + "eks/addons": {}, + "eks/access-entries": {}, "redshift/snapshots": {}, } diff --git a/internal/view/diff_view.go b/internal/view/diff_view.go index 99f66273..cb41a783 100644 --- a/internal/view/diff_view.go +++ b/internal/view/diff_view.go @@ -14,8 +14,10 @@ import ( type DiffView struct { ctx context.Context - left dao.Resource - right dao.Resource + left dao.Resource // wrapped resource (for metadata) + right dao.Resource // wrapped resource (for metadata) + leftUnwrap dao.Resource // unwrapped for rendering + rightUnwrap dao.Resource // unwrapped for rendering renderer render.Renderer service string resourceType string @@ -39,11 +41,14 @@ func newDiffViewStyles() diffViewStyles { } // NewDiffView creates a new DiffView for comparing two resources +// Accepts wrapped resources (ProfiledResource/RegionalResource) and unwraps internally for rendering func NewDiffView(ctx context.Context, left, right dao.Resource, renderer render.Renderer, service, resourceType string) *DiffView { return &DiffView{ ctx: ctx, - left: left, - right: right, + left: left, // keep wrapped for metadata + right: right, // keep wrapped for metadata + leftUnwrap: dao.UnwrapResource(left), // unwrap for rendering + rightUnwrap: dao.UnwrapResource(right), renderer: renderer, service: service, resourceType: resourceType, @@ -108,7 +113,7 @@ func (d *DiffView) SetSize(width, height int) tea.Cmd { // StatusLine implements View func (d *DiffView) StatusLine() string { - return dao.UnwrapResource(d.left).GetName() + " vs " + dao.UnwrapResource(d.right).GetName() + " • ↑/↓:scroll • q/esc:back" + return d.leftUnwrap.GetName() + " vs " + d.rightUnwrap.GetName() + " • ↑/↓:scroll • q/esc:back" } // renderSideBySide generates the side-by-side view @@ -124,8 +129,8 @@ func (d *DiffView) renderSideBySide() string { leftDetail := "" rightDetail := "" if d.renderer != nil { - leftDetail = d.renderer.RenderDetail(dao.UnwrapResource(d.left)) - rightDetail = d.renderer.RenderDetail(dao.UnwrapResource(d.right)) + leftDetail = d.renderer.RenderDetail(d.leftUnwrap) + rightDetail = d.renderer.RenderDetail(d.rightUnwrap) } // Split into lines @@ -136,8 +141,8 @@ func (d *DiffView) renderSideBySide() string { colWidth := (d.width - 3) / 2 // Column headers - leftHeader := TruncateOrPadString("◀ "+dao.UnwrapResource(d.left).GetName(), colWidth) - rightHeader := TruncateOrPadString(dao.UnwrapResource(d.right).GetName()+" ▶", colWidth) + leftHeader := TruncateOrPadString("◀ "+d.leftUnwrap.GetName(), colWidth) + rightHeader := TruncateOrPadString(d.rightUnwrap.GetName()+" ▶", colWidth) out.WriteString(s.header.Render(leftHeader)) out.WriteString(s.separator.Render(" │ ")) out.WriteString(s.header.Render(rightHeader)) diff --git a/internal/view/resource_browser_input.go b/internal/view/resource_browser_input.go index 5cedc3f6..b2f9f5a4 100644 --- a/internal/view/resource_browser_input.go +++ b/internal/view/resource_browser_input.go @@ -133,9 +133,9 @@ func (r *ResourceBrowser) handleClearFilter() (tea.Model, tea.Cmd) { r.fieldFilter = "" r.fieldFilterValue = "" r.markedResource = nil - r.applyFilter() - r.buildTable() - return r, nil + r.loading = true + r.err = nil + return r, tea.Batch(r.loadResources, r.spinner.Tick) } func (r *ResourceBrowser) handleEsc() (tea.Model, tea.Cmd) { @@ -196,7 +196,7 @@ func (r *ResourceBrowser) handleAction() (tea.Model, tea.Cmd) { if len(r.filtered) > 0 && cursor >= 0 && cursor < len(r.filtered) { if actions := action.Global.Get(r.service, r.resourceType); len(actions) > 0 { ctx, resource := r.contextForResource(r.filtered[cursor]) - actionMenu := NewActionMenu(ctx, resource, r.service, r.resourceType) + actionMenu := NewActionMenu(ctx, dao.UnwrapResource(resource), r.service, r.resourceType) return r, func() tea.Msg { return ShowModalMsg{Modal: &Modal{Content: actionMenu, Width: ModalWidthActionMenu}} } diff --git a/internal/view/resource_browser_nav.go b/internal/view/resource_browser_nav.go index adc300b8..345b9c08 100644 --- a/internal/view/resource_browser_nav.go +++ b/internal/view/resource_browser_nav.go @@ -18,7 +18,8 @@ func (r *ResourceBrowser) handleNavigation(key string) (tea.Model, tea.Cmd) { return nil, nil } - ctx, resource := r.contextForResource(r.filtered[r.tc.Cursor()]) + ctx, _ := r.contextForResource(r.filtered[r.tc.Cursor()]) + resource := dao.UnwrapResource(r.filtered[r.tc.Cursor()]) helper := &NavigationHelper{ Ctx: ctx,