fix(ecs): complete Describe read-back for task definitions, services and clusters (#1038)

DescribeTaskDefinition now echoes portMappings[].name and runtimePlatform,
DescribeServices projects the stored networkConfiguration and
enableECSManagedTags, DescribeClusters returns the four instance, task and
service counts, and the five list operations return their ARN key as a
present empty list instead of omitting it.

UpdateService now honours networkConfiguration and enableECSManagedTags so a
changed value converges instead of producing a permanent diff.

The eleven container runtime fields the task definition also drops are
deliberately left absent: nothing between the daemon and the on-host agent
carries them, and reporting a container as privileged or read-only-root when
nothing enforces it is worse than reporting nothing.

Closes: mulga-0t9q2
Closes: mulga-7thn7
Closes: mulga-d8kif
Closes: mulga-5z8z9
This commit is contained in:
Josh Siviour
2026-09-16 07:39:41 +10:00
committed by GitHub
parent c818255365
commit fd4f3bcb49
12 changed files with 485 additions and 52 deletions
+6 -1
View File
@@ -53,11 +53,16 @@ const (
TaskStatusStopped = "STOPPED"
)
// PortMapping is a container port exposed on the host (bridge mode v1).
// PortMapping is a container port exposed on the host (bridge mode v1). Shared
// by the persisted ContainerDef and the AssignContainer wire payload below; Name
// is a pure echo the agent does not read.
type PortMapping struct {
ContainerPort int `json:"containerPort"`
HostPort int `json:"hostPort,omitempty"`
Protocol string `json:"protocol,omitempty"` // tcp | udp (default tcp)
// Name is the Service Connect port-mapping name; inert until Service Connect
// is implemented, but harmless to echo (the agent ignores it).
Name string `json:"name,omitempty"`
}
// AssignContainer is one container the agent must pull and run for a task.
+16
View File
@@ -116,6 +116,7 @@ func containerDefsFromAWS(in []*ecs.ContainerDefinition) []ContainerDef {
ContainerPort: int(aws.Int64Value(p.ContainerPort)),
HostPort: int(aws.Int64Value(p.HostPort)),
Protocol: aws.StringValue(p.Protocol),
Name: aws.StringValue(p.Name),
})
}
if lc := c.LogConfiguration; lc != nil {
@@ -132,6 +133,18 @@ func containerDefsFromAWS(in []*ecs.ContainerDefinition) []ContainerDef {
return out
}
// runtimePlatformFromAWS maps the SDK runtimePlatform to the persisted subset.
// Pure echo: nothing in v1 enforces CPU architecture or OS family selection.
func runtimePlatformFromAWS(in *ecs.RuntimePlatform) *RuntimePlatformRecord {
if in == nil {
return nil
}
return &RuntimePlatformRecord{
CPUArchitecture: aws.StringValue(in.CpuArchitecture),
OperatingSystemFamily: aws.StringValue(in.OperatingSystemFamily),
}
}
func (c ContainerDef) toAWS() *ecs.ContainerDefinition {
cd := &ecs.ContainerDefinition{
Name: aws.String(c.Name),
@@ -164,6 +177,9 @@ func (c ContainerDef) toAWS() *ecs.ContainerDefinition {
if p.Protocol != "" {
pm.Protocol = aws.String(p.Protocol)
}
if p.Name != "" {
pm.Name = aws.String(p.Name)
}
cd.PortMappings = append(cd.PortMappings, pm)
}
if c.LogDriver != "" {
+17 -1
View File
@@ -56,7 +56,7 @@ func TestContainerDefRoundTrip(t *testing.T) {
Essential: aws.Bool(true),
Command: []*string{aws.String("/bin/sh"), aws.String("-c")},
Environment: []*ecs.KeyValuePair{{Name: aws.String("FOO"), Value: aws.String("bar")}},
PortMappings: []*ecs.PortMapping{{ContainerPort: aws.Int64(80), HostPort: aws.Int64(8080), Protocol: aws.String("tcp")}},
PortMappings: []*ecs.PortMapping{{ContainerPort: aws.Int64(80), HostPort: aws.Int64(8080), Protocol: aws.String("tcp"), Name: aws.String("http")}},
}}
defs := containerDefsFromAWS(in)
require.Len(t, defs, 1)
@@ -67,10 +67,12 @@ func TestContainerDefRoundTrip(t *testing.T) {
assert.Equal(t, "bar", d.Environment["FOO"])
require.Len(t, d.PortMappings, 1)
assert.Equal(t, 8080, d.PortMappings[0].HostPort)
assert.Equal(t, "http", d.PortMappings[0].Name)
back := d.toAWS()
assert.Equal(t, "registry/web:1", aws.StringValue(back.Image))
assert.Equal(t, int64(80), aws.Int64Value(back.PortMappings[0].ContainerPort))
assert.Equal(t, "http", aws.StringValue(back.PortMappings[0].Name))
ac := d.toAssignContainer()
assert.Equal(t, "web", ac.Name)
@@ -78,6 +80,20 @@ func TestContainerDefRoundTrip(t *testing.T) {
assert.Equal(t, "bar", ac.Environment["FOO"])
}
// TestContainerDefRoundTrip_PortNameOmittedWhenUnset covers the AWS-parity
// path: a port mapping with no name must not grow one on the way back out.
func TestContainerDefRoundTrip_PortNameOmittedWhenUnset(t *testing.T) {
in := []*ecs.ContainerDefinition{{
Name: aws.String("web"), Image: aws.String("registry/web:1"), Essential: aws.Bool(true),
PortMappings: []*ecs.PortMapping{{ContainerPort: aws.Int64(80)}},
}}
defs := containerDefsFromAWS(in)
require.Len(t, defs, 1)
assert.Empty(t, defs[0].PortMappings[0].Name)
back := defs[0].toAWS()
assert.Nil(t, back.PortMappings[0].Name)
}
func TestContainerDefsFromAWS_SkipsNil(t *testing.T) {
assert.Empty(t, containerDefsFromAWS([]*ecs.ContainerDefinition{nil}))
}
+3 -3
View File
@@ -114,11 +114,11 @@ func (s *Service) ListContainerInstances(ctx context.Context, input *ecs.ListCon
if err != nil {
return nil, err
}
out := &ecs.ListContainerInstancesOutput{}
arns := make([]string, 0, len(recs))
for i := range recs {
out.ContainerInstanceArns = append(out.ContainerInstanceArns, aws.String(recs[i].ARN))
arns = append(arns, recs[i].ARN)
}
return out, nil
return &ecs.ListContainerInstancesOutput{ContainerInstanceArns: aws.StringSlice(arns)}, nil
}
// upsertInstance reads-or-creates the instance record, applies mutate, and writes
+18 -6
View File
@@ -206,6 +206,14 @@ type ContainerDef struct {
// lands in the host journal/file, retrievable per ecs-logging.md.
const LogDriverJSONFile = "json-file"
// RuntimePlatformRecord is the persisted subset of an ecs.RuntimePlatform.
// Pure echo, same as RequiresCompatibilities: nothing selects capacity by CPU
// architecture or OS family, but a caller that sets it must read it back.
type RuntimePlatformRecord struct {
CPUArchitecture string `json:"cpuArchitecture,omitempty"`
OperatingSystemFamily string `json:"operatingSystemFamily,omitempty"`
}
// TaskDefRecord is the persisted task definition revision at TaskDefRevKey.
type TaskDefRecord struct {
Family string `json:"family"`
@@ -219,11 +227,12 @@ type TaskDefRecord struct {
// Persisted purely so Describe echoes back what Register was given. Only
// the EC2 launch type is implemented, but a client that sets this and
// reads back an empty list sees permanent drift.
RequiresCompatibilities []string `json:"requiresCompatibilities,omitempty"`
Containers []ContainerDef `json:"containers"`
Status string `json:"status"`
Tags map[string]string `json:"tags,omitempty"`
RegisteredAt time.Time `json:"registeredAt"`
RequiresCompatibilities []string `json:"requiresCompatibilities,omitempty"`
RuntimePlatform *RuntimePlatformRecord `json:"runtimePlatform,omitempty"`
Containers []ContainerDef `json:"containers"`
Status string `json:"status"`
Tags map[string]string `json:"tags,omitempty"`
RegisteredAt time.Time `json:"registeredAt"`
}
// reservedCPU/reservedMemory sum the task definition's per-container reservations
@@ -384,7 +393,10 @@ type ServiceRecord struct {
DeploymentID string `json:"deploymentId"`
RunningCount int `json:"runningCount"`
PendingCount int `json:"pendingCount"`
Tags map[string]string `json:"tags,omitempty"`
// EnableECSManagedTags is persisted purely so Describe echoes back what
// Create was given; nothing in v1 propagates ECS-managed tags onto tasks.
EnableECSManagedTags bool `json:"enableEcsManagedTags,omitempty"`
Tags map[string]string `json:"tags,omitempty"`
// Rolling-update configuration (deploymentConfiguration) and its live state.
// MinimumHealthyPercent / MaximumPercent gate the rollout; the circuit breaker
// trips a failing deployment and optionally rolls back to LastGoodTaskDefARN.
+62
View File
@@ -0,0 +1,62 @@
package handlers_ecs
//test:in-package — these decode persisted records straight into the unexported
// record types and call their unexported toAWS projection, which is the whole
// point: the assertion is that an older KV entry still reads.
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestTaskDefRecord_DecodesPreExistingJSON verifies that a TaskDefRecord
// persisted before portMappings[].name and runtimePlatform existed still
// decodes cleanly: every KV entry written by an older daemon build must stay
// readable without a migration.
func TestTaskDefRecord_DecodesPreExistingJSON(t *testing.T) {
legacy := `{
"family": "app",
"revision": 1,
"arn": "arn:aws:ecs:ap-southeast-2:123456789012:task-definition/app:1",
"status": "ACTIVE",
"containers": [{
"name": "app",
"image": "registry/app:1",
"essential": true,
"portMappings": [{"containerPort": 80, "hostPort": 8080, "protocol": "tcp"}]
}]
}`
var rec TaskDefRecord
require.NoError(t, json.Unmarshal([]byte(legacy), &rec))
assert.Nil(t, rec.RuntimePlatform)
require.Len(t, rec.Containers, 1)
require.Len(t, rec.Containers[0].PortMappings, 1)
assert.Empty(t, rec.Containers[0].PortMappings[0].Name)
// toAWS must not panic or invent a RuntimePlatform for the missing field.
td := rec.toAWS()
assert.Nil(t, td.RuntimePlatform)
require.Len(t, td.ContainerDefinitions, 1)
assert.Nil(t, td.ContainerDefinitions[0].PortMappings[0].Name)
}
// TestServiceRecord_DecodesPreExistingJSON verifies that a ServiceRecord
// persisted before enableEcsManagedTags existed still decodes cleanly, and
// defaults the new field to false rather than failing to unmarshal.
func TestServiceRecord_DecodesPreExistingJSON(t *testing.T) {
legacy := `{
"name": "web",
"arn": "arn:aws:ecs:ap-southeast-2:123456789012:service/web/web",
"cluster": "web",
"status": "ACTIVE",
"schedulingStrategy": "REPLICA",
"deploymentId": "d-1"
}`
var rec ServiceRecord
require.NoError(t, json.Unmarshal([]byte(legacy), &rec))
assert.False(t, rec.EnableECSManagedTags)
assert.Empty(t, rec.Subnets)
}
+64 -8
View File
@@ -282,13 +282,58 @@ func (s *Service) DescribeClusters(ctx context.Context, input *ecs.DescribeClust
if err != nil {
return nil, err
}
if found {
out.Clusters = append(out.Clusters, rec.toAWS())
if !found {
continue
}
c := rec.toAWS()
if err := s.applyClusterCounts(ctx, kv, name, c); err != nil {
return nil, err
}
out.Clusters = append(out.Clusters, c)
}
return out, nil
}
// applyClusterCounts fills the four count fields AWS always returns on a
// cluster, tallied at read time from the existing listers. The separate
// Statistics key/value list, which Include=STATISTICS adds, stays unset.
func (s *Service) applyClusterCounts(ctx context.Context, kv jetstream.KeyValue, cluster string, c *ecs.Cluster) error {
instances, err := s.listInstanceRecords(ctx, kv, cluster)
if err != nil {
return err
}
c.RegisteredContainerInstancesCount = aws.Int64(int64(len(instances)))
tasks, err := s.listTaskRecords(ctx, kv, cluster)
if err != nil {
return err
}
var running, pending int64
for i := range tasks {
switch tasks[i].LastStatus {
case TaskStatusRunning:
running++
case TaskStatusPending:
pending++
}
}
c.RunningTasksCount = aws.Int64(running)
c.PendingTasksCount = aws.Int64(pending)
services, err := s.listServiceRecords(ctx, kv, cluster)
if err != nil {
return err
}
var active int64
for i := range services {
if services[i].Status == ServiceStatusActive {
active++
}
}
c.ActiveServicesCount = aws.Int64(active)
return nil
}
// ListClusters returns the ARNs of all clusters in the account.
func (s *Service) ListClusters(ctx context.Context, _ *ecs.ListClustersInput, accountID string) (*ecs.ListClustersOutput, error) {
kv, err := s.bucket(ctx, accountID)
@@ -299,7 +344,7 @@ func (s *Service) ListClusters(ctx context.Context, _ *ecs.ListClustersInput, ac
if err != nil {
return nil, err
}
out := &ecs.ListClustersOutput{}
arns := make([]string, 0, len(keys))
for _, k := range keys {
if !strings.HasSuffix(k, "/meta") {
continue
@@ -310,10 +355,10 @@ func (s *Service) ListClusters(ctx context.Context, _ *ecs.ListClustersInput, ac
return nil, err
}
if found {
out.ClusterArns = append(out.ClusterArns, aws.String(rec.ARN))
arns = append(arns, rec.ARN)
}
}
return out, nil
return &ecs.ListClustersOutput{ClusterArns: aws.StringSlice(arns)}, nil
}
func (r *ClusterRecord) toAWS() *ecs.Cluster {
@@ -402,6 +447,7 @@ func (s *Service) RegisterTaskDefinition(ctx context.Context, input *ecs.Registe
Containers: containerDefsFromAWS(input.ContainerDefinitions),
RequiresCompatibilities: aws.StringValueSlice(input.RequiresCompatibilities),
RuntimePlatform: runtimePlatformFromAWS(input.RuntimePlatform),
}
if err := putJSON(ctx, kv, TaskDefRevKey(family, rev), &rec); err != nil {
return nil, err
@@ -514,7 +560,7 @@ func (s *Service) ListTaskDefinitions(ctx context.Context, input *ecs.ListTaskDe
if err != nil {
return nil, err
}
out := &ecs.ListTaskDefinitionsOutput{}
arns := make([]string, 0, len(keys))
for _, k := range keys {
if !strings.Contains(k, "/revs/") {
continue
@@ -525,10 +571,10 @@ func (s *Service) ListTaskDefinitions(ctx context.Context, input *ecs.ListTaskDe
return nil, err
}
if found && rec.Status == wantStatus {
out.TaskDefinitionArns = append(out.TaskDefinitionArns, aws.String(rec.ARN))
arns = append(arns, rec.ARN)
}
}
return out, nil
return &ecs.ListTaskDefinitionsOutput{TaskDefinitionArns: aws.StringSlice(arns)}, nil
}
// resolveTaskDef loads the TaskDefRecord named by ref ("family", "family:rev",
@@ -603,6 +649,16 @@ func (r *TaskDefRecord) toAWS() *ecs.TaskDefinition {
if len(r.RequiresCompatibilities) > 0 {
td.RequiresCompatibilities = aws.StringSlice(r.RequiresCompatibilities)
}
if r.RuntimePlatform != nil {
rp := &ecs.RuntimePlatform{}
if r.RuntimePlatform.CPUArchitecture != "" {
rp.CpuArchitecture = aws.String(r.RuntimePlatform.CPUArchitecture)
}
if r.RuntimePlatform.OperatingSystemFamily != "" {
rp.OperatingSystemFamily = aws.String(r.RuntimePlatform.OperatingSystemFamily)
}
td.RuntimePlatform = rp
}
for _, c := range r.Containers {
td.ContainerDefinitions = append(td.ContainerDefinitions, c.toAWS())
}
+81
View File
@@ -694,6 +694,87 @@ func TestRecordRegister_GPU_CarriesDeviceUUIDs(t *testing.T) {
assert.Equal(t, []string{"GPU-aaa", "GPU-bbb"}, rec.GPUIDs)
}
// TestService_DescribeClusters_CountsAlwaysPresent verifies the four count
// fields come back on every DescribeClusters, not only under
// Include=STATISTICS, and that they are tallied from the live
// instance/task/service records rather than any persisted counter. AWS returns
// them unconditionally; Include=STATISTICS adds a separate key/value list.
func TestService_DescribeClusters_CountsAlwaysPresent(t *testing.T) {
svc, _, _ := serviceTestRig(t)
_, err := svc.CreateService(context.Background(), &ecs.CreateServiceInput{
Cluster: aws.String("web"), ServiceName: aws.String("web"), TaskDefinition: aws.String("app"),
DesiredCount: aws.Int64(1),
}, testAccountID)
require.NoError(t, err)
assertCounts := func(t *testing.T, c *ecs.Cluster) {
t.Helper()
assert.Equal(t, int64(1), aws.Int64Value(c.RegisteredContainerInstancesCount))
assert.Equal(t, int64(1), aws.Int64Value(c.ActiveServicesCount))
assert.Equal(t, int64(1), aws.Int64Value(c.PendingTasksCount))
assert.Equal(t, int64(0), aws.Int64Value(c.RunningTasksCount))
}
// No Include at all: this is what the Terraform data source sends, and the
// counts must still be populated.
out, err := svc.DescribeClusters(context.Background(), &ecs.DescribeClustersInput{
Clusters: []*string{aws.String("web")},
}, testAccountID)
require.NoError(t, err)
require.Len(t, out.Clusters, 1)
assertCounts(t, out.Clusters[0])
stats, err := svc.DescribeClusters(context.Background(), &ecs.DescribeClustersInput{
Clusters: []*string{aws.String("web")},
Include: aws.StringSlice([]string{ecs.ClusterFieldStatistics}),
}, testAccountID)
require.NoError(t, err)
require.Len(t, stats.Clusters, 1)
assertCounts(t, stats.Clusters[0])
}
// The following cover the ECS list operations that must return the ARNs key
// as a present, empty list rather than omit it: the SDK's JSON marshaller
// drops a nil slice unconditionally, regardless of the "omitempty" AWS itself
// documents for these fields, so this must build the slice with make(..., 0,
// n) rather than append into a nil field.
func TestService_ListClusters_EmptyIsPresentNotAbsent(t *testing.T) {
svc, _ := newTestService(t)
out, err := svc.ListClusters(context.Background(), &ecs.ListClustersInput{}, testAccountID)
require.NoError(t, err)
assert.NotNil(t, out.ClusterArns)
assert.Empty(t, out.ClusterArns)
}
func TestService_ListTaskDefinitions_EmptyIsPresentNotAbsent(t *testing.T) {
svc, _ := newTestService(t)
out, err := svc.ListTaskDefinitions(context.Background(), &ecs.ListTaskDefinitionsInput{}, testAccountID)
require.NoError(t, err)
assert.NotNil(t, out.TaskDefinitionArns)
assert.Empty(t, out.TaskDefinitionArns)
}
func TestService_ListTasks_EmptyIsPresentNotAbsent(t *testing.T) {
svc, _ := newTestService(t)
_, err := svc.CreateCluster(context.Background(), &ecs.CreateClusterInput{ClusterName: aws.String("web")}, testAccountID)
require.NoError(t, err)
out, err := svc.ListTasks(context.Background(), &ecs.ListTasksInput{Cluster: aws.String("web")}, testAccountID)
require.NoError(t, err)
assert.NotNil(t, out.TaskArns)
assert.Empty(t, out.TaskArns)
}
func TestService_ListContainerInstances_EmptyIsPresentNotAbsent(t *testing.T) {
svc, _ := newTestService(t)
_, err := svc.CreateCluster(context.Background(), &ecs.CreateClusterInput{ClusterName: aws.String("web")}, testAccountID)
require.NoError(t, err)
out, err := svc.ListContainerInstances(context.Background(), &ecs.ListContainerInstancesInput{Cluster: aws.String("web")}, testAccountID)
require.NoError(t, err)
assert.NotNil(t, out.ContainerInstanceArns)
assert.Empty(t, out.ContainerInstanceArns)
}
func TestAccountIDFromBucket(t *testing.T) {
id, ok := accountIDFromBucket(AccountBucketName(testAccountID))
assert.True(t, ok)
+56 -30
View File
@@ -166,23 +166,24 @@ func (s *Service) CreateService(ctx context.Context, input *ecs.CreateServiceInp
now := time.Now().UTC()
rec := ServiceRecord{
Name: name,
ARN: ServiceARN(s.region, accountID, cluster, name),
Cluster: cluster,
TaskDefFamily: taskDef.Family,
TaskDefRevision: taskDef.Revision,
TaskDefARN: taskDef.ARN,
DesiredCount: int(aws.Int64Value(input.DesiredCount)),
Status: ServiceStatusActive,
SchedulingStrategy: SchedulingStrategyReplica,
LaunchType: aws.StringValue(input.LaunchType),
NetworkMode: resolveNetworkMode(taskDef),
PlacementStrategy: placementStrategyFromAWS(input.PlacementStrategy),
LoadBalancers: loadBalancersFromAWS(input.LoadBalancers),
DeploymentID: uuid.NewV4().String(),
Tags: tagsToMap(input.Tags),
CreatedAt: now,
UpdatedAt: now,
Name: name,
ARN: ServiceARN(s.region, accountID, cluster, name),
Cluster: cluster,
TaskDefFamily: taskDef.Family,
TaskDefRevision: taskDef.Revision,
TaskDefARN: taskDef.ARN,
DesiredCount: int(aws.Int64Value(input.DesiredCount)),
Status: ServiceStatusActive,
SchedulingStrategy: SchedulingStrategyReplica,
LaunchType: aws.StringValue(input.LaunchType),
NetworkMode: resolveNetworkMode(taskDef),
PlacementStrategy: placementStrategyFromAWS(input.PlacementStrategy),
LoadBalancers: loadBalancersFromAWS(input.LoadBalancers),
DeploymentID: uuid.NewV4().String(),
EnableECSManagedTags: aws.BoolValue(input.EnableECSManagedTags),
Tags: tagsToMap(input.Tags),
CreatedAt: now,
UpdatedAt: now,
}
if input.NetworkConfiguration != nil && input.NetworkConfiguration.AwsvpcConfiguration != nil {
v := input.NetworkConfiguration.AwsvpcConfiguration
@@ -224,6 +225,18 @@ func (s *Service) UpdateService(ctx context.Context, input *ecs.UpdateServiceInp
if input.DeploymentConfiguration != nil {
applyDeploymentConfig(&rec, input.DeploymentConfiguration)
}
// Persisted so the next Describe reflects it; only tasks placed after this
// point pick it up, as AWS does not move an existing awsvpc ENI. Without
// this a subnet change is a diff no apply can converge.
if input.NetworkConfiguration != nil && input.NetworkConfiguration.AwsvpcConfiguration != nil {
v := input.NetworkConfiguration.AwsvpcConfiguration
rec.Subnets = awsStringSlice(v.Subnets)
rec.SecurityGroups = awsStringSlice(v.SecurityGroups)
rec.AssignPublicIP = aws.StringValue(v.AssignPublicIp)
}
if input.EnableECSManagedTags != nil {
rec.EnableECSManagedTags = aws.BoolValue(input.EnableECSManagedTags)
}
if input.DesiredCount != nil {
rec.DesiredCount = int(aws.Int64Value(input.DesiredCount))
}
@@ -319,11 +332,11 @@ func (s *Service) ListServices(ctx context.Context, input *ecs.ListServicesInput
if err != nil {
return nil, err
}
out := &ecs.ListServicesOutput{}
arns := make([]string, 0, len(recs))
for i := range recs {
out.ServiceArns = append(out.ServiceArns, aws.String(recs[i].ARN))
arns = append(arns, recs[i].ARN)
}
return out, nil
return &ecs.ListServicesOutput{ServiceArns: aws.StringSlice(arns)}, nil
}
// --- Reconciliation ---
@@ -661,20 +674,33 @@ func loadBalancersFromAWS(in []*ecs.LoadBalancer) []LoadBalancerTarget {
func (s *Service) serviceToAWS(accountID string, r *ServiceRecord) *ecs.Service {
svc := &ecs.Service{
ServiceName: aws.String(r.Name),
ServiceArn: aws.String(r.ARN),
ClusterArn: aws.String(ClusterARN(s.region, accountID, r.Cluster)),
Status: aws.String(r.Status),
DesiredCount: aws.Int64(int64(r.DesiredCount)),
RunningCount: aws.Int64(int64(r.RunningCount)),
PendingCount: aws.Int64(int64(r.PendingCount)),
SchedulingStrategy: aws.String(r.SchedulingStrategy),
TaskDefinition: aws.String(r.TaskDefARN),
Tags: tagsToAWS(r.Tags),
ServiceName: aws.String(r.Name),
ServiceArn: aws.String(r.ARN),
ClusterArn: aws.String(ClusterARN(s.region, accountID, r.Cluster)),
Status: aws.String(r.Status),
DesiredCount: aws.Int64(int64(r.DesiredCount)),
RunningCount: aws.Int64(int64(r.RunningCount)),
PendingCount: aws.Int64(int64(r.PendingCount)),
SchedulingStrategy: aws.String(r.SchedulingStrategy),
TaskDefinition: aws.String(r.TaskDefARN),
EnableECSManagedTags: aws.Bool(r.EnableECSManagedTags),
Tags: tagsToAWS(r.Tags),
}
if r.LaunchType != "" {
svc.LaunchType = aws.String(r.LaunchType)
}
// Only awsvpc tasks carry a subnet/security-group selection; other modes
// never populate one (RunTask's parseAwsvpcConfig gates it the same way).
if r.NetworkMode == NetworkModeAwsvpc {
awsvpc := &ecs.AwsVpcConfiguration{
Subnets: aws.StringSlice(r.Subnets),
SecurityGroups: aws.StringSlice(r.SecurityGroups),
}
if r.AssignPublicIP != "" {
awsvpc.AssignPublicIp = aws.String(r.AssignPublicIP)
}
svc.NetworkConfiguration = &ecs.NetworkConfiguration{AwsvpcConfiguration: awsvpc}
}
for _, lb := range r.LoadBalancers {
svc.LoadBalancers = append(svc.LoadBalancers, &ecs.LoadBalancer{
TargetGroupArn: aws.String(lb.TargetGroupARN),
+113
View File
@@ -198,6 +198,119 @@ func TestService_DescribeAndList(t *testing.T) {
assert.Len(t, miss.Failures, 1)
}
// TestService_ListServices_EmptyIsPresentNotAbsent covers the ARN-list D-fix
// for ListServices: the key must marshal as an empty list, not vanish.
func TestService_ListServices_EmptyIsPresentNotAbsent(t *testing.T) {
svc, _, _ := serviceTestRig(t)
out, err := svc.ListServices(context.Background(), &ecs.ListServicesInput{Cluster: aws.String("web")}, testAccountID)
require.NoError(t, err)
assert.NotNil(t, out.ServiceArns)
assert.Empty(t, out.ServiceArns)
}
// TestService_CreateService_ProjectsNetworkConfigurationAndManagedTags covers
// the two DescribeServices projection gaps: networkConfiguration is already
// stored and used internally to place tasks, but serviceToAWS never echoed
// it; enableECSManagedTags was not stored at all.
func TestService_CreateService_ProjectsNetworkConfigurationAndManagedTags(t *testing.T) {
svc, _ := newTestService(t)
_, err := svc.CreateCluster(context.Background(), &ecs.CreateClusterInput{ClusterName: aws.String("web")}, testAccountID)
require.NoError(t, err)
_, err = svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
Family: aws.String("app"),
NetworkMode: aws.String(NetworkModeAwsvpc),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
}},
}, testAccountID)
require.NoError(t, err)
out, err := svc.CreateService(context.Background(), &ecs.CreateServiceInput{
Cluster: aws.String("web"), ServiceName: aws.String("web"), TaskDefinition: aws.String("app"),
DesiredCount: aws.Int64(0),
EnableECSManagedTags: aws.Bool(true),
NetworkConfiguration: &ecs.NetworkConfiguration{
AwsvpcConfiguration: &ecs.AwsVpcConfiguration{
Subnets: aws.StringSlice([]string{"subnet-1", "subnet-2"}),
SecurityGroups: aws.StringSlice([]string{"sg-1"}),
AssignPublicIp: aws.String("ENABLED"),
},
},
}, testAccountID)
require.NoError(t, err)
require.True(t, aws.BoolValue(out.Service.EnableECSManagedTags))
require.NotNil(t, out.Service.NetworkConfiguration)
nc := out.Service.NetworkConfiguration.AwsvpcConfiguration
require.NotNil(t, nc)
assert.ElementsMatch(t, []string{"subnet-1", "subnet-2"}, aws.StringValueSlice(nc.Subnets))
assert.ElementsMatch(t, []string{"sg-1"}, aws.StringValueSlice(nc.SecurityGroups))
assert.Equal(t, "ENABLED", aws.StringValue(nc.AssignPublicIp))
}
// TestService_UpdateService_ChangesNetworkConfiguration covers the
// cross-cutting constraint: once networkConfiguration is echoed back,
// UpdateService must actually persist a changed value, or a caller changing
// subnets would see a diff no apply could ever converge.
func TestService_UpdateService_ChangesNetworkConfiguration(t *testing.T) {
svc, _ := newTestService(t)
_, err := svc.CreateCluster(context.Background(), &ecs.CreateClusterInput{ClusterName: aws.String("web")}, testAccountID)
require.NoError(t, err)
_, err = svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
Family: aws.String("app"),
NetworkMode: aws.String(NetworkModeAwsvpc),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
}},
}, testAccountID)
require.NoError(t, err)
_, err = svc.CreateService(context.Background(), &ecs.CreateServiceInput{
Cluster: aws.String("web"), ServiceName: aws.String("web"), TaskDefinition: aws.String("app"),
DesiredCount: aws.Int64(0),
NetworkConfiguration: &ecs.NetworkConfiguration{
AwsvpcConfiguration: &ecs.AwsVpcConfiguration{Subnets: aws.StringSlice([]string{"subnet-1"})},
},
}, testAccountID)
require.NoError(t, err)
out, err := svc.UpdateService(context.Background(), &ecs.UpdateServiceInput{
Cluster: aws.String("web"), Service: aws.String("web"),
NetworkConfiguration: &ecs.NetworkConfiguration{
AwsvpcConfiguration: &ecs.AwsVpcConfiguration{Subnets: aws.StringSlice([]string{"subnet-2"})},
},
EnableECSManagedTags: aws.Bool(true),
}, testAccountID)
require.NoError(t, err)
require.NotNil(t, out.Service.NetworkConfiguration)
assert.Equal(t, []string{"subnet-2"},
aws.StringValueSlice(out.Service.NetworkConfiguration.AwsvpcConfiguration.Subnets))
assert.True(t, aws.BoolValue(out.Service.EnableECSManagedTags))
// The change persisted, not just the response of this one call.
desc, err := svc.DescribeServices(context.Background(), &ecs.DescribeServicesInput{
Cluster: aws.String("web"), Services: []*string{aws.String("web")},
}, testAccountID)
require.NoError(t, err)
require.Len(t, desc.Services, 1)
assert.Equal(t, []string{"subnet-2"},
aws.StringValueSlice(desc.Services[0].NetworkConfiguration.AwsvpcConfiguration.Subnets))
assert.True(t, aws.BoolValue(desc.Services[0].EnableECSManagedTags))
// An update naming neither field leaves both alone rather than resetting
// them to their zero values.
_, err = svc.UpdateService(context.Background(), &ecs.UpdateServiceInput{
Cluster: aws.String("web"), Service: aws.String("web"), DesiredCount: aws.Int64(0),
}, testAccountID)
require.NoError(t, err)
desc, err = svc.DescribeServices(context.Background(), &ecs.DescribeServicesInput{
Cluster: aws.String("web"), Services: []*string{aws.String("web")},
}, testAccountID)
require.NoError(t, err)
require.Len(t, desc.Services, 1)
assert.Equal(t, []string{"subnet-2"},
aws.StringValueSlice(desc.Services[0].NetworkConfiguration.AwsvpcConfiguration.Subnets))
assert.True(t, aws.BoolValue(desc.Services[0].EnableECSManagedTags))
}
// StartTask places one task per named container instance and assigns it.
func TestService_StartTask_PlacesPerInstance(t *testing.T) {
svc, _, kv := serviceTestRig(t)
@@ -77,6 +77,52 @@ func TestRegisterTaskDefinition_EchoesRequiresCompatibilities(t *testing.T) {
assert.Equal(t, []string{"EC2"}, aws.StringValueSlice(d.TaskDefinition.RequiresCompatibilities))
}
// TestRegisterTaskDefinition_EchoesRuntimePlatform: runtimePlatform is inert
// (nothing selects capacity by CPU architecture or OS family in v1), but the
// same drift trap as RequiresCompatibilities applies if it is not echoed back.
func TestRegisterTaskDefinition_EchoesRuntimePlatform(t *testing.T) {
svc, _ := newTestService(t)
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
Family: aws.String("app"),
RuntimePlatform: &ecs.RuntimePlatform{
CpuArchitecture: aws.String("ARM64"),
OperatingSystemFamily: aws.String("LINUX"),
},
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
}},
}, testAccountID)
require.NoError(t, err)
d, err := svc.DescribeTaskDefinition(context.Background(), &ecs.DescribeTaskDefinitionInput{
TaskDefinition: aws.String("app"),
}, testAccountID)
require.NoError(t, err)
require.NotNil(t, d.TaskDefinition.RuntimePlatform)
assert.Equal(t, "ARM64", aws.StringValue(d.TaskDefinition.RuntimePlatform.CpuArchitecture))
assert.Equal(t, "LINUX", aws.StringValue(d.TaskDefinition.RuntimePlatform.OperatingSystemFamily))
}
// TestRegisterTaskDefinition_RuntimePlatformOmittedWhenUnset verifies that a
// task definition registered without runtimePlatform describes with a nil
// RuntimePlatform rather than an invented empty struct.
func TestRegisterTaskDefinition_RuntimePlatformOmittedWhenUnset(t *testing.T) {
svc, _ := newTestService(t)
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
Family: aws.String("app"),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
}},
}, testAccountID)
require.NoError(t, err)
d, err := svc.DescribeTaskDefinition(context.Background(), &ecs.DescribeTaskDefinitionInput{
TaskDefinition: aws.String("app"),
}, testAccountID)
require.NoError(t, err)
assert.Nil(t, d.TaskDefinition.RuntimePlatform)
}
// TestRunTask_AssignCarriesExecutionRoleAndLogDriver verifies that execution
// role plumbed to the agent) and the log driver reaches the assign.
func TestRunTask_AssignCarriesExecutionRoleAndLogDriver(t *testing.T) {
+3 -3
View File
@@ -252,7 +252,7 @@ func (s *Service) ListTasks(ctx context.Context, input *ecs.ListTasksInput, acco
if err != nil {
return nil, err
}
out := &ecs.ListTasksOutput{}
arns := make([]string, 0, len(keys))
for _, k := range keys {
var rec TaskRecord
found, err := getJSON(ctx, kv, k, &rec)
@@ -260,10 +260,10 @@ func (s *Service) ListTasks(ctx context.Context, input *ecs.ListTasksInput, acco
return nil, err
}
if found {
out.TaskArns = append(out.TaskArns, aws.String(rec.ARN))
arns = append(arns, rec.ARN)
}
}
return out, nil
return &ecs.ListTasksOutput{TaskArns: aws.StringSlice(arns)}, nil
}
func (s *Service) taskToAWS(accountID string, r *TaskRecord) *ecs.Task {