From 2b5c8fc34f8827777f72c8cb6f7ae25607c5014a Mon Sep 17 00:00:00 2001 From: Guillaume Delacour Date: Fri, 12 Jun 2026 10:59:47 +0200 Subject: [PATCH 1/2] feat: Support ECS Managed Daemons --- .github/workflows/publish-image.yml | 2 +- .github/workflows/release.yml | 4 +- .github/workflows/test.yml | 4 +- go.mod | 10 +- go.sum | 20 +- internal/api/daemon.go | 112 ++++++++++ internal/view/app.go | 42 ++-- internal/view/cluster.go | 22 +- internal/view/cluster_test.go | 4 +- internal/view/daemon.go | 93 ++++++++ internal/view/daemon_task_definition.go | 146 +++++++++++++ internal/view/daemon_task_definition_test.go | 109 ++++++++++ internal/view/daemon_test.go | 113 ++++++++++ internal/view/footer.go | 56 +++-- internal/view/header.go | 1 + internal/view/json.go | 4 + internal/view/kind.go | 14 +- internal/view/table.go | 40 +++- tests/daemon.tf | 217 +++++++++++++++++++ tests/providers.tf | 2 +- 20 files changed, 948 insertions(+), 67 deletions(-) create mode 100644 internal/api/daemon.go create mode 100644 internal/view/daemon.go create mode 100644 internal/view/daemon_task_definition.go create mode 100644 internal/view/daemon_task_definition_test.go create mode 100644 internal/view/daemon_test.go create mode 100644 tests/daemon.tf diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index 47a21c3..3028b1a 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -21,7 +21,7 @@ jobs: packages: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 # Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here. - name: Log in to the Container registry diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 71e0169..6c6008e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,12 +15,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 # - run: git fetch --force --tags - name: Setup GO - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version-file: go.mod - name: Release diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a1a9781..8eef66e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,10 +13,10 @@ jobs: name: Test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version-file: go.mod diff --git a/go.mod b/go.mod index 39c15a5..450b7d6 100644 --- a/go.mod +++ b/go.mod @@ -5,13 +5,13 @@ go 1.26 require ( github.com/BurntSushi/toml v1.4.0 github.com/atotto/clipboard v0.1.4 - github.com/aws/aws-sdk-go-v2 v1.41.7 + github.com/aws/aws-sdk-go-v2 v1.42.0 github.com/aws/aws-sdk-go-v2/config v1.32.17 github.com/aws/aws-sdk-go-v2/service/account v1.31.0 github.com/aws/aws-sdk-go-v2/service/applicationautoscaling v1.41.13 github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.55.2 github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.71.1 - github.com/aws/aws-sdk-go-v2/service/ecs v1.74.0 + github.com/aws/aws-sdk-go-v2/service/ecs v1.83.0 github.com/aws/aws-sdk-go-v2/service/ssm v1.68.3 github.com/gdamore/tcell/v2 v2.13.9 github.com/keidarcy/aws-regions/v3 v3.0.0-20260309105808-fbc1ba25ea42 @@ -26,8 +26,8 @@ require ( github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect @@ -35,7 +35,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect - github.com/aws/smithy-go v1.25.1 // indirect + github.com/aws/smithy-go v1.27.1 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/gdamore/encoding v1.0.1 // indirect github.com/hashicorp/hcl v1.0.0 // indirect diff --git a/go.sum b/go.sum index 56e55b6..578b13e 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0 github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= -github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= +github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA= +github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY= github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU= @@ -12,10 +12,10 @@ github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60Qb github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= github.com/aws/aws-sdk-go-v2/service/account v1.31.0 h1:AdQH4Jtf/2gu8h1lyUpwJmhtW/3ppyj4emi/PThI9Zw= @@ -26,8 +26,8 @@ github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.55.2 h1:mleWBVIxwceEzyItUVoqM github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.55.2/go.mod h1:cMApt548kNgu87UsBTNWVv+fpzjbUTFRSFjD1688SBs= github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.71.1 h1:p0A8HO2B++3LfOTRxQScOPc3QhFWgyAXQQ6W92RT7Yk= github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.71.1/go.mod h1:MLJu3PUd8fp5Qvj4CiLvyY5H8y7kxHKlTp060Wsd+Vc= -github.com/aws/aws-sdk-go-v2/service/ecs v1.74.0 h1:YS5TXaEvzDb+sV+wdQFUtuCAk0GeFR9Ai6HFdxpz6q8= -github.com/aws/aws-sdk-go-v2/service/ecs v1.74.0/go.mod h1:10kBgdaNJz0FO/+JWDUH+0rtSjkn5yafgavDDmmhFzs= +github.com/aws/aws-sdk-go-v2/service/ecs v1.83.0 h1:LQKIHuVHqdbU9LUt5c2G9f+CcQAzolxQmAch3RTORMc= +github.com/aws/aws-sdk-go-v2/service/ecs v1.83.0/go.mod h1:0vahPCh3slyORHbSuAP8YDyJKLEUQAMX7+bzYGxEnVI= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= @@ -42,8 +42,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29 github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= -github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= -github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8= +github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/internal/api/daemon.go b/internal/api/daemon.go new file mode 100644 index 0000000..f07455e --- /dev/null +++ b/internal/api/daemon.go @@ -0,0 +1,112 @@ +package api + +import ( + "context" + "log/slog" + + "github.com/aws/aws-sdk-go-v2/service/ecs" + "github.com/aws/aws-sdk-go-v2/service/ecs/types" +) + +// Equivalent to +// aws ecs list-daemons --cluster ${cluster} +func (store *Store) ListDaemons(clusterArn *string) ([]types.DaemonSummary, error) { + limit := int32(100) + params := &ecs.ListDaemonsInput{ + ClusterArn: clusterArn, + MaxResults: &limit, + } + + results := []types.DaemonSummary{} + + for { + output, err := store.ecs.ListDaemons(context.Background(), params) + if err != nil { + slog.Warn("failed to run aws api to list daemons", "error", err) + if len(results) == 0 { + return nil, err + } + break + } + + results = append(results, output.DaemonSummariesList...) + + if output.NextToken != nil { + params.NextToken = output.NextToken + } else { + break + } + } + + return results, nil +} + +// Equivalent to +// aws ecs describe-daemon --daemon-arn ${daemonArn} +func (store *Store) DescribeDaemon(daemonArn *string) (*types.DaemonDetail, error) { + output, err := store.ecs.DescribeDaemon(context.Background(), &ecs.DescribeDaemonInput{ + DaemonArn: daemonArn, + }) + if err != nil { + slog.Warn("failed to run aws api to describe daemon", "error", err) + return nil, err + } + return output.Daemon, nil +} + +// Equivalent to +// aws ecs list-daemon-task-definitions --family ${family} +// aws ecs describe-daemon-task-definition --daemon-task-definition ${arn} +func (store *Store) ListDaemonTaskDefinitions(family *string) ([]types.DaemonTaskDefinition, error) { + limit := int32(20) + output, err := store.ecs.ListDaemonTaskDefinitions(context.Background(), &ecs.ListDaemonTaskDefinitionsInput{ + Family: family, + MaxResults: &limit, + Sort: types.SortOrderDesc, + }) + if err != nil { + slog.Warn("failed to run aws api to list daemon task definitions", "error", err) + return nil, err + } + + results := []types.DaemonTaskDefinition{} + for _, summary := range output.DaemonTaskDefinitions { + if summary.Arn == nil { + continue + } + td, err := store.DescribeDaemonTaskDefinition(summary.Arn) + if err != nil { + slog.Warn("failed to describe daemon task definition", "arn", *summary.Arn, "error", err) + continue + } + results = append(results, *td) + } + + return results, nil +} + +// Equivalent to +// aws ecs describe-daemon-task-definition --daemon-task-definition ${arn} +func (store *Store) DescribeDaemonTaskDefinition(arn *string) (*types.DaemonTaskDefinition, error) { + output, err := store.ecs.DescribeDaemonTaskDefinition(context.Background(), &ecs.DescribeDaemonTaskDefinitionInput{ + DaemonTaskDefinition: arn, + }) + if err != nil { + slog.Warn("failed to run aws api to describe daemon task definition", "error", err) + return nil, err + } + return output.DaemonTaskDefinition, nil +} + +// Equivalent to +// aws ecs describe-daemon-revisions --daemon-revision-arns ${arns} +func (store *Store) DescribeDaemonRevisions(arns []string) ([]types.DaemonRevision, error) { + output, err := store.ecs.DescribeDaemonRevisions(context.Background(), &ecs.DescribeDaemonRevisionsInput{ + DaemonRevisionArns: arns, + }) + if err != nil { + slog.Warn("failed to run aws api to describe daemon revisions", "error", err) + return nil, err + } + return output.DaemonRevisions, nil +} diff --git a/internal/view/app.go b/internal/view/app.go index 53a4708..9b2e11a 100644 --- a/internal/view/app.go +++ b/internal/view/app.go @@ -26,20 +26,22 @@ var globalRegion string // Entity contains ECS resources to show, use uppercase to make items like app.cluster easy to access type Entity struct { - cluster *types.Cluster - service *types.Service - task *types.Task - container *types.Container - taskDefinition *types.TaskDefinition - events []types.ServiceEvent - metrics *api.MetricsData - autoScaling *api.AutoScalingData - instance *types.ContainerInstance - serviceDeployment *types.ServiceDeployment - serviceRevision *types.ServiceRevision - profile string - region *api.Region - entityName string + cluster *types.Cluster + service *types.Service + task *types.Task + container *types.Container + taskDefinition *types.TaskDefinition + daemonSummary *types.DaemonSummary + daemonTaskDefinition *types.DaemonTaskDefinition + events []types.ServiceEvent + metrics *api.MetricsData + autoScaling *api.AutoScalingData + instance *types.ContainerInstance + serviceDeployment *types.ServiceDeployment + serviceRevision *types.ServiceRevision + profile string + region *api.Region + entityName string } type Option struct { @@ -287,6 +289,12 @@ func (app *App) getPageHandle() string { switch app.kind { case ServiceKind: name = *app.cluster.ClusterArn + case DaemonKind: + name = *app.cluster.ClusterArn + case DaemonTaskDefinitionKind: + if app.daemonSummary != nil && app.daemonSummary.DaemonArn != nil { + name = *app.daemonSummary.DaemonArn + } case TaskKind, TaskDefinitionKind, ServiceDeploymentKind: name = *app.service.ServiceArn case ContainerKind: @@ -347,7 +355,7 @@ func (app *App) start() error { // Show Primary kind page func (app *App) showPrimaryKindPage(k kind, reload bool) error { var err error - if k == TaskDefinitionKind { + if k == TaskDefinitionKind || k == DaemonTaskDefinitionKind { app.backKind = app.kind } app.kind = k @@ -364,6 +372,10 @@ func (app *App) showPrimaryKindPage(k kind, reload bool) error { err = app.showContainersPage(reload) case TaskDefinitionKind: err = app.showTaskDefinitionPage(reload) + case DaemonKind: + err = app.showDaemonsPage(reload) + case DaemonTaskDefinitionKind: + err = app.showDaemonTaskDefinitionPage(reload) case ServiceDeploymentKind: err = app.showServiceDeploymentPage(reload) default: diff --git a/internal/view/cluster.go b/internal/view/cluster.go index 73a3761..3fd0964 100644 --- a/internal/view/cluster.go +++ b/internal/view/cluster.go @@ -13,19 +13,22 @@ import ( type clusterView struct { view - clusters []types.Cluster + clusters []types.Cluster + daemonCounts map[string]int } -func newClusterView(clusters []types.Cluster, app *App) *clusterView { +func newClusterView(clusters []types.Cluster, daemonCounts map[string]int, app *App) *clusterView { keys := append(basicKeyInputs, []keyDescriptionPair{ hotKeyMap["n"], hotKeyMap["N"], + hotKeyMap["M"], }...) return &clusterView{ view: *newView(app, keys, secondaryPageKeyMap{ DescriptionKind: describePageKeys, }), - clusters: clusters, + clusters: clusters, + daemonCounts: daemonCounts, } } @@ -44,7 +47,14 @@ func (app *App) showClustersPage(reload bool) error { resources, err = app.Store.ListClusters() } err = buildResourcePage(resources, app, err, func() resourceViewBuilder { - return newClusterView(resources, app) + daemonCounts := make(map[string]int) + for _, c := range resources { + daemons, dErr := app.Store.ListDaemons(c.ClusterArn) + if dErr == nil { + daemonCounts[*c.ClusterArn] = len(daemons) + } + } + return newClusterView(resources, daemonCounts, app) }) return err } @@ -141,6 +151,7 @@ func (v *clusterView) tableParamsBuilder() (title string, headers []string, rows "Name", "Status", "Services", + "Daemons", "Tasks", "Container instances", "Capacity providers", @@ -151,10 +162,13 @@ func (v *clusterView) tableParamsBuilder() (title string, headers []string, rows // calculate tasks tasks := fmt.Sprintf(color.TableClusterTasksFmt, c.PendingTasksCount, c.RunningTasksCount) + daemonCount := v.daemonCounts[*c.ClusterArn] + row := []string{} row = append(row, utils.ShowString(c.ClusterName)) row = append(row, utils.ShowGreenGrey(c.Status, "active")) row = append(row, utils.ShowInt(&c.ActiveServicesCount)) + row = append(row, strconv.Itoa(daemonCount)) row = append(row, tasks) row = append(row, utils.ShowInt(&c.RegisteredContainerInstancesCount)+" EC2") row = append(row, utils.ShowArray(c.CapacityProviders)) diff --git a/internal/view/cluster_test.go b/internal/view/cluster_test.go index 7bbc3c0..c66ae9a 100644 --- a/internal/view/cluster_test.go +++ b/internal/view/cluster_test.go @@ -45,8 +45,8 @@ func getClusterViews() []clusterView { cluster2.ActiveServicesCount = *clusterServices2 app, _ := newApp(Option{}) - clusterView1 := newClusterView([]types.Cluster{cluster1}, app) - clusterView2 := newClusterView([]types.Cluster{cluster2}, app) + clusterView1 := newClusterView([]types.Cluster{cluster1}, map[string]int{}, app) + clusterView2 := newClusterView([]types.Cluster{cluster2}, map[string]int{}, app) return []clusterView{*clusterView1, *clusterView2} } diff --git a/internal/view/daemon.go b/internal/view/daemon.go new file mode 100644 index 0000000..484c95e --- /dev/null +++ b/internal/view/daemon.go @@ -0,0 +1,93 @@ +package view + +import ( + "fmt" + + "github.com/aws/aws-sdk-go-v2/service/ecs/types" + "github.com/keidarcy/e1s/internal/color" + "github.com/keidarcy/e1s/internal/utils" + "github.com/rivo/tview" +) + +type daemonView struct { + view + daemons []types.DaemonSummary +} + +func newDaemonView(daemons []types.DaemonSummary, app *App) *daemonView { + keys := append(basicKeyInputs, []keyDescriptionPair{ + hotKeyMap["t"], + }...) + return &daemonView{ + view: *newView(app, keys, secondaryPageKeyMap{ + DescriptionKind: describePageKeys, + }), + daemons: daemons, + } +} + +func (app *App) showDaemonsPage(reload bool) error { + if switched := app.switchPage(reload); switched { + return nil + } + + resources, err := app.Store.ListDaemons(app.cluster.ClusterArn) + err = buildResourcePage(resources, app, err, func() resourceViewBuilder { + return newDaemonView(resources, app) + }) + return err +} + +func (v *daemonView) getViewAndFooter() (*view, *tview.TextView) { + return &v.view, v.footer.daemon +} + +func (v *daemonView) headerParamsBuilder() []headerPageParam { + params := make([]headerPageParam, 0, len(v.daemons)) + for i, d := range v.daemons { + params = append(params, headerPageParam{ + title: utils.ArnToName(d.DaemonArn), + entityName: *d.DaemonArn, + items: v.headerPageItems(i), + }) + } + return params +} + +func (v *daemonView) headerPageItems(index int) (items []headerItem) { + d := v.daemons[index] + items = []headerItem{ + {name: "Daemon", value: utils.ArnToName(d.DaemonArn)}, + {name: "Status", value: string(d.Status)}, + {name: "Created at", value: utils.ShowTime(d.CreatedAt)}, + {name: "Updated at", value: utils.ShowTime(d.UpdatedAt)}, + } + return +} + +func (v *daemonView) tableParamsBuilder() (title string, headers []string, rowsBuilder func() [][]string) { + title = fmt.Sprintf(color.TableTitleFmt, v.app.kind, *v.app.cluster.ClusterName, len(v.daemons)) + headers = []string{ + "Daemon", + "Status", + "Created", + "Updated", + } + rowsBuilder = func() (data [][]string) { + for _, d := range v.daemons { + status := string(d.Status) + row := []string{ + utils.ArnToName(d.DaemonArn), + utils.ShowGreenGrey(&status, "active"), + utils.Age(d.CreatedAt), + utils.Age(d.UpdatedAt), + } + data = append(data, row) + + entity := Entity{daemonSummary: &d, entityName: *d.DaemonArn} + v.originalRowReferences = append(v.originalRowReferences, entity) + } + return data + } + return +} diff --git a/internal/view/daemon_task_definition.go b/internal/view/daemon_task_definition.go new file mode 100644 index 0000000..4057233 --- /dev/null +++ b/internal/view/daemon_task_definition.go @@ -0,0 +1,146 @@ +package view + +import ( + "fmt" + "strconv" + "strings" + + "github.com/aws/aws-sdk-go-v2/service/ecs/types" + "github.com/keidarcy/e1s/internal/color" + "github.com/keidarcy/e1s/internal/utils" + "github.com/rivo/tview" +) + +type daemonTaskDefinitionView struct { + view + taskDefinitions []types.DaemonTaskDefinition +} + +func newDaemonTaskDefinitionView(taskDefinitions []types.DaemonTaskDefinition, app *App) *daemonTaskDefinitionView { + return &daemonTaskDefinitionView{ + view: *newView(app, basicKeyInputs, secondaryPageKeyMap{ + DescriptionKind: describePageKeys, + }), + taskDefinitions: taskDefinitions, + } +} + +func (app *App) showDaemonTaskDefinitionPage(reload bool) error { + if switched := app.switchPage(reload); switched { + return nil + } + + var family *string + + // Coming from a daemon task (via task view with group "daemon:...") + if app.task != nil && app.task.TaskDefinitionArn != nil && app.task.Group != nil && strings.HasPrefix(*app.task.Group, "daemon:") { + name := utils.ArnToName(app.task.TaskDefinitionArn) + parts := strings.Split(name, ":") + family = &parts[0] + } else if app.daemonSummary != nil && app.daemonSummary.DaemonArn != nil { + // Coming from the daemon view — resolve via DescribeDaemon + DescribeDaemonRevisions + detail, err := app.Store.DescribeDaemon(app.daemonSummary.DaemonArn) + if err == nil && detail != nil && len(detail.CurrentRevisions) > 0 { + revOutput, revErr := app.Store.DescribeDaemonRevisions([]string{*detail.CurrentRevisions[0].Arn}) + if revErr == nil && len(revOutput) > 0 && revOutput[0].DaemonTaskDefinitionArn != nil { + name := utils.ArnToName(revOutput[0].DaemonTaskDefinitionArn) + parts := strings.Split(name, ":") + family = &parts[0] + } + } + } + + if family == nil { + return fmt.Errorf("no daemon task definition found") + } + + resources, err := app.Store.ListDaemonTaskDefinitions(family) + err = buildResourcePage(resources, app, err, func() resourceViewBuilder { + return newDaemonTaskDefinitionView(resources, app) + }) + return err +} + +func (v *daemonTaskDefinitionView) getViewAndFooter() (*view, *tview.TextView) { + return &v.view, v.footer.daemonTaskDefinition +} + +func (v *daemonTaskDefinitionView) headerParamsBuilder() []headerPageParam { + params := make([]headerPageParam, 0, len(v.taskDefinitions)) + for i, t := range v.taskDefinitions { + params = append(params, headerPageParam{ + title: utils.ArnToName(t.DaemonTaskDefinitionArn), + entityName: *t.DaemonTaskDefinitionArn, + items: v.headerPageItems(i), + }) + } + return params +} + +func (v *daemonTaskDefinitionView) headerPageItems(index int) (items []headerItem) { + t := v.taskDefinitions[index] + + containers := []string{} + for _, c := range t.ContainerDefinitions { + if c.Name != nil { + containers = append(containers, *c.Name) + } + } + + items = []headerItem{ + {name: "Family", value: utils.ShowString(t.Family)}, + {name: "Revision", value: strconv.Itoa(int(t.Revision))}, + {name: "Status", value: string(t.Status)}, + {name: "CPU", value: utils.ShowString(t.Cpu)}, + {name: "Memory", value: utils.ShowString(t.Memory)}, + {name: "Containers", value: strings.Join(containers, ",")}, + {name: "Task role", value: utils.ShowString(t.TaskRoleArn)}, + {name: "Execution role", value: utils.ShowString(t.ExecutionRoleArn)}, + {name: "PID mode", value: string(t.PidMode)}, + {name: "IPC mode", value: string(t.IpcMode)}, + {name: "Registered at", value: utils.ShowTime(t.RegisteredAt)}, + {name: "Registered by", value: utils.ShowString(t.RegisteredBy)}, + } + return +} + +func (v *daemonTaskDefinitionView) tableParamsBuilder() (title string, headers []string, rowsBuilder func() [][]string) { + parent := "" + if v.app.daemonSummary != nil && v.app.daemonSummary.DaemonArn != nil { + parent = utils.ArnToName(v.app.daemonSummary.DaemonArn) + } else if v.app.task != nil && v.app.task.TaskDefinitionArn != nil { + parent = utils.ArnToName(v.app.task.TaskDefinitionArn) + } + title = fmt.Sprintf(color.TableTitleFmt, v.app.kind, parent, len(v.taskDefinitions)) + headers = []string{ + "Revision", + "Status", + "CPU", + "Memory", + "Containers", + "Age", + } + + rowsBuilder = func() (data [][]string) { + for _, t := range v.taskDefinitions { + status := string(t.Status) + cpu := utils.ShowString(t.Cpu) + memory := utils.ShowString(t.Memory) + + row := []string{ + fmt.Sprintf("%s:%d", utils.ShowString(t.Family), t.Revision), + utils.ShowGreenGrey(&status, "active"), + cpu, + memory, + strconv.Itoa(len(t.ContainerDefinitions)), + utils.Age(t.RegisteredAt), + } + data = append(data, row) + + entity := Entity{daemonTaskDefinition: &t, entityName: *t.DaemonTaskDefinitionArn} + v.originalRowReferences = append(v.originalRowReferences, entity) + } + return data + } + return +} diff --git a/internal/view/daemon_task_definition_test.go b/internal/view/daemon_task_definition_test.go new file mode 100644 index 0000000..3cfef72 --- /dev/null +++ b/internal/view/daemon_task_definition_test.go @@ -0,0 +1,109 @@ +package view + +import ( + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ecs/types" +) + +var ( + daemonTdArn1 = "arn:aws:ecs:us-east-1:111111:daemon-task-definition/my-daemon:1" + daemonTdArn2 = "arn:aws:ecs:us-east-1:111111:daemon-task-definition/my-daemon:2" +) + +func getDaemonTaskDefinitionViews() []daemonTaskDefinitionView { + now := time.Now() + + td1 := types.DaemonTaskDefinition{ + DaemonTaskDefinitionArn: aws.String(daemonTdArn1), + Family: aws.String("my-daemon"), + Revision: 1, + Status: types.DaemonTaskDefinitionStatusActive, + Cpu: aws.String("256"), + Memory: aws.String("512"), + RegisteredAt: &now, + ContainerDefinitions: []types.DaemonContainerDefinition{ + {Name: aws.String("agent")}, + }, + } + td2 := types.DaemonTaskDefinition{ + DaemonTaskDefinitionArn: aws.String(daemonTdArn2), + Family: aws.String("my-daemon"), + Revision: 2, + Status: types.DaemonTaskDefinitionStatusActive, + Cpu: aws.String("512"), + Memory: aws.String("1024"), + RegisteredAt: &now, + ContainerDefinitions: []types.DaemonContainerDefinition{ + {Name: aws.String("agent")}, + {Name: aws.String("sidecar")}, + }, + } + + app, _ := newApp(Option{}) + app.cluster = &types.Cluster{ + ClusterName: aws.String(clusterName1), + ClusterArn: aws.String(clusterArn1), + } + app.daemonSummary = &types.DaemonSummary{ + DaemonArn: aws.String(daemonArn1), + } + + v1 := newDaemonTaskDefinitionView([]types.DaemonTaskDefinition{td1, td2}, app) + return []daemonTaskDefinitionView{*v1} +} + +func TestDaemonTaskDefinitionHeaderPageItems(t *testing.T) { + views := getDaemonTaskDefinitionViews() + v := views[0] + + items := v.headerPageItems(0) + if items[0].value != "my-daemon" { + t.Errorf("Family Got: %s, Want: my-daemon", items[0].value) + } + if items[1].value != "1" { + t.Errorf("Revision Got: %s, Want: 1", items[1].value) + } + if items[3].value != "256" { + t.Errorf("CPU Got: %s, Want: 256", items[3].value) + } + + items = v.headerPageItems(1) + if items[1].value != "2" { + t.Errorf("Revision Got: %s, Want: 2", items[1].value) + } + if items[5].value != "agent,sidecar" { + t.Errorf("Containers Got: %s, Want: agent,sidecar", items[5].value) + } +} + +func TestDaemonTaskDefinitionTableParamsBuilder(t *testing.T) { + views := getDaemonTaskDefinitionViews() + v := views[0] + + _, headers, rowsBuilder := v.tableParamsBuilder() + if headers[0] != "Revision" { + t.Errorf("Header[0] Got: %s, Want: Revision", headers[0]) + } + + matrix := rowsBuilder() + if len(matrix) != 2 { + t.Fatalf("Rows Got: %d, Want: 2", len(matrix)) + } + // First row: my-daemon:1 + if matrix[0][0] != "my-daemon:1" { + t.Errorf("Revision Got: %s, Want: my-daemon:1", matrix[0][0]) + } + if matrix[0][2] != "256" { + t.Errorf("CPU Got: %s, Want: 256", matrix[0][2]) + } + // Second row: my-daemon:2 + if matrix[1][0] != "my-daemon:2" { + t.Errorf("Revision Got: %s, Want: my-daemon:2", matrix[1][0]) + } + if matrix[1][4] != "2" { + t.Errorf("Containers Got: %s, Want: 2", matrix[1][4]) + } +} diff --git a/internal/view/daemon_test.go b/internal/view/daemon_test.go new file mode 100644 index 0000000..e2052ed --- /dev/null +++ b/internal/view/daemon_test.go @@ -0,0 +1,113 @@ +package view + +import ( + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ecs/types" + "github.com/keidarcy/e1s/internal/utils" +) + +var ( + daemonArn1 = "arn:aws:ecs:us-east-1:111111:daemon/cluster1/my-daemon-1" + daemonArn2 = "arn:aws:ecs:us-east-1:111111:daemon/cluster1/my-daemon-2" +) + +func getDaemonViews() []daemonView { + now := time.Now() + + daemon1 := types.DaemonSummary{ + DaemonArn: aws.String(daemonArn1), + Status: types.DaemonStatusActive, + CreatedAt: &now, + UpdatedAt: &now, + } + daemon2 := types.DaemonSummary{ + DaemonArn: aws.String(daemonArn2), + Status: types.DaemonStatusDeleteInProgress, + CreatedAt: &now, + UpdatedAt: &now, + } + + app, _ := newApp(Option{}) + app.cluster = &types.Cluster{ + ClusterName: aws.String(clusterName1), + ClusterArn: aws.String(clusterArn1), + } + + v1 := newDaemonView([]types.DaemonSummary{daemon1}, app) + v2 := newDaemonView([]types.DaemonSummary{daemon2}, app) + + return []daemonView{*v1, *v2} +} + +func TestDaemonHeaderPageItems(t *testing.T) { + views := getDaemonViews() + + testCases := []struct { + name string + view daemonView + wantDaemon string + wantStatus string + }{ + { + name: "daemon1", + view: views[0], + wantDaemon: utils.ArnToName(&daemonArn1), + wantStatus: "ACTIVE", + }, + { + name: "daemon2", + view: views[1], + wantDaemon: utils.ArnToName(&daemonArn2), + wantStatus: "DELETE_IN_PROGRESS", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + items := tc.view.headerPageItems(0) + if items[0].value != tc.wantDaemon { + t.Errorf("Daemon Got: %s, Want: %s", items[0].value, tc.wantDaemon) + } + if items[1].value != tc.wantStatus { + t.Errorf("Status Got: %s, Want: %s", items[1].value, tc.wantStatus) + } + }) + } +} + +func TestDaemonTableParamsBuilder(t *testing.T) { + views := getDaemonViews() + + testCases := []struct { + name string + view daemonView + wantDaemon string + }{ + { + name: "daemon1", + view: views[0], + wantDaemon: utils.ArnToName(&daemonArn1), + }, + { + name: "daemon2", + view: views[1], + wantDaemon: utils.ArnToName(&daemonArn2), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, headers, rowsBuilder := tc.view.tableParamsBuilder() + if headers[0] != "Daemon" { + t.Errorf("Header[0] Got: %s, Want: Daemon", headers[0]) + } + matrix := rowsBuilder() + if matrix[0][0] != tc.wantDaemon { + t.Errorf("Name Got: %s, Want: %s", matrix[0][0], tc.wantDaemon) + } + }) + } +} diff --git a/internal/view/footer.go b/internal/view/footer.go index 2a99b07..95ad283 100644 --- a/internal/view/footer.go +++ b/internal/view/footer.go @@ -10,34 +10,38 @@ import ( // View footer struct type footer struct { - footerFlex *tview.Flex - cluster *tview.TextView - service *tview.TextView - task *tview.TextView - container *tview.TextView - profile *tview.TextView - region *tview.TextView - instance *tview.TextView - taskDefinition *tview.TextView - serviceDeployment *tview.TextView - help *tview.TextView + footerFlex *tview.Flex + cluster *tview.TextView + service *tview.TextView + task *tview.TextView + container *tview.TextView + profile *tview.TextView + region *tview.TextView + instance *tview.TextView + taskDefinition *tview.TextView + daemon *tview.TextView + daemonTaskDefinition *tview.TextView + serviceDeployment *tview.TextView + help *tview.TextView } func newFooter() *footer { footerFlex := tview.NewFlex().SetDirection(tview.FlexColumn) footerFlex.SetBackgroundColor(color.Color(theme.BgColor)) return &footer{ - footerFlex: footerFlex, - cluster: tview.NewTextView().SetDynamicColors(true).SetText(fmt.Sprintf(color.FooterItemFmt, ClusterKind)), - service: tview.NewTextView().SetDynamicColors(true).SetText(fmt.Sprintf(color.FooterItemFmt, ServiceKind)), - task: tview.NewTextView().SetDynamicColors(true).SetText(fmt.Sprintf(color.FooterItemFmt, TaskKind)), - container: tview.NewTextView().SetDynamicColors(true).SetText(fmt.Sprintf(color.FooterItemFmt, ContainerKind)), - profile: tview.NewTextView().SetDynamicColors(true).SetText(fmt.Sprintf(color.FooterItemFmt, ProfileKind)), - region: tview.NewTextView().SetDynamicColors(true).SetText(fmt.Sprintf(color.FooterItemFmt, RegionKind)), - instance: tview.NewTextView().SetDynamicColors(true).SetText(fmt.Sprintf(color.FooterItemFmt, InstanceKind)).SetTextAlign(L), - taskDefinition: tview.NewTextView().SetDynamicColors(true).SetText(fmt.Sprintf(color.FooterItemFmt, TaskDefinitionKind)).SetTextAlign(L), - serviceDeployment: tview.NewTextView().SetDynamicColors(true).SetText(fmt.Sprintf(color.FooterItemFmt, ServiceDeploymentKind)).SetTextAlign(L), - help: tview.NewTextView().SetDynamicColors(true).SetText(fmt.Sprintf(color.FooterItemFmt, HelpKind)).SetTextAlign(L), + footerFlex: footerFlex, + cluster: tview.NewTextView().SetDynamicColors(true).SetText(fmt.Sprintf(color.FooterItemFmt, ClusterKind)), + service: tview.NewTextView().SetDynamicColors(true).SetText(fmt.Sprintf(color.FooterItemFmt, ServiceKind)), + task: tview.NewTextView().SetDynamicColors(true).SetText(fmt.Sprintf(color.FooterItemFmt, TaskKind)), + container: tview.NewTextView().SetDynamicColors(true).SetText(fmt.Sprintf(color.FooterItemFmt, ContainerKind)), + profile: tview.NewTextView().SetDynamicColors(true).SetText(fmt.Sprintf(color.FooterItemFmt, ProfileKind)), + region: tview.NewTextView().SetDynamicColors(true).SetText(fmt.Sprintf(color.FooterItemFmt, RegionKind)), + instance: tview.NewTextView().SetDynamicColors(true).SetText(fmt.Sprintf(color.FooterItemFmt, InstanceKind)).SetTextAlign(L), + taskDefinition: tview.NewTextView().SetDynamicColors(true).SetText(fmt.Sprintf(color.FooterItemFmt, TaskDefinitionKind)).SetTextAlign(L), + daemon: tview.NewTextView().SetDynamicColors(true).SetText(fmt.Sprintf(color.FooterItemFmt, DaemonKind)).SetTextAlign(L), + daemonTaskDefinition: tview.NewTextView().SetDynamicColors(true).SetText(fmt.Sprintf(color.FooterItemFmt, DaemonTaskDefinitionKind)).SetTextAlign(L), + serviceDeployment: tview.NewTextView().SetDynamicColors(true).SetText(fmt.Sprintf(color.FooterItemFmt, ServiceDeploymentKind)).SetTextAlign(L), + help: tview.NewTextView().SetDynamicColors(true).SetText(fmt.Sprintf(color.FooterItemFmt, HelpKind)).SetTextAlign(L), } } func (v *view) addFooterItems() { @@ -52,6 +56,14 @@ func (v *view) addFooterItems() { v.footer.footerFlex. AddItem(tview.NewTextView(), 5, 0, false). AddItem(v.footer.taskDefinition, 0, 1, false) + } else if v.app.kind == DaemonKind { + v.footer.footerFlex. + AddItem(tview.NewTextView(), 5, 0, false). + AddItem(v.footer.daemon, 0, 1, false) + } else if v.app.kind == DaemonTaskDefinitionKind { + v.footer.footerFlex. + AddItem(tview.NewTextView(), 5, 0, false). + AddItem(v.footer.daemonTaskDefinition, 0, 1, false) } else if v.app.kind == InstanceKind { v.footer.footerFlex. AddItem(tview.NewTextView(), 5, 0, false). diff --git a/internal/view/header.go b/internal/view/header.go index 405c7ee..0c11265 100644 --- a/internal/view/header.go +++ b/internal/view/header.go @@ -28,6 +28,7 @@ var hotKeyMap = map[string]keyDescriptionPair{ "p": {key: "p", description: "Show service deployments"}, "n": {key: "n", description: "Show related EC2 instances"}, "N": {key: "shift-n", description: "Show all cluster tasks"}, + "M": {key: "shift-m", description: "Show managed daemons"}, "s": {key: "s", description: "Shell access"}, "x": {key: "x", description: "Toggle running/stopped tasks"}, "w": {key: "w", description: "Show service events"}, diff --git a/internal/view/json.go b/internal/view/json.go index 81504b7..497a6cb 100644 --- a/internal/view/json.go +++ b/internal/view/json.go @@ -251,6 +251,10 @@ func (v *view) getJsonString(entity Entity) (string, []byte, error) { data = entity.container case entity.taskDefinition != nil && v.app.kind == TaskDefinitionKind: data = entity.taskDefinition + case entity.daemonSummary != nil && v.app.kind == DaemonKind: + data = entity.daemonSummary + case entity.daemonTaskDefinition != nil && v.app.kind == DaemonTaskDefinitionKind: + data = entity.daemonTaskDefinition case entity.metrics != nil: data = entity.metrics case entity.autoScaling != nil: diff --git a/internal/view/kind.go b/internal/view/kind.go index 7da32b9..46aeda5 100644 --- a/internal/view/kind.go +++ b/internal/view/kind.go @@ -9,6 +9,8 @@ const ( InstanceKind ContainerKind TaskDefinitionKind + DaemonKind + DaemonTaskDefinitionKind HelpKind DescriptionKind ServiceEventsKind @@ -38,6 +40,10 @@ func (k kind) String() string { return "description" case TaskDefinitionKind: return "task definitions" + case DaemonKind: + return "daemons" + case DaemonTaskDefinitionKind: + return "daemon task definitions" case InstanceKind: return "instances" case ServiceEventsKind: @@ -71,6 +77,8 @@ func (k kind) nextKind() kind { return ContainerKind case ContainerKind: return ContainerKind + case DaemonKind: + return DaemonTaskDefinitionKind default: return ClusterKind } @@ -86,6 +94,10 @@ func (k kind) prevKind() kind { return RegionKind case ServiceKind: return ClusterKind + case DaemonKind: + return ClusterKind + case DaemonTaskDefinitionKind: + return DaemonKind case TaskKind, TaskDefinitionKind, ServiceDeploymentKind: return ServiceKind case ContainerKind: @@ -103,7 +115,7 @@ func (k kind) getAppPageName(name string) string { return k.String() case ClusterKind: return prefix + "." + k.String() - case ServiceKind, TaskKind, ContainerKind, TaskDefinitionKind, ServiceDeploymentKind, DescriptionKind, InstanceKind: + case ServiceKind, TaskKind, ContainerKind, TaskDefinitionKind, ServiceDeploymentKind, DescriptionKind, InstanceKind, DaemonKind, DaemonTaskDefinitionKind: return prefix + "." + k.String() + "." + name default: return prefix + "." + k.String() diff --git a/internal/view/table.go b/internal/view/table.go index 434cb4e..5b7477d 100644 --- a/internal/view/table.go +++ b/internal/view/table.go @@ -170,7 +170,7 @@ func (v *view) handleSelected(row, column int) { return } - if v.app.kind == TaskDefinitionKind || v.app.kind == InstanceKind { + if v.app.kind == TaskDefinitionKind || v.app.kind == InstanceKind || v.app.kind == DaemonTaskDefinitionKind { return } if v.app.kind == ContainerKind { @@ -214,10 +214,23 @@ func (v *view) handleInputCapture(event *tcell.EventKey) *tcell.EventKey { return event } case 't': - if v.app.kind == ServiceKind || v.app.kind == TaskKind { + if v.app.kind == ServiceKind { v.showKindPage(TaskDefinitionKind, false) return event } + if v.app.kind == TaskKind { + // Check if selected task belongs to a daemon (group starts with "daemon:") + if v.app.task != nil && v.app.task.Group != nil && strings.HasPrefix(*v.app.task.Group, "daemon:") { + v.showKindPage(DaemonTaskDefinitionKind, false) + } else { + v.showKindPage(TaskDefinitionKind, false) + } + return event + } + if v.app.kind == DaemonKind { + v.showKindPage(DaemonTaskDefinitionKind, false) + return event + } case 'p': if v.app.kind == ServiceKind { v.showKindPage(ServiceDeploymentKind, false) @@ -271,6 +284,11 @@ func (v *view) handleInputCapture(event *tcell.EventKey) *tcell.EventKey { v.showKindPage(TaskKind, false) return event } + case 'M': + if v.app.kind == ClusterKind { + v.showKindPage(DaemonKind, false) + return event + } case 'n': if v.app.kind == ClusterKind { v.app.fromCluster = true @@ -487,6 +505,24 @@ func (v *view) changeSelectedValues() { slog.Warn("unexpected in changeSelectedValues", "kind", v.app.kind) return } + case DaemonKind: + daemon := selected.daemonSummary + if daemon != nil { + v.app.daemonSummary = daemon + v.app.entityName = *daemon.DaemonArn + } else { + slog.Warn("unexpected in changeSelectedValues", "kind", v.app.kind) + return + } + case DaemonTaskDefinitionKind: + dtd := selected.daemonTaskDefinition + if dtd != nil { + v.app.daemonTaskDefinition = dtd + v.app.entityName = *dtd.DaemonTaskDefinitionArn + } else { + slog.Warn("unexpected in changeSelectedValues", "kind", v.app.kind) + return + } default: v.app.back() } diff --git a/tests/daemon.tf b/tests/daemon.tf new file mode 100644 index 0000000..7a8660e --- /dev/null +++ b/tests/daemon.tf @@ -0,0 +1,217 @@ +####################### +# ECS Managed Daemons +####################### + +locals { + daemon_name = "e1s-daemon" +} + +resource "aws_ecs_cluster" "daemon" { + name = "${local.daemon_name}-cluster" +} + +resource "aws_iam_role" "ecs_infrastructure" { + name = "${local.daemon_name}-infra-role" + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "ecs.amazonaws.com" + } + }] + }) +} + +resource "aws_iam_role_policy_attachment" "ecs_infrastructure" { + role = aws_iam_role.ecs_infrastructure.name + policy_arn = "arn:aws:iam::aws:policy/AmazonECSInfrastructureRolePolicyForManagedInstances" +} + +resource "aws_iam_role_policy" "ecs_infrastructure_pass_role" { + name = "pass-role" + role = aws_iam_role.ecs_infrastructure.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "iam:PassRole" + Resource = aws_iam_role.daemon_instance.arn + }] + }) +} + +resource "aws_iam_role" "daemon_instance" { + name = "${local.daemon_name}-instance-role" + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "ec2.amazonaws.com" + } + }] + }) +} + +resource "aws_iam_role_policy_attachment" "daemon_instance_ecs" { + role = aws_iam_role.daemon_instance.name + policy_arn = "arn:aws:iam::aws:policy/AmazonECSInstanceRolePolicyForManagedInstances" +} + +resource "aws_iam_role_policy_attachment" "daemon_instance_ssm" { + role = aws_iam_role.daemon_instance.name + policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" +} + +resource "aws_iam_instance_profile" "daemon" { + name = "${local.daemon_name}-instance-profile" + role = aws_iam_role.daemon_instance.name +} + +resource "aws_ecs_capacity_provider" "daemon" { + name = "${local.daemon_name}-cp" + cluster = aws_ecs_cluster.daemon.name + + managed_instances_provider { + infrastructure_role_arn = aws_iam_role.ecs_infrastructure.arn + + instance_launch_template { + ec2_instance_profile_arn = aws_iam_instance_profile.daemon.arn + monitoring = "BASIC" + + network_configuration { + subnets = aws_subnet.private[*].id + security_groups = [aws_security_group.ecs.id] + } + } + } + + # Managed Instances need outbound internet during deprovisioning to allow the + # ECS agent to drain tasks and deregister. On destroy, Terraform must keep the + # full network path (IGW -> public route -> NAT -> private route -> association) + # and IAM roles alive until the capacity provider is fully deleted. + depends_on = [ + aws_iam_role_policy_attachment.ecs_infrastructure, + aws_iam_role_policy.ecs_infrastructure_pass_role, + aws_iam_role_policy_attachment.daemon_instance_ecs, + aws_iam_role_policy_attachment.daemon_instance_ssm, + aws_internet_gateway.main, + aws_route.public, + aws_route_table_association.public_1a, + aws_nat_gateway.nat, + aws_route.private_1a, + aws_route_table_association.private, + ] +} + +resource "aws_ecs_cluster_capacity_providers" "daemon" { + cluster_name = aws_ecs_cluster.daemon.name + capacity_providers = [aws_ecs_capacity_provider.daemon.name] +} + +resource "aws_ecs_daemon_task_definition" "main" { + family = "${local.daemon_name}-monitoring" + cpu = "256" + memory = "512" + task_role_arn = aws_iam_role.task_role.arn + execution_role_arn = aws_iam_role.task_role.arn + + container_definition { + name = "monitoring-agent" + image = "public.ecr.aws/docker/library/busybox:latest" + essential = true + command = ["sh", "-c", "while true; do echo 'Daemon running'; sleep 30; done"] + memory_reservation = 128 + + log_configuration { + log_driver = "awslogs" + options = { + "awslogs-group" = aws_cloudwatch_log_group.daemon.name + "awslogs-region" = "us-east-1" + "awslogs-stream-prefix" = "daemon" + } + } + } +} + +resource "aws_ecs_daemon" "main" { + name = local.daemon_name + cluster_arn = aws_ecs_cluster.daemon.arn + daemon_task_definition_arn = aws_ecs_daemon_task_definition.main.arn + enable_execute_command = true + + capacity_provider_arns = [aws_ecs_capacity_provider.daemon.arn] + + depends_on = [ + aws_ecs_cluster_capacity_providers.daemon, + aws_internet_gateway.main, + aws_route.public, + aws_route_table_association.public_1a, + aws_nat_gateway.nat, + aws_route.private_1a, + aws_route_table_association.private, + ] +} + +####################### +# Nginx service on Managed Instances +####################### +resource "aws_ecs_task_definition" "daemon_nginx" { + family = "${local.daemon_name}-nginx" + task_role_arn = aws_iam_role.task_role.arn + execution_role_arn = aws_iam_role.task_role.arn + requires_compatibilities = ["EC2"] + network_mode = "awsvpc" + cpu = "256" + memory = "512" + + container_definitions = jsonencode([{ + name = "nginx" + image = "nginx:alpine" + essential = true + portMappings = [{ containerPort = 80 }] + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = aws_cloudwatch_log_group.daemon.name + "awslogs-region" = "us-east-1" + "awslogs-stream-prefix" = "nginx" + } + } + }]) +} + +resource "aws_ecs_service" "daemon_nginx" { + name = "${local.daemon_name}-nginx" + cluster = aws_ecs_cluster.daemon.id + task_definition = aws_ecs_task_definition.daemon_nginx.arn + desired_count = 1 + enable_execute_command = true + + capacity_provider_strategy { + capacity_provider = aws_ecs_capacity_provider.daemon.name + weight = 1 + base = 1 + } + + network_configuration { + subnets = aws_subnet.private[*].id + security_groups = [aws_security_group.ecs.id] + } + + depends_on = [ + aws_ecs_cluster_capacity_providers.daemon, + aws_internet_gateway.main, + aws_route.public, + aws_route_table_association.public_1a, + aws_nat_gateway.nat, + aws_route.private_1a, + aws_route_table_association.private, + ] +} +resource "aws_cloudwatch_log_group" "daemon" { + name = "/aws/ecs/${local.daemon_name}" +} diff --git a/tests/providers.tf b/tests/providers.tf index 96b5aae..070c65f 100644 --- a/tests/providers.tf +++ b/tests/providers.tf @@ -4,7 +4,7 @@ terraform { required_providers { aws = { source = "hashicorp/aws" - version = ">= 4.55" + version = ">= 6.50" } } } From efc0a762d41d2d1bd9fefce7d68dd2ae8c7835fb Mon Sep 17 00:00:00 2001 From: Xing Yahao <48758247+keidarcy@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:46:13 +0900 Subject: [PATCH 2/2] remove deamon requests in cluster and align task definition impl --- internal/api/daemon.go | 8 +-- internal/utils/utils.go | 38 +++++++++++++ internal/utils/utils_test.go | 48 +++++++++++++++++ internal/view/cluster.go | 21 ++------ internal/view/cluster_test.go | 4 +- internal/view/daemon_task_definition.go | 32 ++++++----- internal/view/daemon_task_definition_test.go | 11 ++-- internal/view/table.go | 8 +++ internal/view/task_definition.go | 57 ++++++++++++-------- 9 files changed, 165 insertions(+), 62 deletions(-) diff --git a/internal/api/daemon.go b/internal/api/daemon.go index f07455e..4bae52b 100644 --- a/internal/api/daemon.go +++ b/internal/api/daemon.go @@ -4,6 +4,7 @@ import ( "context" "log/slog" + "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ecs" "github.com/aws/aws-sdk-go-v2/service/ecs/types" ) @@ -58,11 +59,10 @@ func (store *Store) DescribeDaemon(daemonArn *string) (*types.DaemonDetail, erro // aws ecs list-daemon-task-definitions --family ${family} // aws ecs describe-daemon-task-definition --daemon-task-definition ${arn} func (store *Store) ListDaemonTaskDefinitions(family *string) ([]types.DaemonTaskDefinition, error) { - limit := int32(20) output, err := store.ecs.ListDaemonTaskDefinitions(context.Background(), &ecs.ListDaemonTaskDefinitionsInput{ - Family: family, - MaxResults: &limit, - Sort: types.SortOrderDesc, + Family: family, + MaxResults: aws.Int32(MaxTaskDefinitionRevision), + Sort: types.SortOrderDesc, }) if err != nil { slog.Warn("failed to run aws api to list daemon task definitions", "error", err) diff --git a/internal/utils/utils.go b/internal/utils/utils.go index b2c0530..2c6573f 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -25,6 +25,8 @@ const ( serviceURLFmt = clusterFmt + serviceFmt + regionFmt taskURLFmt = clusterFmt + serviceFmt + taskFmt + regionFmt taskDefinitionURLFmt = "https://%s.console.aws.amazon.com/ecs/v2/task-definitions/%s/%s/containers?region=%s" + daemonURLFmt = clusterFmt + "/daemons/%s/health" + regionFmt + daemonTaskDefinitionFmt = "https://%s.console.aws.amazon.com/ecs/v2/daemon-task-definitions/%s/%s/containers?region=%s" serviceDeploymentURLFmt = "https://%s.console.aws.amazon.com/ecs/v2/clusters/%s/services/%s/service-deployments/%s?region=%s" ) @@ -96,6 +98,10 @@ func ShowGreenGrey(inputStr *string, greenStr string) string { // TaskARN not contains service but need service name as second argument func ArnToUrl(arn string, taskService string) string { components := strings.Split(arn, ":") + if len(components) < 6 { + return "" + } + resources := components[len(components)-1] names := strings.Split(resources, "/") _, err := strconv.Atoi(resources) @@ -105,6 +111,9 @@ func ArnToUrl(arn string, taskService string) string { names = strings.Split(resources, "/") names = append(names, components[len(components)-1]) } + if len(names) == 0 { + return "" + } region := components[3] clusterName := "" @@ -113,25 +122,54 @@ func ArnToUrl(arn string, taskService string) string { switch names[0] { case "cluster": + if len(names) < 2 { + return "" + } clusterName = names[1] return fmt.Sprintf(clusterURLFmt, region, clusterName, region) case "service": + if len(names) < 3 { + return "" + } clusterName = names[1] serviceName = names[2] return fmt.Sprintf(serviceURLFmt, region, clusterName, serviceName, region) case "service-deployment": + if len(names) < 4 { + return "" + } clusterName = names[1] serviceName = names[2] deploymentId := names[3] return fmt.Sprintf(serviceDeploymentURLFmt, region, clusterName, serviceName, deploymentId, region) case "task", "container": + if len(names) < 3 { + return "" + } clusterName = names[1] taskName = names[2] return fmt.Sprintf(taskURLFmt, region, clusterName, taskService, taskName, region) case "task-definition": + if len(names) < 3 { + return "" + } taskDefName := names[1] revision := names[2] return fmt.Sprintf(taskDefinitionURLFmt, region, taskDefName, revision, region) + case "daemon": + if len(names) < 3 { + return "" + } + clusterName = names[1] + daemonName := names[2] + return fmt.Sprintf(daemonURLFmt, region, clusterName, daemonName, region) + case "daemon-task-definition": + if len(names) < 3 { + return "" + } + taskDefName := names[1] + revision := names[2] + return fmt.Sprintf(daemonTaskDefinitionFmt, region, taskDefName, revision, region) default: return "" } diff --git a/internal/utils/utils_test.go b/internal/utils/utils_test.go index 50008d5..6e7fcb4 100644 --- a/internal/utils/utils_test.go +++ b/internal/utils/utils_test.go @@ -12,6 +12,8 @@ const ( serviceArnFmt = "arn:aws:ecs:%s:111111:service/%s/%s" taskArnFmt = "arn:aws:ecs:%s:111111:task/%s/%s" taskDefinitionArnFmt = "arn:aws:ecs:%s:111111:task-definition/%s:%s" + daemonArnFmt = "arn:aws:ecs:%s:111111:daemon/%s/%s" + daemonTdArnFmt = "arn:aws:ecs:%s:111111:daemon-task-definition/%s:%s" ) func TestArnToURL(t *testing.T) { @@ -25,6 +27,8 @@ func TestArnToURL(t *testing.T) { taskService1 := "taskService1" taskDef1 := "my-task-def" revision1 := "1" + daemon1 := "daemon1" + daemonTaskDef1 := "my-daemon-task-def" arn1 := fmt.Sprintf(clusterArnFmt, testRegion, cluster1) url1 := fmt.Sprintf(clusterURLFmt, testRegion, cluster1, testRegion) arn2 := fmt.Sprintf(serviceArnFmt, testRegion, cluster1, service1) @@ -33,6 +37,10 @@ func TestArnToURL(t *testing.T) { url3 := fmt.Sprintf(taskURLFmt, testRegion, cluster1, taskService1, task1, testRegion) arn4 := fmt.Sprintf(taskDefinitionArnFmt, testRegion, taskDef1, revision1) url4 := fmt.Sprintf(taskDefinitionURLFmt, testRegion, taskDef1, revision1, testRegion) + arn5 := fmt.Sprintf(daemonArnFmt, testRegion, cluster1, daemon1) + url5 := fmt.Sprintf(daemonURLFmt, testRegion, cluster1, daemon1, testRegion) + arn6 := fmt.Sprintf(daemonTdArnFmt, testRegion, daemonTaskDef1, revision1) + url6 := fmt.Sprintf(daemonTaskDefinitionFmt, testRegion, daemonTaskDef1, revision1, testRegion) testCases := []struct { name string @@ -71,6 +79,46 @@ func TestArnToURL(t *testing.T) { }, want: url4, }, + { + name: "daemon arn convert", + args: Args{ + arn: arn5, + taskService: "", + }, + want: url5, + }, + { + name: "daemon task definition arn convert", + args: Args{ + arn: arn6, + taskService: "", + }, + want: url6, + }, + { + name: "empty arn", + args: Args{ + arn: "", + taskService: "", + }, + want: "", + }, + { + name: "malformed arn", + args: Args{ + arn: "not-an-arn", + taskService: "", + }, + want: "", + }, + { + name: "incomplete cluster arn", + args: Args{ + arn: "arn:aws:ecs:us-east-1:111111:cluster", + taskService: "", + }, + want: "", + }, } for _, tc := range testCases { diff --git a/internal/view/cluster.go b/internal/view/cluster.go index 3fd0964..38cebf8 100644 --- a/internal/view/cluster.go +++ b/internal/view/cluster.go @@ -13,11 +13,10 @@ import ( type clusterView struct { view - clusters []types.Cluster - daemonCounts map[string]int + clusters []types.Cluster } -func newClusterView(clusters []types.Cluster, daemonCounts map[string]int, app *App) *clusterView { +func newClusterView(clusters []types.Cluster, app *App) *clusterView { keys := append(basicKeyInputs, []keyDescriptionPair{ hotKeyMap["n"], hotKeyMap["N"], @@ -27,8 +26,7 @@ func newClusterView(clusters []types.Cluster, daemonCounts map[string]int, app * view: *newView(app, keys, secondaryPageKeyMap{ DescriptionKind: describePageKeys, }), - clusters: clusters, - daemonCounts: daemonCounts, + clusters: clusters, } } @@ -47,14 +45,7 @@ func (app *App) showClustersPage(reload bool) error { resources, err = app.Store.ListClusters() } err = buildResourcePage(resources, app, err, func() resourceViewBuilder { - daemonCounts := make(map[string]int) - for _, c := range resources { - daemons, dErr := app.Store.ListDaemons(c.ClusterArn) - if dErr == nil { - daemonCounts[*c.ClusterArn] = len(daemons) - } - } - return newClusterView(resources, daemonCounts, app) + return newClusterView(resources, app) }) return err } @@ -151,7 +142,6 @@ func (v *clusterView) tableParamsBuilder() (title string, headers []string, rows "Name", "Status", "Services", - "Daemons", "Tasks", "Container instances", "Capacity providers", @@ -162,13 +152,10 @@ func (v *clusterView) tableParamsBuilder() (title string, headers []string, rows // calculate tasks tasks := fmt.Sprintf(color.TableClusterTasksFmt, c.PendingTasksCount, c.RunningTasksCount) - daemonCount := v.daemonCounts[*c.ClusterArn] - row := []string{} row = append(row, utils.ShowString(c.ClusterName)) row = append(row, utils.ShowGreenGrey(c.Status, "active")) row = append(row, utils.ShowInt(&c.ActiveServicesCount)) - row = append(row, strconv.Itoa(daemonCount)) row = append(row, tasks) row = append(row, utils.ShowInt(&c.RegisteredContainerInstancesCount)+" EC2") row = append(row, utils.ShowArray(c.CapacityProviders)) diff --git a/internal/view/cluster_test.go b/internal/view/cluster_test.go index c66ae9a..7bbc3c0 100644 --- a/internal/view/cluster_test.go +++ b/internal/view/cluster_test.go @@ -45,8 +45,8 @@ func getClusterViews() []clusterView { cluster2.ActiveServicesCount = *clusterServices2 app, _ := newApp(Option{}) - clusterView1 := newClusterView([]types.Cluster{cluster1}, map[string]int{}, app) - clusterView2 := newClusterView([]types.Cluster{cluster2}, map[string]int{}, app) + clusterView1 := newClusterView([]types.Cluster{cluster1}, app) + clusterView2 := newClusterView([]types.Cluster{cluster2}, app) return []clusterView{*clusterView1, *clusterView2} } diff --git a/internal/view/daemon_task_definition.go b/internal/view/daemon_task_definition.go index 4057233..67d0691 100644 --- a/internal/view/daemon_task_definition.go +++ b/internal/view/daemon_task_definition.go @@ -105,16 +105,19 @@ func (v *daemonTaskDefinitionView) headerPageItems(index int) (items []headerIte } func (v *daemonTaskDefinitionView) tableParamsBuilder() (title string, headers []string, rowsBuilder func() [][]string) { - parent := "" + parent, td := "", "" if v.app.daemonSummary != nil && v.app.daemonSummary.DaemonArn != nil { parent = utils.ArnToName(v.app.daemonSummary.DaemonArn) + td = *v.app.daemonSummary.DaemonArn } else if v.app.task != nil && v.app.task.TaskDefinitionArn != nil { parent = utils.ArnToName(v.app.task.TaskDefinitionArn) + td = *v.app.task.TaskDefinitionArn } title = fmt.Sprintf(color.TableTitleFmt, v.app.kind, parent, len(v.taskDefinitions)) headers = []string{ "Revision", "Status", + "In use", "CPU", "Memory", "Containers", @@ -123,18 +126,23 @@ func (v *daemonTaskDefinitionView) tableParamsBuilder() (title string, headers [ rowsBuilder = func() (data [][]string) { for _, t := range v.taskDefinitions { - status := string(t.Status) - cpu := utils.ShowString(t.Cpu) - memory := utils.ShowString(t.Memory) - - row := []string{ - fmt.Sprintf("%s:%d", utils.ShowString(t.Family), t.Revision), - utils.ShowGreenGrey(&status, "active"), - cpu, - memory, - strconv.Itoa(len(t.ContainerDefinitions)), - utils.Age(t.RegisteredAt), + inUse := "-" + if td == *t.DaemonTaskDefinitionArn { + inUse = "Yes" } + cpu, memory := taskDefinitionResources(t.Cpu, t.Memory, len(t.ContainerDefinitions), func(i int) (int32, *int32) { + c := t.ContainerDefinitions[i] + return c.Cpu, c.Memory + }) + + row := []string{} + row = append(row, utils.ArnToName(t.DaemonTaskDefinitionArn)) + row = append(row, string(t.Status)) + row = append(row, utils.ShowGreenGrey(&inUse, "yes")) + row = append(row, cpu) + row = append(row, memory) + row = append(row, strconv.Itoa(len(t.ContainerDefinitions))) + row = append(row, utils.Age(t.RegisteredAt)) data = append(data, row) entity := Entity{daemonTaskDefinition: &t, entityName: *t.DaemonTaskDefinitionArn} diff --git a/internal/view/daemon_task_definition_test.go b/internal/view/daemon_task_definition_test.go index 3cfef72..9fb8092 100644 --- a/internal/view/daemon_task_definition_test.go +++ b/internal/view/daemon_task_definition_test.go @@ -87,6 +87,9 @@ func TestDaemonTaskDefinitionTableParamsBuilder(t *testing.T) { if headers[0] != "Revision" { t.Errorf("Header[0] Got: %s, Want: Revision", headers[0]) } + if headers[2] != "In use" { + t.Errorf("Header[2] Got: %s, Want: In use", headers[2]) + } matrix := rowsBuilder() if len(matrix) != 2 { @@ -96,14 +99,14 @@ func TestDaemonTaskDefinitionTableParamsBuilder(t *testing.T) { if matrix[0][0] != "my-daemon:1" { t.Errorf("Revision Got: %s, Want: my-daemon:1", matrix[0][0]) } - if matrix[0][2] != "256" { - t.Errorf("CPU Got: %s, Want: 256", matrix[0][2]) + if matrix[0][3] != "256" { + t.Errorf("CPU Got: %s, Want: 256", matrix[0][3]) } // Second row: my-daemon:2 if matrix[1][0] != "my-daemon:2" { t.Errorf("Revision Got: %s, Want: my-daemon:2", matrix[1][0]) } - if matrix[1][4] != "2" { - t.Errorf("Containers Got: %s, Want: 2", matrix[1][4]) + if matrix[1][5] != "2" { + t.Errorf("Containers Got: %s, Want: 2", matrix[1][5]) } } diff --git a/internal/view/table.go b/internal/view/table.go index 5b7477d..1d6c144 100644 --- a/internal/view/table.go +++ b/internal/view/table.go @@ -550,6 +550,14 @@ func (v *view) openInBrowser() { arn = *v.app.task.TaskArn case TaskDefinitionKind: arn = *v.app.taskDefinition.TaskDefinitionArn + case DaemonKind: + if selected.daemonSummary != nil && selected.daemonSummary.DaemonArn != nil { + arn = *selected.daemonSummary.DaemonArn + } + case DaemonTaskDefinitionKind: + if selected.daemonTaskDefinition != nil && selected.daemonTaskDefinition.DaemonTaskDefinitionArn != nil { + arn = *selected.daemonTaskDefinition.DaemonTaskDefinitionArn + } case ServiceDeploymentKind: arn = *v.app.serviceDeployment.ServiceDeploymentArn } diff --git a/internal/view/task_definition.go b/internal/view/task_definition.go index 8c835f4..f9234c3 100644 --- a/internal/view/task_definition.go +++ b/internal/view/task_definition.go @@ -136,9 +136,11 @@ func (v *taskDefinitionView) tableParamsBuilder() (title string, headers []strin title = fmt.Sprintf(color.TableTitleFmt, v.app.kind, serviceName, len(v.taskDefinitions)) headers = []string{ "Revision", + "Status", "In use", "CPU", "Memory", + "Containers", "Age", } @@ -149,35 +151,18 @@ func (v *taskDefinitionView) tableParamsBuilder() (title string, headers []strin inUse = "Yes" } - var cpu string - if t.Cpu == nil { - sum := 0 - for _, c := range t.ContainerDefinitions { - sum += int(c.Cpu) - } - cpu = strconv.Itoa(sum) - } else { - cpu = *t.Cpu - } - - var memory string - if t.Memory == nil { - sum := 0 - for _, c := range t.ContainerDefinitions { - if c.Memory != nil { - sum += int(*c.Memory) - } - } - memory = strconv.Itoa(sum) - } else { - memory = *t.Memory - } + cpu, memory := taskDefinitionResources(t.Cpu, t.Memory, len(t.ContainerDefinitions), func(i int) (int32, *int32) { + c := t.ContainerDefinitions[i] + return c.Cpu, c.Memory + }) row := []string{} row = append(row, utils.ArnToName(t.TaskDefinitionArn)) + row = append(row, string(t.Status)) row = append(row, utils.ShowGreenGrey(&inUse, "yes")) row = append(row, cpu) row = append(row, memory) + row = append(row, strconv.Itoa(len(t.ContainerDefinitions))) row = append(row, utils.Age(t.RegisteredAt)) data = append(data, row) @@ -189,3 +174,29 @@ func (v *taskDefinitionView) tableParamsBuilder() (title string, headers []strin return } + +func taskDefinitionResources(cpu *string, memory *string, containerCount int, containerResources func(int) (int32, *int32)) (string, string) { + cpuValue := utils.ShowString(cpu) + memoryValue := utils.ShowString(memory) + if cpu != nil && memory != nil { + return cpuValue, memoryValue + } + + cpuSum := 0 + memorySum := 0 + for i := 0; i < containerCount; i++ { + containerCPU, containerMemory := containerResources(i) + cpuSum += int(containerCPU) + if containerMemory != nil { + memorySum += int(*containerMemory) + } + } + + if cpu == nil { + cpuValue = strconv.Itoa(cpuSum) + } + if memory == nil { + memoryValue = strconv.Itoa(memorySum) + } + return cpuValue, memoryValue +}