diff --git a/README.md b/README.md index ae6f637..fee2869 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ It combines a Bubble Tea application, Cobra-based CLI commands, and AWS SDK v2 c - Drill down into resources with filters, detail views, and action screens - Open a context-aware keyboard shortcut help screen with `?` - Show animated loading indicators while async AWS data is being fetched -- Perform operational workflows such as EC2 inventory inspection, SSM sessions, RDS control, Route53 record changes, ECS rollout inspection/exec, IAM access key rotation, and Bedrock API key management +- Perform operational workflows such as EC2 inventory inspection, SSM sessions, RDS control, Route53 record changes, ECS rollout inspection/exec, EKS cluster and node group review, IAM access key rotation, and Bedrock API key management - Press `i` from the service picker to enter Inspector mode, then run either the Security Inspector workflow for built-in findings or the Checklist Inspector workflow for YAML-driven readiness checks across databases, network resources, DNS, logging, secrets, and baseline posture. Checklist files can be loaded from the in-TUI picker or preloaded with `--checklist ` ## Documentation Map @@ -226,6 +226,7 @@ Context ordering: | CloudWatch Logs | Logs Browser | | ECR | ECR Login Helper | | ECS | ECS Browser & Exec | +| EKS | Cluster & Node Group Browser | | S3 | S3 Browser | | Lambda | Lambda Browser | | Bedrock | API Key Manager | @@ -340,13 +341,14 @@ checks: | ECR Login | CLI helper: `unic ecr login [--runtime docker|podman] [--copy]` | | ECS Exec | `r` refresh, `Enter` drill down / exec | | ECS Rollout / Exec | cluster/service lists support refresh and drill-down, service detail shows deployments/task definition images/events, `Enter` continues into tasks and exec | +| EKS Browser | cluster/node group lists support `/` filter and `r` refresh, cluster view shows version/status/endpoint visibility/ARN summary, node group detail shows desired/min/max scaling plus health issues | | Inspector Mode | `i` open mode from the service list, `Enter` open the selected workflow, `l` open the checklist file picker | | Security Inspector | `r` run/rescan, `1`-`5` severity filter, `Enter` finding detail | | Checklist Inspector | `l` load or switch checklist files, `r` run/rerun the loaded checklist, `Enter` result detail | | Context Picker | `a` add context, type or `/` filter, `s` setup selected context and quit, `y` copy selected exports and quit, `u` clear shell context and quit with a final confirmation message | | Lambda | `Enter` invoke, `d` detail, `l` view CloudWatch Logs, `/` filter, `r` refresh | -The service list defaults to favorites first, then alphabetical order. Press `f` to favorite or unfavorite the selected service; favorites are saved under `favorites.services` in `config.yaml` and rendered with a distinct marker/style. The service list supports `/` filtering across service names, feature names, and feature descriptions. Shared list filters use fuzzy matching with inline match highlighting. While filter mode stays active, `↑`/`↓` continue to move through the filtered results without requiring an extra Enter first. Filtering is currently available on the service list, EC2 SSM instances, EC2 inventory instances, IAM users, VPCs, subnets, RDS instances, Route53 zones/records, CloudWatch metrics, CloudWatch log groups/streams, Secrets Manager resources, ECS clusters/services, S3 buckets/objects, Lambda functions, Bedrock API keys, and the context picker. +The service list defaults to favorites first, then alphabetical order. Press `f` to favorite or unfavorite the selected service; favorites are saved under `favorites.services` in `config.yaml` and rendered with a distinct marker/style. The service list supports `/` filtering across service names, feature names, and feature descriptions. Shared list filters use fuzzy matching with inline match highlighting. While filter mode stays active, `↑`/`↓` continue to move through the filtered results without requiring an extra Enter first. Filtering is currently available on the service list, EC2 SSM instances, EC2 inventory instances, IAM users, VPCs, subnets, RDS instances, Route53 zones/records, CloudWatch metrics, CloudWatch log groups/streams, Secrets Manager resources, ECS clusters/services, EKS clusters/node groups, S3 buckets/objects, Lambda functions, Bedrock API keys, and the context picker. The EC2 Instance Browser lists EC2 instances across available states for the active context and region, separate from the SSM session picker that only lists connectable running instances. The detail screen shows core metadata including instance ID, name tag, state, instance type, AZ, VPC, subnet, private and public IPs, launch time, platform details, IAM profile, and tags. diff --git a/go.mod b/go.mod index 1a289a7..b8736fc 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module unic go 1.24.2 require ( - github.com/aws/aws-sdk-go-v2 v1.41.5 + github.com/aws/aws-sdk-go-v2 v1.41.6 github.com/aws/aws-sdk-go-v2/config v1.32.12 github.com/aws/aws-sdk-go-v2/credentials v1.19.12 github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.56.0 @@ -18,7 +18,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssm v1.68.3 github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 - github.com/aws/smithy-go v1.24.2 + github.com/aws/smithy-go v1.25.0 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 @@ -30,12 +30,13 @@ require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect github.com/aws/aws-sdk-go-v2/service/cloudtrail v1.55.9 // indirect github.com/aws/aws-sdk-go-v2/service/configservice v1.62.1 // indirect + github.com/aws/aws-sdk-go-v2/service/eks v1.82.1 // indirect github.com/aws/aws-sdk-go-v2/service/elasticache v1.52.0 // indirect github.com/aws/aws-sdk-go-v2/service/guardduty v1.75.0 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect diff --git a/go.sum b/go.sum index baff826..e122eca 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg= +github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0= @@ -12,8 +14,12 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqb github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 h1:GmLa5Kw1ESqtFpXsx5MmC84QWa/ZrLZvlJGa2y+4kcQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22/go.mod h1:6sW9iWm9DK9YRpRGga/qzrzNLgKpT2cIxb7Vo2eNOp0= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 h1:dY4kWZiSaXIzxnKlj17nHnBcXXBfac6UlsAx2qL6XrU= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22/go.mod h1:KIpEUx0JuRZLO7U6cbV204cWAEco2iC3l061IxlwLtI= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ= @@ -30,6 +36,8 @@ github.com/aws/aws-sdk-go-v2/service/ec2 v1.296.0 h1:98Miqj16un1WLNyM1RjVDhXYumh github.com/aws/aws-sdk-go-v2/service/ec2 v1.296.0/go.mod h1:T6ndRfdhnXLIY5oKBHjYZDVj706los2zGdpThppquvA= github.com/aws/aws-sdk-go-v2/service/ecs v1.76.0 h1:a5G/TgJNrpuCjZBTf8/PTN0C2B0do/ylaYVynxPSbUQ= github.com/aws/aws-sdk-go-v2/service/ecs v1.76.0/go.mod h1:QkWmubOYmjj3cHn7A4CoUU7BKJhVeo39Gp6NH7IyhZw= +github.com/aws/aws-sdk-go-v2/service/eks v1.82.1 h1:xTzXiQ8Q6U4ACdMNSCm72zd4Ds7QxhgVLqt5x8GXLBM= +github.com/aws/aws-sdk-go-v2/service/eks v1.82.1/go.mod h1:jjcGpziR11RTrr3JIgXg/Nn8GSwK3WOz2z1v/RqEBUI= github.com/aws/aws-sdk-go-v2/service/elasticache v1.52.0 h1:inluxH5ArTlQNGrFxP7RN5o5DEfP8bRbkPC/408Esgs= github.com/aws/aws-sdk-go-v2/service/elasticache v1.52.0/go.mod h1:DxywiXnEB21757xcql9xCqgt8vyTxSB7tVEIOdfKIY8= github.com/aws/aws-sdk-go-v2/service/guardduty v1.75.0 h1:sZA3jpOkwrinRL0B5aZY6npTjDmmBCJRY51dee1Oh7Q= @@ -66,6 +74,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8 github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk= github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U= +github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= diff --git a/internal/app/app.go b/internal/app/app.go index 7fad1a7..6008014 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -64,6 +64,9 @@ const ( screenECSServiceDetail screenECSTaskList screenECSContainerList + screenEKSClusterList + screenEKSNodeGroupList + screenEKSNodeGroupDetail screenS3BucketList screenS3ObjectList screenS3ObjectDetail @@ -233,6 +236,18 @@ type Model struct { ecsContainers []awsservice.ECSContainer ecsContainerIdx int + // EKS browser state + eksClusters []awsservice.EKSCluster + filteredEKSClusters []awsservice.EKSCluster + eksClusterIdx int + selectedEKSCluster *awsservice.EKSCluster + + eksNodeGroups []awsservice.EKSNodeGroup + filteredEKSNodeGroups []awsservice.EKSNodeGroup + eksNodeGroupIdx int + selectedEKSNodeGroup *awsservice.EKSNodeGroup + eksNodeGroupScroll int + // Feature submodels ec2Browser ec2InstanceBrowserModel cwMetrics cloudWatchMetricsModel @@ -448,6 +463,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.handleSecurityGroupMsg, m.handleIAMMsg, m.handleECSMsg, + m.handleEKSMsg, m.handleInspectorMsg, m.handleContextMsg, } { @@ -587,6 +603,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateECSTaskList(msg) case screenECSContainerList: return m.updateECSContainerList(msg) + case screenEKSClusterList: + return m.updateEKSClusterList(msg) + case screenEKSNodeGroupList: + return m.updateEKSNodeGroupList(msg) + case screenEKSNodeGroupDetail: + return m.updateEKSNodeGroupDetail(msg) case screenContextPicker: return m.updateContextPicker(msg) case screenContextAdd: @@ -711,6 +733,8 @@ func (m Model) updateFeatureList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.startLoading(m.loadIAMKeys()) case domain.FeatureECSExec: return m.startLoading(m.loadECSClusters()) + case domain.FeatureEKSBrowser: + return m.startLoading(m.loadEKSClusters()) case domain.FeatureLambdaBrowser: return m.lambda.Start(&m) case domain.FeatureBedrockAPIKeys: @@ -829,6 +853,12 @@ func (m Model) View() string { v = m.viewECSTaskList() case screenECSContainerList: v = m.viewECSContainerList() + case screenEKSClusterList: + v = m.viewEKSClusterList() + case screenEKSNodeGroupList: + v = m.viewEKSNodeGroupList() + case screenEKSNodeGroupDetail: + v = m.viewEKSNodeGroupDetail() case screenContextPicker: v = m.viewContextPicker() case screenContextAdd: diff --git a/internal/app/filter.go b/internal/app/filter.go index f70efd0..203829f 100644 --- a/internal/app/filter.go +++ b/internal/app/filter.go @@ -21,6 +21,8 @@ const ( filterSecurityGroups filterECSClusters filterECSServices + filterEKSClusters + filterEKSNodeGroups filterCWMetrics filterCWLogGroups filterCWLogStreams @@ -182,6 +184,12 @@ func (m *Model) applyFilterTarget(target filterTarget) { case filterECSServices: m.filteredECSServices = applyFilter(m.ecsServices, m.filterValue(target)) m.ecsServiceIdx = 0 + case filterEKSClusters: + m.filteredEKSClusters = applyFilter(m.eksClusters, m.filterValue(target)) + m.eksClusterIdx = 0 + case filterEKSNodeGroups: + m.filteredEKSNodeGroups = applyFilter(m.eksNodeGroups, m.filterValue(target)) + m.eksNodeGroupIdx = 0 case filterContexts: m.filteredCtxList = applyFilter(m.ctxList, m.filterValue(target)) m.ctxIdx = 0 diff --git a/internal/app/help.go b/internal/app/help.go index f08779f..4278c6f 100644 --- a/internal/app/help.go +++ b/internal/app/help.go @@ -405,6 +405,17 @@ func (m Model) currentScreenShortcuts() []helpShortcut { {"enter", "Start an ECS exec session for the selected container"}, {"q / esc", "Go back to the task list"}, } + case screenEKSClusterList: + return listScreenShortcuts("open managed node groups for the selected cluster", "go back to the feature list", true, true) + case screenEKSNodeGroupList: + return listScreenShortcuts("open the selected node group", "go back to the cluster list", true, true) + case screenEKSNodeGroupDetail: + return []helpShortcut{ + {"↑/↓, j/k", "Scroll the node group detail"}, + {"pgup / pgdn", "Scroll by one page"}, + {"esc", "Go back to the node group list"}, + {"q", "Go back to the feature list"}, + } case screenS3BucketList: return listScreenShortcuts("browse the selected bucket", "go back to the feature list", true, false) case screenS3ObjectList: @@ -763,6 +774,12 @@ func (m Model) helpScreenTitle() string { return "ECS Tasks" case screenECSContainerList: return "ECS Containers" + case screenEKSClusterList: + return "EKS Clusters" + case screenEKSNodeGroupList: + return "EKS Node Groups" + case screenEKSNodeGroupDetail: + return "EKS Node Group Detail" case screenS3BucketList: return "S3 Buckets" case screenS3ObjectList: diff --git a/internal/app/messages.go b/internal/app/messages.go index 8fb6bbe..bbb59ea 100644 --- a/internal/app/messages.go +++ b/internal/app/messages.go @@ -237,6 +237,14 @@ type ecsExecDoneMsg struct { err error } +type eksClustersLoadedMsg struct { + clusters []awsservice.EKSCluster +} + +type eksNodeGroupsLoadedMsg struct { + nodeGroups []awsservice.EKSNodeGroup +} + type inspectorScanLoadedMsg struct { report *inspector.SecurityScanReport } diff --git a/internal/app/screen_eks.go b/internal/app/screen_eks.go new file mode 100644 index 0000000..bb7ff50 --- /dev/null +++ b/internal/app/screen_eks.go @@ -0,0 +1,354 @@ +package app + +import ( + "context" + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + + awsservice "unic/internal/services/aws" +) + +const eksAPITimeout = 30 * time.Second + +func (m Model) handleEKSMsg(msg tea.Msg) (tea.Model, tea.Cmd, bool) { + switch msg := msg.(type) { + case eksClustersLoadedMsg: + m.eksClusters = msg.clusters + m.filteredEKSClusters = msg.clusters + m.eksClusterIdx = 0 + m.selectedEKSCluster = nil + m.eksNodeGroups = nil + m.filteredEKSNodeGroups = nil + m.selectedEKSNodeGroup = nil + m.eksNodeGroupScroll = 0 + m.resetFilter(filterEKSClusters) + m.screen = screenEKSClusterList + return m, nil, true + case eksNodeGroupsLoadedMsg: + m.eksNodeGroups = msg.nodeGroups + m.filteredEKSNodeGroups = msg.nodeGroups + m.eksNodeGroupIdx = 0 + m.selectedEKSNodeGroup = nil + m.eksNodeGroupScroll = 0 + m.resetFilter(filterEKSNodeGroups) + m.screen = screenEKSNodeGroupList + return m, nil, true + } + return m, nil, false +} + +func (m Model) updateEKSClusterList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if cmd, handled := m.updateSharedFilter(msg, filterEKSClusters); handled { + return m, cmd + } + + switch msg.String() { + case "q", "esc": + m.screen = screenFeatureList + case "up", "k": + m.eksClusterIdx = previousListIndex(m.eksClusterIdx, len(m.filteredEKSClusters)) + case "down", "j": + m.eksClusterIdx = nextListIndex(m.eksClusterIdx, len(m.filteredEKSClusters)) + case "/": + return m, m.activateFilter(filterEKSClusters) + case "r": + return m.startLoading(m.loadEKSClusters()) + case "enter": + if len(m.filteredEKSClusters) > 0 && m.eksClusterIdx < len(m.filteredEKSClusters) { + cluster := m.filteredEKSClusters[m.eksClusterIdx] + m.selectedEKSCluster = &cluster + return m.startLoading(m.loadEKSNodeGroups()) + } + } + return m, nil +} + +func (m Model) viewEKSClusterList() string { + var b strings.Builder + var panel strings.Builder + b.WriteString(m.renderStatusBar()) + b.WriteString(titleStyle.Render("EKS Clusters")) + b.WriteString("\n") + b.WriteString(m.renderFilterValue(filterEKSClusters)) + b.WriteString("\n\n") + + if len(m.filteredEKSClusters) == 0 { + panel.WriteString(dimStyle.Render(" No EKS clusters found")) + panel.WriteString("\n") + } else { + visibleLines := max(m.height-16, 5) + start := 0 + if m.eksClusterIdx >= visibleLines { + start = m.eksClusterIdx - visibleLines + 1 + } + end := min(start+visibleLines, len(m.filteredEKSClusters)) + for i := start; i < end; i++ { + cluster := m.filteredEKSClusters[i] + cursor := " " + style := normalStyle + if i == m.eksClusterIdx { + cursor = "> " + style = selectedStyle + } + panel.WriteString(style.Render(cursor + m.renderHighlightedValue(filterEKSClusters, cluster.DisplayTitle()))) + panel.WriteString("\n") + } + panel.WriteString("\n") + panel.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d clusters", len(m.filteredEKSClusters), len(m.eksClusters)))) + } + + b.WriteString(m.renderListPanel(panel.String())) + b.WriteString("\n\n") + if cluster := m.currentEKSCluster(); cluster != nil { + b.WriteString(titleStyle.Render("Selected Cluster")) + b.WriteString("\n") + b.WriteString(renderDetailLine("Version", cluster.Version)) + b.WriteString("\n") + b.WriteString(renderDetailLine("Status", cluster.Status)) + b.WriteString("\n") + b.WriteString(renderDetailLine("Endpoint", cluster.EndpointVisibility())) + b.WriteString("\n") + b.WriteString(renderDetailLine("ARN", cluster.ARN)) + b.WriteString("\n\n") + } + b.WriteString(m.renderHelpBar("↑/↓: navigate • /: filter • r: refresh • enter: node groups • esc: back • H: home")) + return b.String() +} + +func (m Model) updateEKSNodeGroupList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if cmd, handled := m.updateSharedFilter(msg, filterEKSNodeGroups); handled { + return m, cmd + } + + switch msg.String() { + case "q": + m.screen = screenFeatureList + case "esc": + m.screen = screenEKSClusterList + case "up", "k": + m.eksNodeGroupIdx = previousListIndex(m.eksNodeGroupIdx, len(m.filteredEKSNodeGroups)) + case "down", "j": + m.eksNodeGroupIdx = nextListIndex(m.eksNodeGroupIdx, len(m.filteredEKSNodeGroups)) + case "/": + return m, m.activateFilter(filterEKSNodeGroups) + case "r": + return m.startLoading(m.loadEKSNodeGroups()) + case "enter": + if len(m.filteredEKSNodeGroups) > 0 && m.eksNodeGroupIdx < len(m.filteredEKSNodeGroups) { + nodeGroup := m.filteredEKSNodeGroups[m.eksNodeGroupIdx] + m.selectedEKSNodeGroup = &nodeGroup + m.eksNodeGroupScroll = 0 + m.screen = screenEKSNodeGroupDetail + } + } + return m, nil +} + +func (m Model) viewEKSNodeGroupList() string { + var b strings.Builder + var panel strings.Builder + clusterName := "" + if m.selectedEKSCluster != nil { + clusterName = m.selectedEKSCluster.Name + } + b.WriteString(m.renderStatusBar()) + b.WriteString(titleStyle.Render(fmt.Sprintf("EKS Node Groups — %s", clusterName))) + b.WriteString("\n") + b.WriteString(m.renderFilterValue(filterEKSNodeGroups)) + b.WriteString("\n\n") + + if len(m.filteredEKSNodeGroups) == 0 { + panel.WriteString(dimStyle.Render(" No managed node groups found")) + panel.WriteString("\n") + } else { + visibleLines := max(m.height-17, 5) + start := 0 + if m.eksNodeGroupIdx >= visibleLines { + start = m.eksNodeGroupIdx - visibleLines + 1 + } + end := min(start+visibleLines, len(m.filteredEKSNodeGroups)) + for i := start; i < end; i++ { + nodeGroup := m.filteredEKSNodeGroups[i] + cursor := " " + style := normalStyle + if i == m.eksNodeGroupIdx { + cursor = "> " + style = selectedStyle + } + panel.WriteString(style.Render(cursor + m.renderHighlightedValue(filterEKSNodeGroups, nodeGroup.DisplayTitle()))) + panel.WriteString("\n") + } + panel.WriteString("\n") + panel.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d node groups", len(m.filteredEKSNodeGroups), len(m.eksNodeGroups)))) + } + + b.WriteString(m.renderListPanel(panel.String())) + b.WriteString("\n\n") + if nodeGroup := m.currentEKSNodeGroup(); nodeGroup != nil { + b.WriteString(titleStyle.Render("Selected Node Group")) + b.WriteString("\n") + b.WriteString(renderDetailLine("Scaling", fmt.Sprintf("desired:%d min:%d max:%d", nodeGroup.DesiredSize, nodeGroup.MinSize, nodeGroup.MaxSize))) + b.WriteString("\n") + b.WriteString(renderDetailLine("Version", firstNonEmpty(nodeGroup.Version, nodeGroup.ReleaseVersion, "-"))) + b.WriteString("\n") + b.WriteString(renderDetailLine("Health", nodeGroup.HealthSummary())) + b.WriteString("\n\n") + } + b.WriteString(m.renderHelpBar("↑/↓: navigate • /: filter • r: refresh • enter: detail • esc: back • q: feature list • H: home")) + return b.String() +} + +func (m Model) updateEKSNodeGroupDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + lines := m.eksNodeGroupDetailLines() + visibleLines := max(m.height-9, 5) + maxOffset := max(len(lines)-visibleLines, 0) + + switch msg.String() { + case "q": + m.screen = screenFeatureList + case "esc": + m.screen = screenEKSNodeGroupList + case "up", "k": + if m.eksNodeGroupScroll > 0 { + m.eksNodeGroupScroll-- + } + case "down", "j": + if m.eksNodeGroupScroll < maxOffset { + m.eksNodeGroupScroll++ + } + case "pgup": + m.eksNodeGroupScroll -= visibleLines + if m.eksNodeGroupScroll < 0 { + m.eksNodeGroupScroll = 0 + } + case "pgdown": + m.eksNodeGroupScroll += visibleLines + if m.eksNodeGroupScroll > maxOffset { + m.eksNodeGroupScroll = maxOffset + } + } + return m, nil +} + +func (m Model) viewEKSNodeGroupDetail() string { + var b strings.Builder + b.WriteString(m.renderStatusBar()) + title := "EKS Node Group Detail" + if m.selectedEKSNodeGroup != nil { + title = fmt.Sprintf("EKS Node Group Detail — %s", m.selectedEKSNodeGroup.Name) + } + b.WriteString(titleStyle.Render(title)) + b.WriteString("\n\n") + + lines := m.eksNodeGroupDetailLines() + visibleLines := max(m.height-9, 5) + start := min(m.eksNodeGroupScroll, max(len(lines)-visibleLines, 0)) + end := min(start+visibleLines, len(lines)) + + var panel strings.Builder + for _, line := range lines[start:end] { + panel.WriteString(line) + panel.WriteString("\n") + } + b.WriteString(m.renderListPanel(panel.String())) + b.WriteString("\n\n") + b.WriteString(m.renderHelpBar("↑/↓: scroll • pgup/pgdn: page • esc: back • q: feature list • H: home")) + return b.String() +} + +func (m Model) eksNodeGroupDetailLines() []string { + if m.selectedEKSNodeGroup == nil { + return []string{dimStyle.Render("No node group selected")} + } + nodeGroup := m.selectedEKSNodeGroup + lines := []string{ + renderDetailLine("Cluster", nodeGroup.ClusterName), + renderDetailLine("Status", nodeGroup.Status), + renderDetailLine("Version", firstNonEmpty(nodeGroup.Version, "-")), + renderDetailLine("Release", firstNonEmpty(nodeGroup.ReleaseVersion, "-")), + renderDetailLine("AMI Type", firstNonEmpty(nodeGroup.AmiType, "-")), + renderDetailLine("Capacity", firstNonEmpty(nodeGroup.CapacityType, "-")), + renderDetailLine("Scaling", fmt.Sprintf("desired:%d min:%d max:%d", nodeGroup.DesiredSize, nodeGroup.MinSize, nodeGroup.MaxSize)), + renderDetailLine("Instances", nodeGroup.InstanceTypesLabel()), + renderDetailLine("ARN", nodeGroup.ARN), + "", + titleStyle.Render("Health"), + } + if len(nodeGroup.HealthIssues) == 0 { + lines = append(lines, " "+successStyle.Render("No active health issues")) + return lines + } + for _, issue := range nodeGroup.HealthIssues { + lines = append(lines, " "+warningStyle.Render(issue.Summary())) + } + return lines +} + +func (m Model) currentEKSCluster() *awsservice.EKSCluster { + if len(m.filteredEKSClusters) == 0 || m.eksClusterIdx < 0 || m.eksClusterIdx >= len(m.filteredEKSClusters) { + return nil + } + cluster := m.filteredEKSClusters[m.eksClusterIdx] + return &cluster +} + +func (m Model) currentEKSNodeGroup() *awsservice.EKSNodeGroup { + if len(m.filteredEKSNodeGroups) == 0 || m.eksNodeGroupIdx < 0 || m.eksNodeGroupIdx >= len(m.filteredEKSNodeGroups) { + return nil + } + nodeGroup := m.filteredEKSNodeGroups[m.eksNodeGroupIdx] + return &nodeGroup +} + +func (m Model) loadEKSClusters() tea.Cmd { + return func() tea.Msg { + cfg := m.cfg + ctx, cancel := context.WithTimeout(context.Background(), eksAPITimeout) + defer cancel() + + repo, err := awsservice.NewAwsRepository(ctx, cfg) + if err != nil { + return errMsg{err} + } + clusters, err := repo.ListEKSClusters(ctx) + if err != nil { + return errMsg{err} + } + return eksClustersLoadedMsg{clusters: clusters} + } +} + +func (m Model) loadEKSNodeGroups() tea.Cmd { + return func() tea.Msg { + cfg := m.cfg + cluster := m.selectedEKSCluster + if cluster == nil { + return errMsg{fmt.Errorf("no EKS cluster selected")} + } + ctx, cancel := context.WithTimeout(context.Background(), eksAPITimeout) + defer cancel() + + repo, err := awsservice.NewAwsRepository(ctx, cfg) + if err != nil { + return errMsg{err} + } + nodeGroups, err := repo.ListEKSNodeGroups(ctx, cluster.Name) + if err != nil { + return errMsg{err} + } + return eksNodeGroupsLoadedMsg{nodeGroups: nodeGroups} + } +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + return value + } + } + return "" +} diff --git a/internal/app/screen_eks_test.go b/internal/app/screen_eks_test.go new file mode 100644 index 0000000..199c262 --- /dev/null +++ b/internal/app/screen_eks_test.go @@ -0,0 +1,101 @@ +package app + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "unic/internal/domain" + awsservice "unic/internal/services/aws" +) + +func TestFeatureListEKSBrowserStartsLoading(t *testing.T) { + m := New(testConfig(), "", "dev") + m.screen = screenFeatureList + m.features = []domain.Feature{{Kind: domain.FeatureEKSBrowser, Description: "Browse EKS clusters"}} + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model := updated.(Model) + if cmd == nil { + t.Fatal("expected load command") + } + if model.screen != screenLoading { + t.Fatalf("expected loading screen, got %v", model.screen) + } +} + +func TestHandleEKSClustersLoadedMsgShowsClusterList(t *testing.T) { + m := New(testConfig(), "", "dev") + clusters := []awsservice.EKSCluster{{Name: "prod-eks", Version: "1.32", Status: "ACTIVE", EndpointPublicAccess: true}} + + updated, _, handled := m.handleEKSMsg(eksClustersLoadedMsg{clusters: clusters}) + if !handled { + t.Fatal("expected message to be handled") + } + model := updated.(Model) + if model.screen != screenEKSClusterList { + t.Fatalf("expected EKS cluster list screen, got %v", model.screen) + } + if len(model.filteredEKSClusters) != 1 || model.filteredEKSClusters[0].Name != "prod-eks" { + t.Fatalf("unexpected clusters: %+v", model.filteredEKSClusters) + } +} + +func TestEKSClusterListEnterLoadsNodeGroups(t *testing.T) { + m := New(testConfig(), "", "dev") + m.screen = screenEKSClusterList + m.eksClusters = []awsservice.EKSCluster{{Name: "prod-eks", ARN: "arn:aws:eks:ap-northeast-2:123456789012:cluster/prod-eks", Version: "1.32", Status: "ACTIVE"}} + m.filteredEKSClusters = m.eksClusters + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model := updated.(Model) + if cmd == nil { + t.Fatal("expected load command") + } + if model.selectedEKSCluster == nil || model.selectedEKSCluster.Name != "prod-eks" { + t.Fatalf("unexpected selected cluster: %+v", model.selectedEKSCluster) + } + if model.screen != screenLoading { + t.Fatalf("expected loading screen, got %v", model.screen) + } +} + +func TestEKSNodeGroupDetailViewShowsScalingAndHealth(t *testing.T) { + m := New(testConfig(), "", "dev") + m.width = 120 + m.height = 32 + m.screen = screenEKSNodeGroupDetail + m.selectedEKSNodeGroup = &awsservice.EKSNodeGroup{ + ClusterName: "prod-eks", + Name: "blue", + ARN: "arn:aws:eks:ap-northeast-2:123456789012:nodegroup/prod-eks/blue/uuid", + Status: "ACTIVE", + Version: "1.32", + ReleaseVersion: "1.32.3-20260401", + AmiType: "AL2_x86_64", + CapacityType: "ON_DEMAND", + InstanceTypes: []string{"m6i.large", "m6a.large"}, + DesiredSize: 3, + MinSize: 2, + MaxSize: 5, + HealthIssues: []awsservice.EKSHealthIssue{{ + Code: "ClusterUnreachable", + Message: "control plane unreachable", + ResourceIDs: []string{"subnet-123"}, + }}, + } + + view := stripANSI(m.viewEKSNodeGroupDetail()) + for _, want := range []string{ + "EKS Node Group Detail — blue", + "desired:3 min:2 max:5", + "m6i.large, m6a.large", + "ClusterUnreachable", + "control plane unreachable", + } { + if !strings.Contains(view, want) { + t.Fatalf("expected view to contain %q, got %q", want, view) + } + } +} diff --git a/internal/domain/catalog.go b/internal/domain/catalog.go index bd609e4..599259a 100644 --- a/internal/domain/catalog.go +++ b/internal/domain/catalog.go @@ -87,6 +87,15 @@ func Catalog() []Service { }, }, }, + { + Name: ServiceEKS, + Features: []Feature{ + { + Kind: FeatureEKSBrowser, + Description: "Browse EKS clusters and managed node groups with scaling and health details", + }, + }, + }, { Name: ServiceS3, Features: []Feature{ diff --git a/internal/domain/catalog_test.go b/internal/domain/catalog_test.go index 5074366..251bc98 100644 --- a/internal/domain/catalog_test.go +++ b/internal/domain/catalog_test.go @@ -197,6 +197,25 @@ func TestCatalogContainsBedrockAPIKeysFeature(t *testing.T) { t.Error("Bedrock service not found in catalog") } +func TestCatalogContainsEKSBrowserFeature(t *testing.T) { + services := Catalog() + + for _, svc := range services { + if svc.Name != ServiceEKS { + continue + } + for _, feat := range svc.Features { + if feat.Kind == FeatureEKSBrowser { + return + } + } + t.Error("EKS service should have EKS Cluster Browser feature") + return + } + + t.Error("EKS service not found in catalog") +} + func TestCatalogDoesNotContainInspectorPseudoService(t *testing.T) { services := Catalog() diff --git a/internal/domain/model.go b/internal/domain/model.go index c82faa5..a931b67 100644 --- a/internal/domain/model.go +++ b/internal/domain/model.go @@ -15,6 +15,7 @@ const ( ServiceCloudWatch AwsService = "CloudWatch" ServiceCloudWatchLogs AwsService = "CloudWatch Logs" ServiceECS AwsService = "ECS" + ServiceEKS AwsService = "EKS" ServiceS3 AwsService = "S3" ServiceLambda AwsService = "Lambda" ServiceBedrock AwsService = "Bedrock" @@ -38,6 +39,7 @@ const ( FeatureCloudWatchMetrics FeatureKind = "CloudWatch Metrics Viewer" FeatureCloudWatchLogsBrowser FeatureKind = "CloudWatch Logs Browser" FeatureECSExec FeatureKind = "ECS Browser & Exec" + FeatureEKSBrowser FeatureKind = "EKS Cluster Browser" FeatureS3Browser FeatureKind = "S3 Browser" FeatureLambdaBrowser FeatureKind = "Lambda Browser" FeatureBedrockAPIKeys FeatureKind = "Bedrock API Keys" diff --git a/internal/services/aws/eks.go b/internal/services/aws/eks.go new file mode 100644 index 0000000..ea884be --- /dev/null +++ b/internal/services/aws/eks.go @@ -0,0 +1,142 @@ +package aws + +import ( + "context" + "fmt" + "sort" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/eks" + ekstypes "github.com/aws/aws-sdk-go-v2/service/eks/types" + + uniclog "unic/internal/log" +) + +// ListEKSClusters returns all EKS clusters in the current account/region. +func (r *AwsRepository) ListEKSClusters(ctx context.Context) ([]EKSCluster, error) { + uniclog.Debug("aws", "ListEKSClusters called") + + var names []string + var nextToken *string + for { + out, err := r.EKSClient.ListClusters(ctx, &eks.ListClustersInput{NextToken: nextToken}) + if err != nil { + return nil, fmt.Errorf("failed to list EKS clusters: %w", err) + } + names = append(names, out.Clusters...) + if out.NextToken == nil { + break + } + nextToken = out.NextToken + } + + if len(names) == 0 { + return nil, nil + } + + clusters := make([]EKSCluster, 0, len(names)) + for _, name := range names { + out, err := r.EKSClient.DescribeCluster(ctx, &eks.DescribeClusterInput{Name: awssdk.String(name)}) + if err != nil { + return nil, fmt.Errorf("failed to describe EKS cluster %s: %w", name, err) + } + if out.Cluster == nil { + continue + } + clusters = append(clusters, mapEKSCluster(out.Cluster)) + } + + sort.SliceStable(clusters, func(i, j int) bool { + return normalizedSortKey(clusters[i].Name, clusters[i].ARN) < normalizedSortKey(clusters[j].Name, clusters[j].ARN) + }) + return clusters, nil +} + +// ListEKSNodeGroups returns all managed node groups for the given cluster. +func (r *AwsRepository) ListEKSNodeGroups(ctx context.Context, clusterName string) ([]EKSNodeGroup, error) { + uniclog.Debug("aws", "ListEKSNodeGroups called", "cluster", clusterName) + + var names []string + var nextToken *string + for { + out, err := r.EKSClient.ListNodegroups(ctx, &eks.ListNodegroupsInput{ + ClusterName: awssdk.String(clusterName), + NextToken: nextToken, + }) + if err != nil { + return nil, fmt.Errorf("failed to list EKS node groups for %s: %w", clusterName, err) + } + names = append(names, out.Nodegroups...) + if out.NextToken == nil { + break + } + nextToken = out.NextToken + } + + if len(names) == 0 { + return nil, nil + } + + nodeGroups := make([]EKSNodeGroup, 0, len(names)) + for _, name := range names { + out, err := r.EKSClient.DescribeNodegroup(ctx, &eks.DescribeNodegroupInput{ + ClusterName: awssdk.String(clusterName), + NodegroupName: awssdk.String(name), + }) + if err != nil { + return nil, fmt.Errorf("failed to describe EKS node group %s/%s: %w", clusterName, name, err) + } + if out.Nodegroup == nil { + continue + } + nodeGroups = append(nodeGroups, mapEKSNodeGroup(out.Nodegroup)) + } + + sort.SliceStable(nodeGroups, func(i, j int) bool { + return normalizedSortKey(nodeGroups[i].Name, nodeGroups[i].ARN) < normalizedSortKey(nodeGroups[j].Name, nodeGroups[j].ARN) + }) + return nodeGroups, nil +} + +func mapEKSCluster(cluster *ekstypes.Cluster) EKSCluster { + item := EKSCluster{ + Name: awssdk.ToString(cluster.Name), + ARN: awssdk.ToString(cluster.Arn), + Version: awssdk.ToString(cluster.Version), + Status: string(cluster.Status), + } + if cluster.ResourcesVpcConfig != nil { + item.EndpointPublicAccess = cluster.ResourcesVpcConfig.EndpointPublicAccess + item.EndpointPrivateAccess = cluster.ResourcesVpcConfig.EndpointPrivateAccess + } + return item +} + +func mapEKSNodeGroup(nodeGroup *ekstypes.Nodegroup) EKSNodeGroup { + item := EKSNodeGroup{ + ClusterName: awssdk.ToString(nodeGroup.ClusterName), + Name: awssdk.ToString(nodeGroup.NodegroupName), + ARN: awssdk.ToString(nodeGroup.NodegroupArn), + Status: string(nodeGroup.Status), + Version: awssdk.ToString(nodeGroup.Version), + ReleaseVersion: awssdk.ToString(nodeGroup.ReleaseVersion), + AmiType: string(nodeGroup.AmiType), + CapacityType: string(nodeGroup.CapacityType), + InstanceTypes: append([]string(nil), nodeGroup.InstanceTypes...), + } + if nodeGroup.ScalingConfig != nil { + item.DesiredSize = awssdk.ToInt32(nodeGroup.ScalingConfig.DesiredSize) + item.MinSize = awssdk.ToInt32(nodeGroup.ScalingConfig.MinSize) + item.MaxSize = awssdk.ToInt32(nodeGroup.ScalingConfig.MaxSize) + } + if nodeGroup.Health != nil { + for _, issue := range nodeGroup.Health.Issues { + item.HealthIssues = append(item.HealthIssues, EKSHealthIssue{ + Code: string(issue.Code), + Message: awssdk.ToString(issue.Message), + ResourceIDs: append([]string(nil), issue.ResourceIds...), + }) + } + } + return item +} diff --git a/internal/services/aws/eks_model.go b/internal/services/aws/eks_model.go new file mode 100644 index 0000000..03eb883 --- /dev/null +++ b/internal/services/aws/eks_model.go @@ -0,0 +1,96 @@ +package aws + +import ( + "fmt" + "strings" +) + +// EKSCluster captures the summary needed for browsing clusters from the TUI. +type EKSCluster struct { + Name string + ARN string + Version string + Status string + EndpointPublicAccess bool + EndpointPrivateAccess bool +} + +func (c EKSCluster) DisplayTitle() string { + return fmt.Sprintf("%-28s v%-8s %-10s %s", c.Name, c.Version, c.Status, c.EndpointVisibility()) +} + +func (c EKSCluster) FilterText() string { + return strings.ToLower(strings.Join([]string{c.Name, c.Version, c.Status, c.ARN, c.EndpointVisibility()}, " ")) +} + +func (c EKSCluster) EndpointVisibility() string { + return fmt.Sprintf("pub:%s priv:%s", enabledMarker(c.EndpointPublicAccess), enabledMarker(c.EndpointPrivateAccess)) +} + +// EKSHealthIssue describes one managed node group health issue. +type EKSHealthIssue struct { + Code string + Message string + ResourceIDs []string +} + +func (i EKSHealthIssue) Summary() string { + parts := []string{i.Code} + if strings.TrimSpace(i.Message) != "" { + parts = append(parts, i.Message) + } + if len(i.ResourceIDs) > 0 { + parts = append(parts, fmt.Sprintf("resources:%s", strings.Join(i.ResourceIDs, ","))) + } + return strings.Join(parts, " • ") +} + +// EKSNodeGroup captures managed node group state for the TUI. +type EKSNodeGroup struct { + ClusterName string + Name string + ARN string + Status string + Version string + ReleaseVersion string + AmiType string + CapacityType string + InstanceTypes []string + DesiredSize int32 + MinSize int32 + MaxSize int32 + HealthIssues []EKSHealthIssue +} + +func (n EKSNodeGroup) DisplayTitle() string { + return fmt.Sprintf("%-26s %-12s desired:%-3d min:%-3d max:%-3d", n.Name, n.Status, n.DesiredSize, n.MinSize, n.MaxSize) +} + +func (n EKSNodeGroup) FilterText() string { + parts := []string{n.ClusterName, n.Name, n.Status, n.Version, n.ReleaseVersion, n.AmiType, n.CapacityType, n.ARN, strings.Join(n.InstanceTypes, " ")} + for _, issue := range n.HealthIssues { + parts = append(parts, issue.Code, issue.Message, strings.Join(issue.ResourceIDs, " ")) + } + return strings.ToLower(strings.Join(parts, " ")) +} + +func (n EKSNodeGroup) HealthSummary() string { + if len(n.HealthIssues) == 0 { + return "healthy" + } + return fmt.Sprintf("%d issue(s)", len(n.HealthIssues)) +} + +func (n EKSNodeGroup) InstanceTypesLabel() string { + if len(n.InstanceTypes) == 0 { + return "-" + } + return strings.Join(n.InstanceTypes, ", ") +} + +func enabledMarker(v bool) string { + if v { + return "yes" + } + return "no" +} diff --git a/internal/services/aws/eks_test.go b/internal/services/aws/eks_test.go new file mode 100644 index 0000000..b4c7483 --- /dev/null +++ b/internal/services/aws/eks_test.go @@ -0,0 +1,139 @@ +package aws + +import ( + "context" + "fmt" + "testing" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/eks" + ekstypes "github.com/aws/aws-sdk-go-v2/service/eks/types" +) + +type mockEKSClient struct { + listClustersFunc func(ctx context.Context, params *eks.ListClustersInput, optFns ...func(*eks.Options)) (*eks.ListClustersOutput, error) + describeClusterFunc func(ctx context.Context, params *eks.DescribeClusterInput, optFns ...func(*eks.Options)) (*eks.DescribeClusterOutput, error) + listNodegroupsFunc func(ctx context.Context, params *eks.ListNodegroupsInput, optFns ...func(*eks.Options)) (*eks.ListNodegroupsOutput, error) + describeNodegroupFunc func(ctx context.Context, params *eks.DescribeNodegroupInput, optFns ...func(*eks.Options)) (*eks.DescribeNodegroupOutput, error) +} + +func (m *mockEKSClient) ListClusters(ctx context.Context, params *eks.ListClustersInput, optFns ...func(*eks.Options)) (*eks.ListClustersOutput, error) { + return m.listClustersFunc(ctx, params, optFns...) +} + +func (m *mockEKSClient) DescribeCluster(ctx context.Context, params *eks.DescribeClusterInput, optFns ...func(*eks.Options)) (*eks.DescribeClusterOutput, error) { + return m.describeClusterFunc(ctx, params, optFns...) +} + +func (m *mockEKSClient) ListNodegroups(ctx context.Context, params *eks.ListNodegroupsInput, optFns ...func(*eks.Options)) (*eks.ListNodegroupsOutput, error) { + return m.listNodegroupsFunc(ctx, params, optFns...) +} + +func (m *mockEKSClient) DescribeNodegroup(ctx context.Context, params *eks.DescribeNodegroupInput, optFns ...func(*eks.Options)) (*eks.DescribeNodegroupOutput, error) { + return m.describeNodegroupFunc(ctx, params, optFns...) +} + +func TestListEKSClusters(t *testing.T) { + mock := &mockEKSClient{ + listClustersFunc: func(_ context.Context, _ *eks.ListClustersInput, _ ...func(*eks.Options)) (*eks.ListClustersOutput, error) { + return &eks.ListClustersOutput{Clusters: []string{"prod-eks", "dev-eks"}}, nil + }, + describeClusterFunc: func(_ context.Context, params *eks.DescribeClusterInput, _ ...func(*eks.Options)) (*eks.DescribeClusterOutput, error) { + name := awssdk.ToString(params.Name) + return &eks.DescribeClusterOutput{Cluster: &ekstypes.Cluster{ + Name: awssdk.String(name), + Arn: awssdk.String("arn:aws:eks:ap-northeast-2:123456789012:cluster/" + name), + Version: awssdk.String("1.32"), + Status: ekstypes.ClusterStatusActive, + ResourcesVpcConfig: &ekstypes.VpcConfigResponse{ + EndpointPublicAccess: true, + EndpointPrivateAccess: false, + }, + }}, nil + }, + } + + repo := &AwsRepository{EKSClient: mock} + clusters, err := repo.ListEKSClusters(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(clusters) != 2 { + t.Fatalf("expected 2 clusters, got %d", len(clusters)) + } + if clusters[0].Name != "dev-eks" { + t.Fatalf("expected sorted cluster dev-eks first, got %s", clusters[0].Name) + } + if got := clusters[0].EndpointVisibility(); got != "pub:yes priv:no" { + t.Fatalf("unexpected endpoint visibility: %s", got) + } +} + +func TestListEKSNodeGroups(t *testing.T) { + mock := &mockEKSClient{ + listNodegroupsFunc: func(_ context.Context, params *eks.ListNodegroupsInput, _ ...func(*eks.Options)) (*eks.ListNodegroupsOutput, error) { + if got := awssdk.ToString(params.ClusterName); got != "prod-eks" { + t.Fatalf("expected cluster name prod-eks, got %s", got) + } + return &eks.ListNodegroupsOutput{Nodegroups: []string{"blue", "green"}}, nil + }, + describeNodegroupFunc: func(_ context.Context, params *eks.DescribeNodegroupInput, _ ...func(*eks.Options)) (*eks.DescribeNodegroupOutput, error) { + name := awssdk.ToString(params.NodegroupName) + return &eks.DescribeNodegroupOutput{Nodegroup: &ekstypes.Nodegroup{ + ClusterName: awssdk.String("prod-eks"), + NodegroupName: awssdk.String(name), + NodegroupArn: awssdk.String("arn:aws:eks:ap-northeast-2:123456789012:nodegroup/prod-eks/" + name + "/uuid"), + Status: ekstypes.NodegroupStatusActive, + Version: awssdk.String("1.32"), + ReleaseVersion: awssdk.String("1.32.3-20260401"), + AmiType: ekstypes.AMITypesAl2X8664, + CapacityType: ekstypes.CapacityTypesOnDemand, + InstanceTypes: []string{"m6i.large"}, + ScalingConfig: &ekstypes.NodegroupScalingConfig{ + DesiredSize: awssdk.Int32(3), + MinSize: awssdk.Int32(2), + MaxSize: awssdk.Int32(5), + }, + Health: &ekstypes.NodegroupHealth{Issues: []ekstypes.Issue{{ + Code: ekstypes.NodegroupIssueCodeClusterUnreachable, + Message: awssdk.String("control plane unreachable"), + ResourceIds: []string{"subnet-123"}, + }}}, + }}, nil + }, + } + + repo := &AwsRepository{EKSClient: mock} + nodeGroups, err := repo.ListEKSNodeGroups(context.Background(), "prod-eks") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(nodeGroups) != 2 { + t.Fatalf("expected 2 node groups, got %d", len(nodeGroups)) + } + if nodeGroups[0].Name != "blue" { + t.Fatalf("expected sorted node group blue first, got %s", nodeGroups[0].Name) + } + if nodeGroups[0].HealthSummary() != "1 issue(s)" { + t.Fatalf("unexpected health summary: %s", nodeGroups[0].HealthSummary()) + } + if nodeGroups[0].HealthIssues[0].Summary() == "" { + t.Fatal("expected health issue summary") + } +} + +func TestListEKSClustersReturnsError(t *testing.T) { + mock := &mockEKSClient{ + listClustersFunc: func(_ context.Context, _ *eks.ListClustersInput, _ ...func(*eks.Options)) (*eks.ListClustersOutput, error) { + return nil, fmt.Errorf("access denied") + }, + describeClusterFunc: func(_ context.Context, _ *eks.DescribeClusterInput, _ ...func(*eks.Options)) (*eks.DescribeClusterOutput, error) { + return nil, nil + }, + } + + repo := &AwsRepository{EKSClient: mock} + if _, err := repo.ListEKSClusters(context.Background()); err == nil { + t.Fatal("expected error") + } +} diff --git a/internal/services/aws/repository.go b/internal/services/aws/repository.go index a8454e8..44d3a43 100644 --- a/internal/services/aws/repository.go +++ b/internal/services/aws/repository.go @@ -13,6 +13,7 @@ import ( "github.com/aws/aws-sdk-go-v2/service/configservice" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ecs" + "github.com/aws/aws-sdk-go-v2/service/eks" "github.com/aws/aws-sdk-go-v2/service/elasticache" "github.com/aws/aws-sdk-go-v2/service/guardduty" "github.com/aws/aws-sdk-go-v2/service/iam" @@ -67,6 +68,9 @@ var _ ConfigServiceClientAPI = (*configservice.Client)(nil) // Verify *ecs.Client satisfies ECSClientAPI at compile time. var _ ECSClientAPI = (*ecs.Client)(nil) +// Verify *eks.Client satisfies EKSClientAPI at compile time. +var _ EKSClientAPI = (*eks.Client)(nil) + // Verify *elasticache.Client satisfies ElastiCacheClientAPI at compile time. var _ ElastiCacheClientAPI = (*elasticache.Client)(nil) @@ -178,6 +182,14 @@ type ECSClientAPI interface { DescribeTasks(ctx context.Context, params *ecs.DescribeTasksInput, optFns ...func(*ecs.Options)) (*ecs.DescribeTasksOutput, error) } +// EKSClientAPI is the interface for EKS operations used by AwsRepository. +type EKSClientAPI interface { + ListClusters(ctx context.Context, params *eks.ListClustersInput, optFns ...func(*eks.Options)) (*eks.ListClustersOutput, error) + DescribeCluster(ctx context.Context, params *eks.DescribeClusterInput, optFns ...func(*eks.Options)) (*eks.DescribeClusterOutput, error) + ListNodegroups(ctx context.Context, params *eks.ListNodegroupsInput, optFns ...func(*eks.Options)) (*eks.ListNodegroupsOutput, error) + DescribeNodegroup(ctx context.Context, params *eks.DescribeNodegroupInput, optFns ...func(*eks.Options)) (*eks.DescribeNodegroupOutput, error) +} + // S3ClientAPI is the interface for S3 operations used by AwsRepository. type S3ClientAPI interface { ListBuckets(ctx context.Context, params *s3.ListBucketsInput, optFns ...func(*s3.Options)) (*s3.ListBucketsOutput, error) @@ -251,6 +263,7 @@ type AwsRepository struct { GuardDutyClient GuardDutyClientAPI ConfigServiceClient ConfigServiceClientAPI ECSClient ECSClientAPI + EKSClient EKSClientAPI ElastiCacheClient ElastiCacheClientAPI S3Client S3ClientAPI LambdaClient LambdaClientAPI @@ -322,6 +335,7 @@ func NewAwsRepository(ctx context.Context, cfg *config.Config) (*AwsRepository, GuardDutyClient: guardduty.NewFromConfig(awsCfg), ConfigServiceClient: configservice.NewFromConfig(awsCfg), ECSClient: ecs.NewFromConfig(awsCfg), + EKSClient: eks.NewFromConfig(awsCfg), ElastiCacheClient: elasticache.NewFromConfig(awsCfg), S3Client: s3.NewFromConfig(awsCfg), LambdaClient: lambda.NewFromConfig(awsCfg),