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/README.md b/README.md index ed4312e..4b60c1b 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,7 @@ Behavior: | 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 @@ -243,6 +244,16 @@ Behavior: | `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 933a65c..25a7702 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( 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/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/checksum v1.9.13 // indirect diff --git a/go.sum b/go.sum index 35aec7e..2cd54ae 100644 --- a/go.sum +++ b/go.sum @@ -26,6 +26,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 dd3346b..b7b76f5 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -48,6 +48,10 @@ const ( screenCWLogGroupList screenCWLogStreamList screenCWLogViewer + screenECSClusterList + screenECSServiceList + screenECSTaskList + screenECSContainerList screenS3BucketList screenS3ObjectList screenS3ObjectDetail @@ -179,6 +183,28 @@ 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 + ecsTaskIdx int + selectedECSTask *awsservice.ECSTask + + ecsContainers []awsservice.ECSContainer + ecsContainerIdx int + // CloudWatch Logs browser state cwLogGroups []awsservice.LogGroup filteredCWLogGroups []awsservice.LogGroup @@ -329,6 +355,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.handleIAMMsg, m.handleSecretMsg, m.handleCloudWatchLogsMsg, + m.handleECSMsg, m.handleS3Msg, m.handleContextMsg, } { @@ -424,6 +451,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: @@ -530,6 +565,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() } } } @@ -621,6 +659,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 8df5731..3c1bcfe 100644 --- a/internal/app/messages.go +++ b/internal/app/messages.go @@ -166,3 +166,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..6658df0 --- /dev/null +++ b/internal/app/screen_ecs.go @@ -0,0 +1,509 @@ +package app + +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) { + 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.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: + 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 { + // 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 { + 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 { + // 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 { + 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.ecsTasks)-1 { + m.ecsTaskIdx++ + } + case "r": + m.screen = screenLoading + return m, m.loadECSTasks() + case "enter": + 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() + } + } + 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.ecsTasks) == 0 { + b.WriteString(dimStyle.Render(" No running tasks found")) + b.WriteString("\n") + } else { + // 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.ecsTasks)) + + for i := start; i < end; i++ { + t := m.ecsTasks[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.ecsTasks)))) + } + + 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 { + // 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 + } + 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, cancel := context.WithTimeout(context.Background(), ecsAPITimeout) + defer cancel() + 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, cancel := context.WithTimeout(context.Background(), ecsAPITimeout) + defer cancel() + 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, cancel := context.WithTimeout(context.Background(), ecsAPITimeout) + defer cancel() + 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, cancel := context.WithTimeout(context.Background(), ecsAPITimeout) + defer cancel() + 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")} + } + + 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 { + if err != nil { + return errMsg{err: err} + } + return ecsExecDoneMsg{} + }) + return execCmd() + } +} diff --git a/internal/domain/catalog.go b/internal/domain/catalog.go index c02414a..f887c21 100644 --- a/internal/domain/catalog.go +++ b/internal/domain/catalog.go @@ -62,7 +62,16 @@ func Catalog() []Service { }, }, { - Name: ServiceS3, + Name: ServiceECS, + Features: []Feature{ + { + Kind: FeatureECSExec, + Description: "Browse ECS clusters, services, tasks, and launch exec sessions", + }, + }, + }, + { + Name: ServiceS3, Features: []Feature{ { Kind: FeatureS3Browser, diff --git a/internal/domain/model.go b/internal/domain/model.go index cde3e62..a0d270d 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" ServiceS3 AwsService = "S3" ) @@ -28,6 +29,7 @@ const ( FeatureListAccessKeys FeatureKind = "ListAccessKeys" FeatureRotateAccessKey FeatureKind = "RotateAccessKey" FeatureCloudWatchLogsBrowser FeatureKind = "CloudWatch Logs Browser" + FeatureECSExec FeatureKind = "ECS Exec Sessions" FeatureS3Browser FeatureKind = "S3 Browser" ) diff --git a/internal/services/aws/ecs.go b/internal/services/aws/ecs.go new file mode 100644 index 0000000..5e24007 --- /dev/null +++ b/internal/services/aws/ecs.go @@ -0,0 +1,217 @@ +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) + + // 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, + ServiceName: serviceNameFilter, + } + 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 && idx < len(taskARN)-1 { + 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..744a310 --- /dev/null +++ b/internal/services/aws/ecs_exec.go @@ -0,0 +1,64 @@ +package aws + +import ( + "fmt" + "os" + "os/exec" + "strings" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" +) + +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 +} + +// 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. +// 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, + "--container", containerName, + "--interactive", + "--command", "/bin/sh", + "--region", region, + ) + if credEnv != nil { + cmd.Env = credEnv + } + return cmd +} 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 e081cf1..7e55c13 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" @@ -45,6 +46,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) + // Verify *s3.Client satisfies S3ClientAPI at compile time. var _ S3ClientAPI = (*s3.Client)(nil) @@ -105,6 +109,15 @@ 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) +} // 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) @@ -133,7 +146,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 @@ -143,9 +156,11 @@ type AwsRepository struct { IAMClient IAMClientAPI STSClient STSClientAPI CloudWatchLogsClient CloudWatchLogsClientAPI + ECSClient ECSClientAPI S3Client S3ClientAPI Region string Profile string + awsCfg aws.Config } // NewAwsRepository creates a new AwsRepository with configured EC2 and SSM clients. @@ -201,12 +216,26 @@ 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), S3Client: s3.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) {