From c4877d611e97d38ad461285cae9407292ce3b974 Mon Sep 17 00:00:00 2001 From: jedon da hyeon Date: Tue, 7 Apr 2026 16:46:26 +0900 Subject: [PATCH 1/3] feat: add ECS Exec debug sessions browser (M3.11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ECS service layer: ListClusters, ListServices, ListTasks, DescribeTaskContainers - Add ECSClientAPI interface and ECSClient field to AwsRepository - Add BuildECSExecCommand wrapping `aws ecs execute-command --interactive` - Add TUI drill-down: Cluster → Service → Task → Container - Show exec readiness (exec:✓/✗) per container; surface actionable error if disabled - Launch exec session via tea.ExecProcess, return cleanly to TUI on exit - Add unit tests with mockECSClient (5 test cases) - Update README features table and key bindings --- README.md | 11 + go.mod | 1 + go.sum | 2 + internal/app/app.go | 47 +++ internal/app/messages.go | 20 ++ internal/app/screen_ecs.go | 487 ++++++++++++++++++++++++++++ internal/domain/catalog.go | 9 + internal/domain/model.go | 2 + internal/services/aws/ecs.go | 208 ++++++++++++ internal/services/aws/ecs_exec.go | 31 ++ internal/services/aws/ecs_model.go | 81 +++++ internal/services/aws/ecs_test.go | 215 ++++++++++++ internal/services/aws/repository.go | 18 +- 13 files changed, 1131 insertions(+), 1 deletion(-) create mode 100644 internal/app/screen_ecs.go create mode 100644 internal/services/aws/ecs.go create mode 100644 internal/services/aws/ecs_exec.go create mode 100644 internal/services/aws/ecs_model.go create mode 100644 internal/services/aws/ecs_test.go diff --git a/README.md b/README.md index e4956b4..03a1a9b 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ contexts: | IAM | IAM User Browser (lightweight username pages, background filter expansion, detail drill-down) | ✅ Implemented | | IAM | Access Key Browser (list keys with status, age, last used) | ✅ Implemented | | IAM | Access Key Rotation (create → verify/apply → deactivate → delete) | ✅ Implemented | +| ECS | ECS Exec Sessions (Clusters → Services → Tasks → Containers, exec session via `aws ecs execute-command`) | ✅ Implemented | ## TUI Key Bindings @@ -202,6 +203,16 @@ contexts: | `n` | Load more (older events) | Viewer | | `PgUp`/`PgDn` | Page scroll | Viewer | +### ECS Exec Sessions + +| Key | Action | Screen | +|-----|--------|--------| +| `/` | Filter clusters/services | Cluster / Service list | +| `r` | Refresh list | Cluster / Service / Task list | +| `Enter` | Drill down (Cluster → Service → Task → Container) | Any list | +| `Enter` | Start exec session (`/bin/sh`) | Container list | +| `Esc` | Go back one level | Any screen | + ### Context Switcher | Key | Action | diff --git a/go.mod b/go.mod index b541260..564ddf2 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.68.0 // indirect + github.com/aws/aws-sdk-go-v2/service/ecs v1.76.0 // indirect github.com/aws/aws-sdk-go-v2/service/iam v1.53.7 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect diff --git a/go.sum b/go.sum index 4e192b1..0d1354d 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,8 @@ github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.68.0 h1:+/lmB/+i2oqkzbmlQ github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.68.0/go.mod h1:PobeppEnIjw4pcgjFryNDZCTH7AiqZw0yb5r98Gvf9c= github.com/aws/aws-sdk-go-v2/service/ec2 v1.296.0 h1:98Miqj16un1WLNyM1RjVDhXYumhqZrQfAeG8i4jPG6o= 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/iam v1.53.7 h1:n9YLiWtX3+6pTLZWvRJmtq5JIB9NA/KFelyCg5fOlTU= github.com/aws/aws-sdk-go-v2/service/iam v1.53.7/go.mod h1:sP46Vo6MeJcM4s0ZXcG2PFmfiSyixhIuC/74W52yKuk= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= diff --git a/internal/app/app.go b/internal/app/app.go index 4379f96..9789552 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -48,6 +48,10 @@ const ( screenCWLogGroupList screenCWLogStreamList screenCWLogViewer + screenECSClusterList + screenECSServiceList + screenECSTaskList + screenECSContainerList screenContextPicker screenContextAdd screenLoading @@ -176,6 +180,29 @@ type Model struct { sgAddInput string // current field text input sgAddSelectIdx int // index for select-type fields (direction, protocol) + // ECS browser state + ecsClusters []awsservice.ECSCluster + filteredECSClusters []awsservice.ECSCluster + ecsClusterIdx int + ecsClusterFilter string + ecsClusterFilterActive bool + selectedECSCluster *awsservice.ECSCluster + + ecsServices []awsservice.ECSService + filteredECSServices []awsservice.ECSService + ecsServiceIdx int + ecsServiceFilter string + ecsServiceFilterActive bool + selectedECSService *awsservice.ECSService + + ecsTasks []awsservice.ECSTask + filteredECSTasks []awsservice.ECSTask + ecsTaskIdx int + selectedECSTask *awsservice.ECSTask + + ecsContainers []awsservice.ECSContainer + ecsContainerIdx int + // CloudWatch Logs browser state cwLogGroups []awsservice.LogGroup filteredCWLogGroups []awsservice.LogGroup @@ -310,6 +337,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.handleIAMMsg, m.handleSecretMsg, m.handleCloudWatchLogsMsg, + m.handleECSMsg, m.handleContextMsg, } { if newM, cmd, handled := h(msg); handled { @@ -398,6 +426,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateIAMKeyRotateConfirm(msg) case screenIAMKeyRotateResult: return m.updateIAMKeyRotateResult(msg) + case screenECSClusterList: + return m.updateECSClusterList(msg) + case screenECSServiceList: + return m.updateECSServiceList(msg) + case screenECSTaskList: + return m.updateECSTaskList(msg) + case screenECSContainerList: + return m.updateECSContainerList(msg) case screenContextPicker: return m.updateContextPicker(msg) case screenContextAdd: @@ -501,6 +537,9 @@ func (m Model) updateFeatureList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.iamRotationEnabled = true m.screen = screenLoading return m, m.loadIAMKeys() + case domain.FeatureECSExec: + m.screen = screenLoading + return m, m.loadECSClusters() } } } @@ -586,6 +625,14 @@ func (m Model) View() string { v = m.viewIAMKeyRotateConfirm() case screenIAMKeyRotateResult: v = m.viewIAMKeyRotateResult() + case screenECSClusterList: + v = m.viewECSClusterList() + case screenECSServiceList: + v = m.viewECSServiceList() + case screenECSTaskList: + v = m.viewECSTaskList() + case screenECSContainerList: + v = m.viewECSContainerList() case screenContextPicker: v = m.viewContextPicker() case screenContextAdd: diff --git a/internal/app/messages.go b/internal/app/messages.go index 83016a5..d5e6262 100644 --- a/internal/app/messages.go +++ b/internal/app/messages.go @@ -152,3 +152,23 @@ type iamKeyDeletedMsg struct { keyID string err error } + +type ecsClustersLoadedMsg struct { + clusters []awsservice.ECSCluster +} + +type ecsServicesLoadedMsg struct { + services []awsservice.ECSService +} + +type ecsTasksLoadedMsg struct { + tasks []awsservice.ECSTask +} + +type ecsContainersLoadedMsg struct { + containers []awsservice.ECSContainer +} + +type ecsExecDoneMsg struct { + err error +} diff --git a/internal/app/screen_ecs.go b/internal/app/screen_ecs.go new file mode 100644 index 0000000..07aff41 --- /dev/null +++ b/internal/app/screen_ecs.go @@ -0,0 +1,487 @@ +package app + +import ( + "context" + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + awsservice "unic/internal/services/aws" +) + +// handleECSMsg routes ECS messages to the correct screen. +func (m Model) handleECSMsg(msg tea.Msg) (tea.Model, tea.Cmd, bool) { + switch msg := msg.(type) { + case ecsClustersLoadedMsg: + m.ecsClusters = msg.clusters + m.filteredECSClusters = msg.clusters + m.ecsClusterIdx = 0 + m.ecsClusterFilter = "" + m.ecsClusterFilterActive = false + m.screen = screenECSClusterList + return m, nil, true + + case ecsServicesLoadedMsg: + m.ecsServices = msg.services + m.filteredECSServices = msg.services + m.ecsServiceIdx = 0 + m.ecsServiceFilter = "" + m.ecsServiceFilterActive = false + m.screen = screenECSServiceList + return m, nil, true + + case ecsTasksLoadedMsg: + m.ecsTasks = msg.tasks + m.filteredECSTasks = msg.tasks + m.ecsTaskIdx = 0 + m.screen = screenECSTaskList + return m, nil, true + + case ecsContainersLoadedMsg: + m.ecsContainers = msg.containers + m.ecsContainerIdx = 0 + m.screen = screenECSContainerList + return m, nil, true + + case ecsExecDoneMsg: + if msg.err != nil { + m.errMsg = msg.err.Error() + m.screen = screenError + return m, nil, true + } + m.screen = screenECSContainerList + return m, nil, true + } + return m, nil, false +} + +// --- Cluster List --- + +func (m Model) updateECSClusterList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + + if m.ecsClusterFilterActive { + newFilter, deactivate, changed := handleFilterKey(key, m.ecsClusterFilter) + m.ecsClusterFilter = newFilter + if deactivate { + m.ecsClusterFilterActive = false + } + if changed { + m.filteredECSClusters = applyFilter(m.ecsClusters, m.ecsClusterFilter) + m.ecsClusterIdx = 0 + } + return m, nil + } + + switch key { + case "q", "esc": + m.screen = screenFeatureList + case "up", "k": + if m.ecsClusterIdx > 0 { + m.ecsClusterIdx-- + } + case "down", "j": + if m.ecsClusterIdx < len(m.filteredECSClusters)-1 { + m.ecsClusterIdx++ + } + case "/": + m.ecsClusterFilterActive = true + case "r": + m.screen = screenLoading + return m, m.loadECSClusters() + case "enter": + if len(m.filteredECSClusters) > 0 && m.ecsClusterIdx < len(m.filteredECSClusters) { + cluster := m.filteredECSClusters[m.ecsClusterIdx] + m.selectedECSCluster = &cluster + m.screen = screenLoading + return m, m.loadECSServices() + } + } + return m, nil +} + +func (m Model) viewECSClusterList() string { + var b strings.Builder + b.WriteString(m.renderStatusBar()) + b.WriteString(titleStyle.Render("ECS Clusters")) + b.WriteString("\n") + + if m.ecsClusterFilterActive { + b.WriteString(filterStyle.Render(fmt.Sprintf("Filter: %s▏", m.ecsClusterFilter))) + } else if m.ecsClusterFilter != "" { + b.WriteString(dimStyle.Render(fmt.Sprintf("Filter: %s", m.ecsClusterFilter))) + } + b.WriteString("\n\n") + + if len(m.filteredECSClusters) == 0 { + b.WriteString(dimStyle.Render(" No clusters found")) + b.WriteString("\n") + } else { + visibleLines := max(m.height-8, 5) + start := 0 + if m.ecsClusterIdx >= visibleLines { + start = m.ecsClusterIdx - visibleLines + 1 + } + end := min(start+visibleLines, len(m.filteredECSClusters)) + + for i := start; i < end; i++ { + c := m.filteredECSClusters[i] + cursor := " " + style := normalStyle + if i == m.ecsClusterIdx { + cursor = "> " + style = selectedStyle + } + b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, c.DisplayTitle()))) + b.WriteString("\n") + } + b.WriteString("\n") + b.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d clusters", len(m.filteredECSClusters), len(m.ecsClusters)))) + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render("↑/↓: navigate • /: filter • r: refresh • enter: select • esc: back • H: home")) + return b.String() +} + +// --- Service List --- + +func (m Model) updateECSServiceList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + + if m.ecsServiceFilterActive { + newFilter, deactivate, changed := handleFilterKey(key, m.ecsServiceFilter) + m.ecsServiceFilter = newFilter + if deactivate { + m.ecsServiceFilterActive = false + } + if changed { + m.filteredECSServices = applyFilter(m.ecsServices, m.ecsServiceFilter) + m.ecsServiceIdx = 0 + } + return m, nil + } + + switch key { + case "q", "esc": + m.screen = screenECSClusterList + case "up", "k": + if m.ecsServiceIdx > 0 { + m.ecsServiceIdx-- + } + case "down", "j": + if m.ecsServiceIdx < len(m.filteredECSServices)-1 { + m.ecsServiceIdx++ + } + case "/": + m.ecsServiceFilterActive = true + case "r": + m.screen = screenLoading + return m, m.loadECSServices() + case "enter": + if len(m.filteredECSServices) > 0 && m.ecsServiceIdx < len(m.filteredECSServices) { + svc := m.filteredECSServices[m.ecsServiceIdx] + m.selectedECSService = &svc + m.screen = screenLoading + return m, m.loadECSTasks() + } + } + return m, nil +} + +func (m Model) viewECSServiceList() string { + var b strings.Builder + b.WriteString(m.renderStatusBar()) + clusterName := "" + if m.selectedECSCluster != nil { + clusterName = m.selectedECSCluster.Name + } + b.WriteString(titleStyle.Render(fmt.Sprintf("ECS Services — %s", clusterName))) + b.WriteString("\n") + + if m.ecsServiceFilterActive { + b.WriteString(filterStyle.Render(fmt.Sprintf("Filter: %s▏", m.ecsServiceFilter))) + } else if m.ecsServiceFilter != "" { + b.WriteString(dimStyle.Render(fmt.Sprintf("Filter: %s", m.ecsServiceFilter))) + } + b.WriteString("\n\n") + + if len(m.filteredECSServices) == 0 { + b.WriteString(dimStyle.Render(" No services found")) + b.WriteString("\n") + } else { + visibleLines := max(m.height-8, 5) + start := 0 + if m.ecsServiceIdx >= visibleLines { + start = m.ecsServiceIdx - visibleLines + 1 + } + end := min(start+visibleLines, len(m.filteredECSServices)) + + for i := start; i < end; i++ { + s := m.filteredECSServices[i] + cursor := " " + style := normalStyle + if i == m.ecsServiceIdx { + cursor = "> " + style = selectedStyle + } + b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, s.DisplayTitle()))) + b.WriteString("\n") + } + b.WriteString("\n") + b.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d services", len(m.filteredECSServices), len(m.ecsServices)))) + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render("↑/↓: navigate • /: filter • r: refresh • enter: select • esc: back • H: home")) + return b.String() +} + +// --- Task List --- + +func (m Model) updateECSTaskList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "esc": + m.screen = screenECSServiceList + case "up", "k": + if m.ecsTaskIdx > 0 { + m.ecsTaskIdx-- + } + case "down", "j": + if m.ecsTaskIdx < len(m.filteredECSTasks)-1 { + m.ecsTaskIdx++ + } + case "r": + m.screen = screenLoading + return m, m.loadECSTasks() + case "enter": + if len(m.filteredECSTasks) > 0 && m.ecsTaskIdx < len(m.filteredECSTasks) { + task := m.filteredECSTasks[m.ecsTaskIdx] + m.selectedECSTask = &task + m.screen = screenLoading + return m, m.loadECSContainers() + } + } + return m, nil +} + +func (m Model) viewECSTaskList() string { + var b strings.Builder + b.WriteString(m.renderStatusBar()) + svcName := "" + if m.selectedECSService != nil { + svcName = m.selectedECSService.Name + } + b.WriteString(titleStyle.Render(fmt.Sprintf("ECS Tasks — %s", svcName))) + b.WriteString("\n\n") + + if len(m.filteredECSTasks) == 0 { + b.WriteString(dimStyle.Render(" No running tasks found")) + b.WriteString("\n") + } else { + visibleLines := max(m.height-8, 5) + start := 0 + if m.ecsTaskIdx >= visibleLines { + start = m.ecsTaskIdx - visibleLines + 1 + } + end := min(start+visibleLines, len(m.filteredECSTasks)) + + for i := start; i < end; i++ { + t := m.filteredECSTasks[i] + cursor := " " + style := normalStyle + if i == m.ecsTaskIdx { + cursor = "> " + style = selectedStyle + } + b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, t.DisplayTitle()))) + b.WriteString("\n") + } + b.WriteString("\n") + b.WriteString(dimStyle.Render(fmt.Sprintf(" %d tasks", len(m.filteredECSTasks)))) + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render("↑/↓: navigate • r: refresh • enter: select • esc: back • H: home")) + return b.String() +} + +// --- Container List --- + +func (m Model) updateECSContainerList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "esc": + m.screen = screenECSTaskList + case "up", "k": + if m.ecsContainerIdx > 0 { + m.ecsContainerIdx-- + } + case "down", "j": + if m.ecsContainerIdx < len(m.ecsContainers)-1 { + m.ecsContainerIdx++ + } + case "enter": + if len(m.ecsContainers) > 0 && m.ecsContainerIdx < len(m.ecsContainers) { + container := m.ecsContainers[m.ecsContainerIdx] + if !container.ExecEnabled { + m.errMsg = fmt.Sprintf( + "ECS Exec is not enabled for container %q.\n\nTo enable it, update the task definition with enableExecuteCommand=true\nand ensure the task IAM role has ssmmessages permissions.", + container.Name, + ) + m.screen = screenError + return m, nil + } + return m, m.startECSExec(container) + } + } + return m, nil +} + +func (m Model) viewECSContainerList() string { + var b strings.Builder + b.WriteString(m.renderStatusBar()) + taskID := "" + if m.selectedECSTask != nil { + taskID = m.selectedECSTask.TaskID + } + b.WriteString(titleStyle.Render(fmt.Sprintf("ECS Containers — %s", taskID))) + b.WriteString("\n\n") + + if len(m.ecsContainers) == 0 { + b.WriteString(dimStyle.Render(" No containers found")) + b.WriteString("\n") + } else { + visibleLines := max(m.height-8, 5) + start := 0 + if m.ecsContainerIdx >= visibleLines { + start = m.ecsContainerIdx - visibleLines + 1 + } + end := min(start+visibleLines, len(m.ecsContainers)) + + for i := start; i < end; i++ { + c := m.ecsContainers[i] + cursor := " " + style := normalStyle + if i == m.ecsContainerIdx { + cursor = "> " + style = selectedStyle + } + b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, c.DisplayTitle()))) + b.WriteString("\n") + } + b.WriteString("\n") + b.WriteString(dimStyle.Render(fmt.Sprintf(" %d containers", len(m.ecsContainers)))) + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render("↑/↓: navigate • enter: exec session • esc: back • H: home")) + return b.String() +} + +// --- Load Commands --- + +func (m Model) loadECSClusters() tea.Cmd { + return func() tea.Msg { + if err := awsservice.CheckAWSCLIInstalled(); err != nil { + return errMsg{err: err} + } + ctx := context.Background() + repo, err := awsservice.NewAwsRepository(ctx, m.cfg) + if err != nil { + return errMsg{err: err} + } + clusters, err := repo.ListClusters(ctx) + if err != nil { + return errMsg{err: err} + } + if len(clusters) == 0 { + return errMsg{err: fmt.Errorf("no ECS clusters found in region %s", m.cfg.Region)} + } + return ecsClustersLoadedMsg{clusters: clusters} + } +} + +func (m Model) loadECSServices() tea.Cmd { + return func() tea.Msg { + if m.selectedECSCluster == nil { + return errMsg{err: fmt.Errorf("no cluster selected")} + } + ctx := context.Background() + repo, err := awsservice.NewAwsRepository(ctx, m.cfg) + if err != nil { + return errMsg{err: err} + } + services, err := repo.ListServices(ctx, m.selectedECSCluster.ARN) + if err != nil { + return errMsg{err: err} + } + if len(services) == 0 { + return errMsg{err: fmt.Errorf("no services found in cluster %s", m.selectedECSCluster.Name)} + } + return ecsServicesLoadedMsg{services: services} + } +} + +func (m Model) loadECSTasks() tea.Cmd { + return func() tea.Msg { + if m.selectedECSCluster == nil || m.selectedECSService == nil { + return errMsg{err: fmt.Errorf("no cluster or service selected")} + } + ctx := context.Background() + repo, err := awsservice.NewAwsRepository(ctx, m.cfg) + if err != nil { + return errMsg{err: err} + } + tasks, err := repo.ListTasks(ctx, m.selectedECSCluster.ARN, m.selectedECSService.ARN) + if err != nil { + return errMsg{err: err} + } + if len(tasks) == 0 { + return errMsg{err: fmt.Errorf("no running tasks found for service %s", m.selectedECSService.Name)} + } + return ecsTasksLoadedMsg{tasks: tasks} + } +} + +func (m Model) loadECSContainers() tea.Cmd { + return func() tea.Msg { + if m.selectedECSCluster == nil || m.selectedECSTask == nil { + return errMsg{err: fmt.Errorf("no cluster or task selected")} + } + ctx := context.Background() + repo, err := awsservice.NewAwsRepository(ctx, m.cfg) + if err != nil { + return errMsg{err: err} + } + containers, err := repo.DescribeTaskContainers(ctx, m.selectedECSCluster.ARN, m.selectedECSTask.TaskARN) + if err != nil { + return errMsg{err: err} + } + if len(containers) == 0 { + return errMsg{err: fmt.Errorf("no containers found for task %s", m.selectedECSTask.TaskID)} + } + return ecsContainersLoadedMsg{containers: containers} + } +} + +// startECSExec launches an ECS exec session for the given container. +func (m Model) startECSExec(container awsservice.ECSContainer) tea.Cmd { + return func() tea.Msg { + if m.selectedECSCluster == nil || m.selectedECSTask == nil { + return errMsg{err: fmt.Errorf("no cluster or task selected")} + } + + cmd := awsservice.BuildECSExecCommand( + m.selectedECSCluster.ARN, + m.selectedECSTask.TaskARN, + container.Name, + m.cfg.Region, + ) + + execCmd := tea.ExecProcess(cmd, func(err error) tea.Msg { + return ecsExecDoneMsg{err: err} + }) + return execCmd() + } +} diff --git a/internal/domain/catalog.go b/internal/domain/catalog.go index 93cd7f6..cec1aee 100644 --- a/internal/domain/catalog.go +++ b/internal/domain/catalog.go @@ -61,6 +61,15 @@ func Catalog() []Service { }, }, }, + { + Name: ServiceECS, + Features: []Feature{ + { + Kind: FeatureECSExec, + Description: "Browse ECS clusters, services, tasks, and launch exec sessions", + }, + }, + }, { Name: ServiceIAM, Features: []Feature{ diff --git a/internal/domain/model.go b/internal/domain/model.go index fdf0636..9ab9474 100644 --- a/internal/domain/model.go +++ b/internal/domain/model.go @@ -11,6 +11,7 @@ const ( ServiceSecretsManager AwsService = "Secrets Manager" ServiceIAM AwsService = "IAM" ServiceCloudWatchLogs AwsService = "CloudWatch Logs" + ServiceECS AwsService = "ECS" ) // FeatureKind represents a specific feature within a service. @@ -27,6 +28,7 @@ const ( FeatureListAccessKeys FeatureKind = "ListAccessKeys" FeatureRotateAccessKey FeatureKind = "RotateAccessKey" FeatureCloudWatchLogsBrowser FeatureKind = "CloudWatch Logs Browser" + FeatureECSExec FeatureKind = "ECS Exec Sessions" ) // Feature describes a selectable feature under an AWS service. diff --git a/internal/services/aws/ecs.go b/internal/services/aws/ecs.go new file mode 100644 index 0000000..d8a8b1e --- /dev/null +++ b/internal/services/aws/ecs.go @@ -0,0 +1,208 @@ +package aws + +import ( + "context" + "fmt" + "strings" + "time" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ecs" + + uniclog "unic/internal/log" +) + +// ListClusters returns all ECS clusters in the current account/region. +func (r *AwsRepository) ListClusters(ctx context.Context) ([]ECSCluster, error) { + uniclog.Debug("aws", "ListClusters called") + + var arns []string + var nextToken *string + for { + out, err := r.ECSClient.ListClusters(ctx, &ecs.ListClustersInput{NextToken: nextToken}) + if err != nil { + return nil, fmt.Errorf("failed to list ECS clusters: %w", err) + } + arns = append(arns, out.ClusterArns...) + if out.NextToken == nil { + break + } + nextToken = out.NextToken + } + + if len(arns) == 0 { + return nil, nil + } + + out, err := r.ECSClient.DescribeClusters(ctx, &ecs.DescribeClustersInput{ + Clusters: arns, + }) + if err != nil { + return nil, fmt.Errorf("failed to describe ECS clusters: %w", err) + } + + clusters := make([]ECSCluster, 0, len(out.Clusters)) + for _, c := range out.Clusters { + clusters = append(clusters, ECSCluster{ + Name: awssdk.ToString(c.ClusterName), + ARN: awssdk.ToString(c.ClusterArn), + Status: awssdk.ToString(c.Status), + ActiveServices: c.ActiveServicesCount, + RunningTasks: c.RunningTasksCount, + }) + } + return clusters, nil +} + +// ListServices returns all ECS services in the given cluster. +func (r *AwsRepository) ListServices(ctx context.Context, clusterARN string) ([]ECSService, error) { + uniclog.Debug("aws", "ListServices called", "cluster", clusterARN) + + var arns []string + var nextToken *string + for { + out, err := r.ECSClient.ListServices(ctx, &ecs.ListServicesInput{ + Cluster: awssdk.String(clusterARN), + NextToken: nextToken, + }) + if err != nil { + return nil, fmt.Errorf("failed to list ECS services: %w", err) + } + arns = append(arns, out.ServiceArns...) + if out.NextToken == nil { + break + } + nextToken = out.NextToken + } + + if len(arns) == 0 { + return nil, nil + } + + // DescribeServices accepts max 10 at a time + var services []ECSService + for i := 0; i < len(arns); i += 10 { + end := i + 10 + if end > len(arns) { + end = len(arns) + } + out, err := r.ECSClient.DescribeServices(ctx, &ecs.DescribeServicesInput{ + Cluster: awssdk.String(clusterARN), + Services: arns[i:end], + }) + if err != nil { + return nil, fmt.Errorf("failed to describe ECS services: %w", err) + } + for _, s := range out.Services { + svc := ECSService{ + Name: awssdk.ToString(s.ServiceName), + ARN: awssdk.ToString(s.ServiceArn), + Status: awssdk.ToString(s.Status), + RunningCount: s.RunningCount, + DesiredCount: s.DesiredCount, + LaunchType: string(s.LaunchType), + } + services = append(services, svc) + } + } + return services, nil +} + +// ListTasks returns running tasks in the given cluster and service. +func (r *AwsRepository) ListTasks(ctx context.Context, clusterARN, serviceARN string) ([]ECSTask, error) { + uniclog.Debug("aws", "ListTasks called", "cluster", clusterARN, "service", serviceARN) + + var taskARNs []string + var nextToken *string + for { + input := &ecs.ListTasksInput{ + Cluster: awssdk.String(clusterARN), + NextToken: nextToken, + } + if serviceARN != "" { + // Extract service name from ARN for the ServiceName filter + parts := strings.Split(serviceARN, "/") + input.ServiceName = awssdk.String(parts[len(parts)-1]) + } + out, err := r.ECSClient.ListTasks(ctx, input) + if err != nil { + return nil, fmt.Errorf("failed to list ECS tasks: %w", err) + } + taskARNs = append(taskARNs, out.TaskArns...) + if out.NextToken == nil { + break + } + nextToken = out.NextToken + } + + if len(taskARNs) == 0 { + return nil, nil + } + + return r.describeTasksFromARNs(ctx, clusterARN, taskARNs) +} + +// DescribeTaskContainers returns the containers for a specific task. +func (r *AwsRepository) DescribeTaskContainers(ctx context.Context, clusterARN, taskARN string) ([]ECSContainer, error) { + uniclog.Debug("aws", "DescribeTaskContainers called", "task", taskARN) + + out, err := r.ECSClient.DescribeTasks(ctx, &ecs.DescribeTasksInput{ + Cluster: awssdk.String(clusterARN), + Tasks: []string{taskARN}, + }) + if err != nil { + return nil, fmt.Errorf("failed to describe ECS task: %w", err) + } + + if len(out.Tasks) == 0 { + return nil, fmt.Errorf("task not found: %s", taskARN) + } + + task := out.Tasks[0] + containers := make([]ECSContainer, 0, len(task.Containers)) + for _, c := range task.Containers { + containers = append(containers, ECSContainer{ + Name: awssdk.ToString(c.Name), + RuntimeID: awssdk.ToString(c.RuntimeId), + ExecEnabled: task.EnableExecuteCommand, + }) + } + return containers, nil +} + +// describeTasksFromARNs fetches task details for a slice of ARNs (max 100 per call). +func (r *AwsRepository) describeTasksFromARNs(ctx context.Context, clusterARN string, arns []string) ([]ECSTask, error) { + var tasks []ECSTask + for i := 0; i < len(arns); i += 100 { + end := i + 100 + if end > len(arns) { + end = len(arns) + } + out, err := r.ECSClient.DescribeTasks(ctx, &ecs.DescribeTasksInput{ + Cluster: awssdk.String(clusterARN), + Tasks: arns[i:end], + }) + if err != nil { + return nil, fmt.Errorf("failed to describe ECS tasks: %w", err) + } + for _, t := range out.Tasks { + taskARN := awssdk.ToString(t.TaskArn) + taskID := taskARN + if idx := strings.LastIndex(taskARN, "/"); idx >= 0 { + taskID = taskARN[idx+1:] + } + var startedAt time.Time + if t.StartedAt != nil { + startedAt = *t.StartedAt + } + tasks = append(tasks, ECSTask{ + TaskARN: taskARN, + TaskID: taskID, + LastStatus: awssdk.ToString(t.LastStatus), + Group: awssdk.ToString(t.Group), + StartedAt: startedAt, + }) + } + } + return tasks, nil +} diff --git a/internal/services/aws/ecs_exec.go b/internal/services/aws/ecs_exec.go new file mode 100644 index 0000000..688224f --- /dev/null +++ b/internal/services/aws/ecs_exec.go @@ -0,0 +1,31 @@ +package aws + +import ( + "fmt" + "os/exec" +) + +const awsCLIBinary = "aws" + +// CheckAWSCLIInstalled verifies that the aws CLI is available in PATH. +func CheckAWSCLIInstalled() error { + _, err := exec.LookPath(awsCLIBinary) + if err != nil { + return fmt.Errorf("aws CLI not found in PATH: install it from https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html") + } + return nil +} + +// BuildECSExecCommand creates the exec.Cmd for `aws ecs execute-command` +// without running it. Used by the Bubbletea tea.ExecProcess integration. +func BuildECSExecCommand(clusterARN, taskARN, containerName, region string) *exec.Cmd { + return exec.Command(awsCLIBinary, + "ecs", "execute-command", + "--cluster", clusterARN, + "--task", taskARN, + "--container", containerName, + "--interactive", + "--command", "/bin/sh", + "--region", region, + ) +} diff --git a/internal/services/aws/ecs_model.go b/internal/services/aws/ecs_model.go new file mode 100644 index 0000000..33ffc33 --- /dev/null +++ b/internal/services/aws/ecs_model.go @@ -0,0 +1,81 @@ +package aws + +import ( + "fmt" + "time" +) + +// ECSCluster represents an ECS cluster. +type ECSCluster struct { + Name string + ARN string + Status string + ActiveServices int32 + RunningTasks int32 +} + +func (c ECSCluster) DisplayTitle() string { + return fmt.Sprintf("%-40s %-10s svc:%-4d tasks:%d", c.Name, c.Status, c.ActiveServices, c.RunningTasks) +} + +func (c ECSCluster) FilterText() string { + return c.Name +} + +// ECSService represents an ECS service within a cluster. +type ECSService struct { + Name string + ARN string + Status string + RunningCount int32 + DesiredCount int32 + LaunchType string +} + +func (s ECSService) DisplayTitle() string { + return fmt.Sprintf("%-40s %-10s %-8s %d/%d", s.Name, s.Status, s.LaunchType, s.RunningCount, s.DesiredCount) +} + +func (s ECSService) FilterText() string { + return s.Name +} + +// ECSTask represents a running ECS task. +type ECSTask struct { + TaskARN string + TaskID string + LastStatus string + Group string + StartedAt time.Time +} + +func (t ECSTask) DisplayTitle() string { + started := "" + if !t.StartedAt.IsZero() { + started = t.StartedAt.Format("2006-01-02 15:04") + } + return fmt.Sprintf("%-32s %-10s %-30s %s", t.TaskID, t.LastStatus, t.Group, started) +} + +func (t ECSTask) FilterText() string { + return t.TaskID + " " + t.Group +} + +// ECSContainer represents a container within an ECS task. +type ECSContainer struct { + Name string + RuntimeID string + ExecEnabled bool +} + +func (c ECSContainer) DisplayTitle() string { + execStatus := "exec:✗" + if c.ExecEnabled { + execStatus = "exec:✓" + } + return fmt.Sprintf("%-40s %s", c.Name, execStatus) +} + +func (c ECSContainer) FilterText() string { + return c.Name +} diff --git a/internal/services/aws/ecs_test.go b/internal/services/aws/ecs_test.go new file mode 100644 index 0000000..1ad2c30 --- /dev/null +++ b/internal/services/aws/ecs_test.go @@ -0,0 +1,215 @@ +package aws + +import ( + "context" + "fmt" + "testing" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ecs" + ecstypes "github.com/aws/aws-sdk-go-v2/service/ecs/types" +) + +// mockECSClient implements ECSClientAPI for testing. +type mockECSClient struct { + listClustersFunc func(ctx context.Context, params *ecs.ListClustersInput, optFns ...func(*ecs.Options)) (*ecs.ListClustersOutput, error) + describeClustersFunc func(ctx context.Context, params *ecs.DescribeClustersInput, optFns ...func(*ecs.Options)) (*ecs.DescribeClustersOutput, error) + listServicesFunc func(ctx context.Context, params *ecs.ListServicesInput, optFns ...func(*ecs.Options)) (*ecs.ListServicesOutput, error) + describeServicesFunc func(ctx context.Context, params *ecs.DescribeServicesInput, optFns ...func(*ecs.Options)) (*ecs.DescribeServicesOutput, error) + listTasksFunc func(ctx context.Context, params *ecs.ListTasksInput, optFns ...func(*ecs.Options)) (*ecs.ListTasksOutput, error) + describeTasksFunc func(ctx context.Context, params *ecs.DescribeTasksInput, optFns ...func(*ecs.Options)) (*ecs.DescribeTasksOutput, error) +} + +func (m *mockECSClient) ListClusters(ctx context.Context, params *ecs.ListClustersInput, optFns ...func(*ecs.Options)) (*ecs.ListClustersOutput, error) { + return m.listClustersFunc(ctx, params, optFns...) +} + +func (m *mockECSClient) DescribeClusters(ctx context.Context, params *ecs.DescribeClustersInput, optFns ...func(*ecs.Options)) (*ecs.DescribeClustersOutput, error) { + return m.describeClustersFunc(ctx, params, optFns...) +} + +func (m *mockECSClient) ListServices(ctx context.Context, params *ecs.ListServicesInput, optFns ...func(*ecs.Options)) (*ecs.ListServicesOutput, error) { + return m.listServicesFunc(ctx, params, optFns...) +} + +func (m *mockECSClient) DescribeServices(ctx context.Context, params *ecs.DescribeServicesInput, optFns ...func(*ecs.Options)) (*ecs.DescribeServicesOutput, error) { + return m.describeServicesFunc(ctx, params, optFns...) +} + +func (m *mockECSClient) ListTasks(ctx context.Context, params *ecs.ListTasksInput, optFns ...func(*ecs.Options)) (*ecs.ListTasksOutput, error) { + return m.listTasksFunc(ctx, params, optFns...) +} + +func (m *mockECSClient) DescribeTasks(ctx context.Context, params *ecs.DescribeTasksInput, optFns ...func(*ecs.Options)) (*ecs.DescribeTasksOutput, error) { + return m.describeTasksFunc(ctx, params, optFns...) +} + +func TestListClusters_success(t *testing.T) { + repo := &AwsRepository{ + ECSClient: &mockECSClient{ + listClustersFunc: func(_ context.Context, _ *ecs.ListClustersInput, _ ...func(*ecs.Options)) (*ecs.ListClustersOutput, error) { + return &ecs.ListClustersOutput{ + ClusterArns: []string{ + "arn:aws:ecs:us-east-1:123456789012:cluster/prod-cluster", + "arn:aws:ecs:us-east-1:123456789012:cluster/staging-cluster", + }, + }, nil + }, + describeClustersFunc: func(_ context.Context, params *ecs.DescribeClustersInput, _ ...func(*ecs.Options)) (*ecs.DescribeClustersOutput, error) { + return &ecs.DescribeClustersOutput{ + Clusters: []ecstypes.Cluster{ + { + ClusterName: awssdk.String("prod-cluster"), + ClusterArn: awssdk.String("arn:aws:ecs:us-east-1:123456789012:cluster/prod-cluster"), + Status: awssdk.String("ACTIVE"), + ActiveServicesCount: 5, + RunningTasksCount: 12, + }, + { + ClusterName: awssdk.String("staging-cluster"), + ClusterArn: awssdk.String("arn:aws:ecs:us-east-1:123456789012:cluster/staging-cluster"), + Status: awssdk.String("ACTIVE"), + ActiveServicesCount: 2, + RunningTasksCount: 3, + }, + }, + }, nil + }, + }, + } + + clusters, err := repo.ListClusters(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 != "prod-cluster" { + t.Errorf("expected prod-cluster, got %s", clusters[0].Name) + } + if clusters[0].ActiveServices != 5 { + t.Errorf("expected 5 active services, got %d", clusters[0].ActiveServices) + } + if clusters[1].Name != "staging-cluster" { + t.Errorf("expected staging-cluster, got %s", clusters[1].Name) + } +} + +func TestListClusters_empty(t *testing.T) { + repo := &AwsRepository{ + ECSClient: &mockECSClient{ + listClustersFunc: func(_ context.Context, _ *ecs.ListClustersInput, _ ...func(*ecs.Options)) (*ecs.ListClustersOutput, error) { + return &ecs.ListClustersOutput{ClusterArns: nil}, nil + }, + describeClustersFunc: func(_ context.Context, _ *ecs.DescribeClustersInput, _ ...func(*ecs.Options)) (*ecs.DescribeClustersOutput, error) { + return &ecs.DescribeClustersOutput{}, nil + }, + }, + } + + clusters, err := repo.ListClusters(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if clusters != nil { + t.Errorf("expected nil clusters, got %v", clusters) + } +} + +func TestListClusters_error(t *testing.T) { + repo := &AwsRepository{ + ECSClient: &mockECSClient{ + listClustersFunc: func(_ context.Context, _ *ecs.ListClustersInput, _ ...func(*ecs.Options)) (*ecs.ListClustersOutput, error) { + return nil, fmt.Errorf("AccessDenied") + }, + describeClustersFunc: nil, + }, + } + + _, err := repo.ListClusters(context.Background()) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestListServices_success(t *testing.T) { + repo := &AwsRepository{ + ECSClient: &mockECSClient{ + listServicesFunc: func(_ context.Context, _ *ecs.ListServicesInput, _ ...func(*ecs.Options)) (*ecs.ListServicesOutput, error) { + return &ecs.ListServicesOutput{ + ServiceArns: []string{"arn:aws:ecs:us-east-1:123456789012:service/prod-cluster/api-service"}, + }, nil + }, + describeServicesFunc: func(_ context.Context, _ *ecs.DescribeServicesInput, _ ...func(*ecs.Options)) (*ecs.DescribeServicesOutput, error) { + return &ecs.DescribeServicesOutput{ + Services: []ecstypes.Service{ + { + ServiceName: awssdk.String("api-service"), + ServiceArn: awssdk.String("arn:aws:ecs:us-east-1:123456789012:service/prod-cluster/api-service"), + Status: awssdk.String("ACTIVE"), + RunningCount: 3, + DesiredCount: 3, + LaunchType: ecstypes.LaunchTypeFargate, + }, + }, + }, nil + }, + }, + } + + services, err := repo.ListServices(context.Background(), "arn:aws:ecs:us-east-1:123456789012:cluster/prod-cluster") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(services) != 1 { + t.Fatalf("expected 1 service, got %d", len(services)) + } + if services[0].Name != "api-service" { + t.Errorf("expected api-service, got %s", services[0].Name) + } + if services[0].RunningCount != 3 { + t.Errorf("expected running count 3, got %d", services[0].RunningCount) + } + if services[0].LaunchType != "FARGATE" { + t.Errorf("expected FARGATE, got %s", services[0].LaunchType) + } +} + +func TestListTasks_success(t *testing.T) { + taskARN := "arn:aws:ecs:us-east-1:123456789012:task/prod-cluster/abc123def456" + repo := &AwsRepository{ + ECSClient: &mockECSClient{ + listTasksFunc: func(_ context.Context, _ *ecs.ListTasksInput, _ ...func(*ecs.Options)) (*ecs.ListTasksOutput, error) { + return &ecs.ListTasksOutput{ + TaskArns: []string{taskARN}, + }, nil + }, + describeTasksFunc: func(_ context.Context, _ *ecs.DescribeTasksInput, _ ...func(*ecs.Options)) (*ecs.DescribeTasksOutput, error) { + return &ecs.DescribeTasksOutput{ + Tasks: []ecstypes.Task{ + { + TaskArn: awssdk.String(taskARN), + LastStatus: awssdk.String("RUNNING"), + Group: awssdk.String("service:api-service"), + }, + }, + }, nil + }, + }, + } + + tasks, err := repo.ListTasks(context.Background(), "arn:aws:ecs:us-east-1:123456789012:cluster/prod-cluster", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(tasks) != 1 { + t.Fatalf("expected 1 task, got %d", len(tasks)) + } + if tasks[0].TaskID != "abc123def456" { + t.Errorf("expected task ID abc123def456, got %s", tasks[0].TaskID) + } + if tasks[0].LastStatus != "RUNNING" { + t.Errorf("expected RUNNING, got %s", tasks[0].LastStatus) + } +} diff --git a/internal/services/aws/repository.go b/internal/services/aws/repository.go index 09c1a9c..8b41f55 100644 --- a/internal/services/aws/repository.go +++ b/internal/services/aws/repository.go @@ -9,6 +9,7 @@ import ( "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs" "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/iam" "github.com/aws/aws-sdk-go-v2/service/rds" "github.com/aws/aws-sdk-go-v2/service/route53" @@ -44,6 +45,9 @@ var _ STSClientAPI = (*sts.Client)(nil) // Verify *cloudwatchlogs.Client satisfies CloudWatchLogsClientAPI at compile time. var _ CloudWatchLogsClientAPI = (*cloudwatchlogs.Client)(nil) +// Verify *ecs.Client satisfies ECSClientAPI at compile time. +var _ ECSClientAPI = (*ecs.Client)(nil) + // SSMClientAPI is the interface for SSM operations used by AwsRepository. type SSMClientAPI interface { ssm.DescribeInstanceInformationAPIClient @@ -101,6 +105,16 @@ type CloudWatchLogsClientAPI interface { FilterLogEvents(ctx context.Context, params *cloudwatchlogs.FilterLogEventsInput, optFns ...func(*cloudwatchlogs.Options)) (*cloudwatchlogs.FilterLogEventsOutput, error) } +// ECSClientAPI is the interface for ECS operations used by AwsRepository. +type ECSClientAPI interface { + ListClusters(ctx context.Context, params *ecs.ListClustersInput, optFns ...func(*ecs.Options)) (*ecs.ListClustersOutput, error) + DescribeClusters(ctx context.Context, params *ecs.DescribeClustersInput, optFns ...func(*ecs.Options)) (*ecs.DescribeClustersOutput, error) + ListServices(ctx context.Context, params *ecs.ListServicesInput, optFns ...func(*ecs.Options)) (*ecs.ListServicesOutput, error) + DescribeServices(ctx context.Context, params *ecs.DescribeServicesInput, optFns ...func(*ecs.Options)) (*ecs.DescribeServicesOutput, error) + ListTasks(ctx context.Context, params *ecs.ListTasksInput, optFns ...func(*ecs.Options)) (*ecs.ListTasksOutput, error) + DescribeTasks(ctx context.Context, params *ecs.DescribeTasksInput, optFns ...func(*ecs.Options)) (*ecs.DescribeTasksOutput, error) +} + // EC2ClientAPI is the interface for EC2 operations used by AwsRepository. type EC2ClientAPI interface { ec2.DescribeInstancesAPIClient @@ -121,7 +135,7 @@ type CallerIdentity struct { UserID string } -// AwsRepository holds AWS SDK clients for EC2, SSM, RDS, Route53, STS, Secrets Manager, and IAM. +// AwsRepository holds AWS SDK clients for EC2, SSM, RDS, Route53, STS, Secrets Manager, IAM, CloudWatch Logs, and ECS. type AwsRepository struct { EC2Client EC2ClientAPI SSMClient SSMClientAPI @@ -131,6 +145,7 @@ type AwsRepository struct { IAMClient IAMClientAPI STSClient STSClientAPI CloudWatchLogsClient CloudWatchLogsClientAPI + ECSClient ECSClientAPI Region string Profile string } @@ -188,6 +203,7 @@ func NewAwsRepository(ctx context.Context, cfg *config.Config) (*AwsRepository, IAMClient: iam.NewFromConfig(awsCfg), STSClient: sts.NewFromConfig(awsCfg), CloudWatchLogsClient: cloudwatchlogs.NewFromConfig(awsCfg), + ECSClient: ecs.NewFromConfig(awsCfg), Region: cfg.Region, Profile: cfg.Profile, }, nil From 76dd7df3ea8b2754419c3aee15d0c958b0ab6a14 Mon Sep 17 00:00:00 2001 From: jedon da hyeon Date: Tue, 7 Apr 2026 16:57:03 +0900 Subject: [PATCH 2/3] fix: harden ECS service name/task ID extraction and add API timeout - Extract service name from ARN before pagination loop (was repeated per iteration) - Guard serviceARN extraction: only use suffix when slash exists and is not the last char - Guard taskARN task ID extraction with same idx < len-1 bounds check - Replace context.Background() with 30s timeout context in all four ECS load commands --- internal/app/screen_ecs.go | 15 +++++++++++---- internal/services/aws/ecs.go | 25 +++++++++++++++++-------- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/internal/app/screen_ecs.go b/internal/app/screen_ecs.go index 07aff41..d329ab7 100644 --- a/internal/app/screen_ecs.go +++ b/internal/app/screen_ecs.go @@ -4,12 +4,15 @@ import ( "context" "fmt" "strings" + "time" tea "github.com/charmbracelet/bubbletea" awsservice "unic/internal/services/aws" ) +const ecsAPITimeout = 30 * time.Second + // handleECSMsg routes ECS messages to the correct screen. func (m Model) handleECSMsg(msg tea.Msg) (tea.Model, tea.Cmd, bool) { switch msg := msg.(type) { @@ -386,7 +389,8 @@ func (m Model) loadECSClusters() tea.Cmd { if err := awsservice.CheckAWSCLIInstalled(); err != nil { return errMsg{err: err} } - ctx := context.Background() + ctx, cancel := context.WithTimeout(context.Background(), ecsAPITimeout) + defer cancel() repo, err := awsservice.NewAwsRepository(ctx, m.cfg) if err != nil { return errMsg{err: err} @@ -407,7 +411,8 @@ func (m Model) loadECSServices() tea.Cmd { if m.selectedECSCluster == nil { return errMsg{err: fmt.Errorf("no cluster selected")} } - ctx := context.Background() + ctx, cancel := context.WithTimeout(context.Background(), ecsAPITimeout) + defer cancel() repo, err := awsservice.NewAwsRepository(ctx, m.cfg) if err != nil { return errMsg{err: err} @@ -428,7 +433,8 @@ func (m Model) loadECSTasks() tea.Cmd { if m.selectedECSCluster == nil || m.selectedECSService == nil { return errMsg{err: fmt.Errorf("no cluster or service selected")} } - ctx := context.Background() + ctx, cancel := context.WithTimeout(context.Background(), ecsAPITimeout) + defer cancel() repo, err := awsservice.NewAwsRepository(ctx, m.cfg) if err != nil { return errMsg{err: err} @@ -449,7 +455,8 @@ func (m Model) loadECSContainers() tea.Cmd { if m.selectedECSCluster == nil || m.selectedECSTask == nil { return errMsg{err: fmt.Errorf("no cluster or task selected")} } - ctx := context.Background() + ctx, cancel := context.WithTimeout(context.Background(), ecsAPITimeout) + defer cancel() repo, err := awsservice.NewAwsRepository(ctx, m.cfg) if err != nil { return errMsg{err: err} diff --git a/internal/services/aws/ecs.go b/internal/services/aws/ecs.go index d8a8b1e..5e24007 100644 --- a/internal/services/aws/ecs.go +++ b/internal/services/aws/ecs.go @@ -112,17 +112,26 @@ func (r *AwsRepository) ListServices(ctx context.Context, clusterARN string) ([] func (r *AwsRepository) ListTasks(ctx context.Context, clusterARN, serviceARN string) ([]ECSTask, error) { uniclog.Debug("aws", "ListTasks called", "cluster", clusterARN, "service", serviceARN) + // Extract service name from ARN once before the pagination loop. + // ECS ARN format: arn:aws:ecs:::service// + // Fall back to the full string if no slash is present (bare service name passed directly). + var serviceNameFilter *string + if serviceARN != "" { + idx := strings.LastIndex(serviceARN, "/") + if idx >= 0 && idx < len(serviceARN)-1 { + serviceNameFilter = awssdk.String(serviceARN[idx+1:]) + } else { + serviceNameFilter = awssdk.String(serviceARN) + } + } + var taskARNs []string var nextToken *string for { input := &ecs.ListTasksInput{ - Cluster: awssdk.String(clusterARN), - NextToken: nextToken, - } - if serviceARN != "" { - // Extract service name from ARN for the ServiceName filter - parts := strings.Split(serviceARN, "/") - input.ServiceName = awssdk.String(parts[len(parts)-1]) + Cluster: awssdk.String(clusterARN), + NextToken: nextToken, + ServiceName: serviceNameFilter, } out, err := r.ECSClient.ListTasks(ctx, input) if err != nil { @@ -188,7 +197,7 @@ func (r *AwsRepository) describeTasksFromARNs(ctx context.Context, clusterARN st for _, t := range out.Tasks { taskARN := awssdk.ToString(t.TaskArn) taskID := taskARN - if idx := strings.LastIndex(taskARN, "/"); idx >= 0 { + if idx := strings.LastIndex(taskARN, "/"); idx >= 0 && idx < len(taskARN)-1 { taskID = taskARN[idx+1:] } var startedAt time.Time From 897ffbaba020e8e0b8328f1c6ab4abc70c6db713 Mon Sep 17 00:00:00 2001 From: jedon da hyeon Date: Wed, 8 Apr 2026 15:05:21 +0900 Subject: [PATCH 3/3] fix: resolve AccountIDs mismatch in ECS exec and apply review fixes - Inject assume-role temporary credentials into aws CLI subprocess env to fix AccountIDs mismatch when using assume_role contexts - Add CredentialEnv() helper and ResolveCredentialEnv() method to strip AWS_PROFILE and inject SDK credentials into subprocess - Remove redundant filteredECSTasks alias (filter was never applied) - Route ECS exec errors through global errMsg handler instead of directly setting m.errMsg / m.screen - Fix scroll viewport overhead constant: task/container lists use 7, not 8; add overhead breakdown comments to all ECS list views - Align ecs_exec.go AWS SDK import alias to awssdk (matches codebase) - Ignore cs.json (SSM session-manager-plugin temp files) in .gitignore Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 2 ++ internal/app/app.go | 7 ++--- internal/app/screen_ecs.go | 47 +++++++++++++++++++---------- internal/services/aws/ecs_exec.go | 37 +++++++++++++++++++++-- internal/services/aws/repository.go | 14 +++++++++ 5 files changed, 85 insertions(+), 22 deletions(-) diff --git a/.gitignore b/.gitignore index d64e837..6eb8478 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /dist/ /unic .claude/reports/ +# SSM session-manager-plugin temp files (ecs exec session state) +cs.json diff --git a/internal/app/app.go b/internal/app/app.go index 9789552..0d405b4 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -195,10 +195,9 @@ type Model struct { ecsServiceFilterActive bool selectedECSService *awsservice.ECSService - ecsTasks []awsservice.ECSTask - filteredECSTasks []awsservice.ECSTask - ecsTaskIdx int - selectedECSTask *awsservice.ECSTask + ecsTasks []awsservice.ECSTask + ecsTaskIdx int + selectedECSTask *awsservice.ECSTask ecsContainers []awsservice.ECSContainer ecsContainerIdx int diff --git a/internal/app/screen_ecs.go b/internal/app/screen_ecs.go index d329ab7..6658df0 100644 --- a/internal/app/screen_ecs.go +++ b/internal/app/screen_ecs.go @@ -36,7 +36,6 @@ func (m Model) handleECSMsg(msg tea.Msg) (tea.Model, tea.Cmd, bool) { case ecsTasksLoadedMsg: m.ecsTasks = msg.tasks - m.filteredECSTasks = msg.tasks m.ecsTaskIdx = 0 m.screen = screenECSTaskList return m, nil, true @@ -48,11 +47,6 @@ func (m Model) handleECSMsg(msg tea.Msg) (tea.Model, tea.Cmd, bool) { return m, nil, true case ecsExecDoneMsg: - if msg.err != nil { - m.errMsg = msg.err.Error() - m.screen = screenError - return m, nil, true - } m.screen = screenECSContainerList return m, nil, true } @@ -121,6 +115,7 @@ func (m Model) viewECSClusterList() string { b.WriteString(dimStyle.Render(" No clusters found")) b.WriteString("\n") } else { + // overhead: status bar (2) + title (1) + filter line (1) + blank (1) + count (1) + blank (1) + footer (1) = 8 visibleLines := max(m.height-8, 5) start := 0 if m.ecsClusterIdx >= visibleLines { @@ -214,6 +209,7 @@ func (m Model) viewECSServiceList() string { b.WriteString(dimStyle.Render(" No services found")) b.WriteString("\n") } else { + // overhead: status bar (2) + title (1) + filter line (1) + blank (1) + count (1) + blank (1) + footer (1) = 8 visibleLines := max(m.height-8, 5) start := 0 if m.ecsServiceIdx >= visibleLines { @@ -252,15 +248,15 @@ func (m Model) updateECSTaskList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.ecsTaskIdx-- } case "down", "j": - if m.ecsTaskIdx < len(m.filteredECSTasks)-1 { + if m.ecsTaskIdx < len(m.ecsTasks)-1 { m.ecsTaskIdx++ } case "r": m.screen = screenLoading return m, m.loadECSTasks() case "enter": - if len(m.filteredECSTasks) > 0 && m.ecsTaskIdx < len(m.filteredECSTasks) { - task := m.filteredECSTasks[m.ecsTaskIdx] + if len(m.ecsTasks) > 0 && m.ecsTaskIdx < len(m.ecsTasks) { + task := m.ecsTasks[m.ecsTaskIdx] m.selectedECSTask = &task m.screen = screenLoading return m, m.loadECSContainers() @@ -279,19 +275,20 @@ func (m Model) viewECSTaskList() string { b.WriteString(titleStyle.Render(fmt.Sprintf("ECS Tasks — %s", svcName))) b.WriteString("\n\n") - if len(m.filteredECSTasks) == 0 { + if len(m.ecsTasks) == 0 { b.WriteString(dimStyle.Render(" No running tasks found")) b.WriteString("\n") } else { - visibleLines := max(m.height-8, 5) + // overhead: status bar (2) + title (1) + blank (1) + count (1) + blank (1) + footer (1) = 7 + visibleLines := max(m.height-7, 5) start := 0 if m.ecsTaskIdx >= visibleLines { start = m.ecsTaskIdx - visibleLines + 1 } - end := min(start+visibleLines, len(m.filteredECSTasks)) + end := min(start+visibleLines, len(m.ecsTasks)) for i := start; i < end; i++ { - t := m.filteredECSTasks[i] + t := m.ecsTasks[i] cursor := " " style := normalStyle if i == m.ecsTaskIdx { @@ -302,7 +299,7 @@ func (m Model) viewECSTaskList() string { b.WriteString("\n") } b.WriteString("\n") - b.WriteString(dimStyle.Render(fmt.Sprintf(" %d tasks", len(m.filteredECSTasks)))) + b.WriteString(dimStyle.Render(fmt.Sprintf(" %d tasks", len(m.ecsTasks)))) } b.WriteString("\n") @@ -355,7 +352,8 @@ func (m Model) viewECSContainerList() string { b.WriteString(dimStyle.Render(" No containers found")) b.WriteString("\n") } else { - visibleLines := max(m.height-8, 5) + // overhead: status bar (2) + title (1) + blank (1) + count (1) + blank (1) + footer (1) = 7 + visibleLines := max(m.height-7, 5) start := 0 if m.ecsContainerIdx >= visibleLines { start = m.ecsContainerIdx - visibleLines + 1 @@ -479,15 +477,32 @@ func (m Model) startECSExec(container awsservice.ECSContainer) tea.Cmd { return errMsg{err: fmt.Errorf("no cluster or task selected")} } + ctx, cancel := context.WithTimeout(context.Background(), ecsAPITimeout) + defer cancel() + + repo, err := awsservice.NewAwsRepository(ctx, m.cfg) + if err != nil { + return errMsg{err: err} + } + + credEnv, err := repo.ResolveCredentialEnv(ctx) + if err != nil { + return errMsg{err: fmt.Errorf("failed to resolve credentials for ECS exec: %w", err)} + } + cmd := awsservice.BuildECSExecCommand( m.selectedECSCluster.ARN, m.selectedECSTask.TaskARN, container.Name, m.cfg.Region, + credEnv, ) execCmd := tea.ExecProcess(cmd, func(err error) tea.Msg { - return ecsExecDoneMsg{err: err} + if err != nil { + return errMsg{err: err} + } + return ecsExecDoneMsg{} }) return execCmd() } diff --git a/internal/services/aws/ecs_exec.go b/internal/services/aws/ecs_exec.go index 688224f..744a310 100644 --- a/internal/services/aws/ecs_exec.go +++ b/internal/services/aws/ecs_exec.go @@ -2,7 +2,11 @@ package aws import ( "fmt" + "os" "os/exec" + "strings" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" ) const awsCLIBinary = "aws" @@ -16,10 +20,35 @@ func CheckAWSCLIInstalled() error { return nil } +// CredentialEnv builds an os.Environ()-based slice with AWS_ACCESS_KEY_ID, +// AWS_SECRET_ACCESS_KEY, and (when present) AWS_SESSION_TOKEN injected. +// AWS_PROFILE / AWS_DEFAULT_PROFILE are stripped so the CLI uses the injected +// credentials rather than the base profile (which may be a different account). +func CredentialEnv(creds awssdk.Credentials) []string { + env := make([]string, 0, len(os.Environ())+3) + for _, e := range os.Environ() { + key, _, _ := strings.Cut(e, "=") + if key == "AWS_PROFILE" || key == "AWS_DEFAULT_PROFILE" { + continue + } + env = append(env, e) + } + env = append(env, + "AWS_ACCESS_KEY_ID="+creds.AccessKeyID, + "AWS_SECRET_ACCESS_KEY="+creds.SecretAccessKey, + ) + if creds.SessionToken != "" { + env = append(env, "AWS_SESSION_TOKEN="+creds.SessionToken) + } + return env +} + // BuildECSExecCommand creates the exec.Cmd for `aws ecs execute-command` // without running it. Used by the Bubbletea tea.ExecProcess integration. -func BuildECSExecCommand(clusterARN, taskARN, containerName, region string) *exec.Cmd { - return exec.Command(awsCLIBinary, +// credEnv, if non-nil, is set as the command's environment to inject +// assume-role temporary credentials and avoid AccountIDs mismatch errors. +func BuildECSExecCommand(clusterARN, taskARN, containerName, region string, credEnv []string) *exec.Cmd { + cmd := exec.Command(awsCLIBinary, "ecs", "execute-command", "--cluster", clusterARN, "--task", taskARN, @@ -28,4 +57,8 @@ func BuildECSExecCommand(clusterARN, taskARN, containerName, region string) *exe "--command", "/bin/sh", "--region", region, ) + if credEnv != nil { + cmd.Env = credEnv + } + return cmd } diff --git a/internal/services/aws/repository.go b/internal/services/aws/repository.go index 8b41f55..cc5d2d4 100644 --- a/internal/services/aws/repository.go +++ b/internal/services/aws/repository.go @@ -148,6 +148,7 @@ type AwsRepository struct { ECSClient ECSClientAPI Region string Profile string + awsCfg aws.Config } // NewAwsRepository creates a new AwsRepository with configured EC2 and SSM clients. @@ -206,9 +207,22 @@ func NewAwsRepository(ctx context.Context, cfg *config.Config) (*AwsRepository, ECSClient: ecs.NewFromConfig(awsCfg), Region: cfg.Region, Profile: cfg.Profile, + awsCfg: awsCfg, }, nil } +// ResolveCredentialEnv retrieves the current AWS credentials and returns them +// as environment variable pairs suitable for subprocess injection. This ensures +// that CLI subprocesses (e.g. aws ecs execute-command) use the same credentials +// as the SDK, preventing AccountIDs mismatch when using assume_role contexts. +func (r *AwsRepository) ResolveCredentialEnv(ctx context.Context) ([]string, error) { + creds, err := r.awsCfg.Credentials.Retrieve(ctx) + if err != nil { + return nil, fmt.Errorf("failed to retrieve AWS credentials: %w", err) + } + return CredentialEnv(creds), nil +} + // LoadBaseConfig resolves AWS config for the requested region/profile. // An explicit profile takes precedence over ambient env credentials. func LoadBaseConfig(ctx context.Context, region, profile string) (aws.Config, error) {