Skip to content

Commit 3efd805

Browse files
committed
feat(ecs): accept a resolvable ContainerDefinition.image
ContainerDefinition.image was typed as a bare String, the only reference-shaped field left on the ECS task definition family that was not a String|formae.Resolvable union. Sibling fields (Secret.valueFrom, RepositoryCredentials.credentialsParameter, EFSVolumeConfiguration.filesystemId, TaskDefinition.executionRoleArn and taskRoleArn) all accept one already. The practical cost was that nothing could wire a built image into a task definition. An AWS::CodeBuild::ImageBuild publishes a digest and an AWS::ECR::Repository publishes a URI, but neither could reach the image field: passing a Resolvable failed PKL type checking, and interpolating one into a string shipped the framed envelope to RegisterTaskDefinition verbatim, which AWS rejects as invalid characters. The workaround was to resolve the digest out of band and pin it as a literal, then re-pin it by hand on every image rebuild. Widening the type to String|formae.Resolvable removes that hand-edit. The conformance fixture ecs-taskdefinition-image-ref.pkl creates an ECR repository from scratch and points the container image at its resolved repositoryUri, so an apply fails outright if the reference does not resolve. Fargate refuses an ECR image without an execution role, so the fixture declares one; that requirement is AWS's and is unrelated to the reference resolving.
1 parent e741005 commit 3efd805

3 files changed

Lines changed: 131 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,14 @@ formae agent.
5353
the association is removed. Exposing the resolvable removes the hand-edit;
5454
it does not change that ordering.
5555

56+
- `AWS::ECS::TaskDefinition`: `ContainerDefinition.image` now accepts a
57+
resolvable (`String|formae.Resolvable`) instead of a bare `String`, so a
58+
container image can be wired through the resource graph: an
59+
`AWS::CodeBuild::ImageBuild` digest, an `AWS::ECR::Repository` URI, or any
60+
other resolvable image reference. It was the only reference-shaped field on
61+
the resource family still typed as a plain string, which forced the image to
62+
be pinned by hand and re-pinned on every rebuild.
63+
5664
### Changed
5765

5866
- **Breaking.** `AWS::CodeBuild::ImageBuild` is now a pure build-and-push

schema/pkl/ecs/taskdefinition.pkl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ open class ContainerDefinition extends formae.SubResource {
4545
firelensConfiguration: FirelensConfiguration?
4646
healthCheck: HealthCheck?
4747
hostname: String?
48-
image: String
48+
image: String|formae.Resolvable
4949
@aws.FieldHint{hasProviderDefault = true}
5050
interactive: Boolean?
5151
links: Listing<String>?
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/*
2+
* © 2025 Platform Engineering Labs Inc.
3+
*
4+
* SPDX-License-Identifier: FSL-1.1-ALv2
5+
*/
6+
7+
amends "@formae/forma.pkl"
8+
import "@formae/formae.pkl"
9+
10+
import "@aws/aws.pkl"
11+
12+
import "@aws/ecr/repository.pkl"
13+
import "@aws/iam/role.pkl"
14+
import "@aws/ecs/taskdefinition.pkl"
15+
16+
local testRunID = read("env:FORMAE_TEST_RUN_ID")
17+
local stackName = "plugin-sdk-test-ecs-taskdefinition-image-ref-\(testRunID)"
18+
19+
// From-scratch ECR repository whose URI the task definition references via
20+
// `testRepo.res.repositoryUri`. The URI is unknown until the repository is
21+
// created, so it resolves at apply time. ECS validates the image reference's
22+
// syntax (not its existence) at RegisterTaskDefinition, so the repository can
23+
// stay empty.
24+
local testRepo = new repository.Repository {
25+
label = "test-repo-for-ecs-taskdef-image"
26+
repositoryName = "formae-plugin-sdk-test-ecs-td-image-\(testRunID)"
27+
}
28+
29+
// Fargate rejects a task definition whose image is an ECR reference unless an
30+
// execution role is set, since the agent needs one to pull the image. The role
31+
// must trust `ecs-tasks.amazonaws.com`; its ARN is referenced via
32+
// `executionRole.res.arn`.
33+
local executionRole = new role.Role {
34+
label = "test-execution-role-for-ecs-taskdef-image"
35+
roleName = "formae-plugin-sdk-test-ecs-td-img-role-\(testRunID)"
36+
assumeRolePolicyDocument {
37+
["Version"] = "2012-10-17"
38+
["Statement"] {
39+
new {
40+
["Effect"] = "Allow"
41+
["Principal"] {
42+
["Service"] = "ecs-tasks.amazonaws.com"
43+
}
44+
["Action"] = "sts:AssumeRole"
45+
}
46+
}
47+
}
48+
}
49+
50+
local testTaskDef = new taskdefinition.TaskDefinition {
51+
label = "plugin-sdk-test-ecs-taskdefinition-image-ref"
52+
family = "formae-sdk-test-td-image-ref-\(testRunID)"
53+
requiresCompatibilities {
54+
"FARGATE"
55+
}
56+
networkMode = "awsvpc"
57+
cpu = "256"
58+
memory = "512"
59+
executionRoleArn = executionRole.res.arn
60+
// AWS returns Tags as [] when unset. The conformance harness's reverse loop
61+
// flags "extra" actual fields not in expected and not marked as
62+
// hasProviderDefault. Set explicitly so the harness sees Tags as "in
63+
// expected" and skips the check. Tags is user-canonical so we don't want
64+
// hasProviderDefault on the schema (would silently drop user changes).
65+
tags = new Listing<aws.Tag> {}
66+
containerDefinitions {
67+
new {
68+
name = "test-container"
69+
// The container image is the resolved repository URI; the apply only
70+
// succeeds if `image` accepts a Resolvable and substitutes it before
71+
// the RegisterTaskDefinition call.
72+
image = testRepo.res.repositoryUri
73+
essential = true
74+
// AWS returns these container fields as [] / {} when unset. Same harness
75+
// reason as Tags above. Schema deliberately omits hasProviderDefault on
76+
// them because they are user-canonical and the strip would silently drop
77+
// user changes.
78+
command = new Listing<String> {}
79+
credentialSpecs = new Listing<String> {}
80+
dependsOn = new Listing<taskdefinition.ContainerDependency> {}
81+
dnsSearchDomains = new Listing<String> {}
82+
dnsServers = new Listing<String> {}
83+
dockerLabels = new Mapping<String, Any> {}
84+
dockerSecurityOptions = new Listing<String> {}
85+
entryPoint = new Listing<String> {}
86+
environment = new Listing<taskdefinition.KeyValuePair> {}
87+
environmentFiles = new Listing<taskdefinition.EnvironmentFile> {}
88+
extraHosts = new Listing<taskdefinition.HostEntry> {}
89+
links = new Listing<String> {}
90+
mountPoints = new Listing<taskdefinition.MountPoint> {}
91+
portMappings = new Listing<taskdefinition.PortMapping> {}
92+
resourceRequirements = new Listing<taskdefinition.ResourceRequirement> {}
93+
secrets = new Listing<taskdefinition.Secret> {}
94+
systemControls = new Listing<taskdefinition.SystemControl> {}
95+
ulimits = new Listing<taskdefinition.Ulimit> {}
96+
volumesFrom = new Listing<taskdefinition.VolumeFrom> {}
97+
}
98+
}
99+
}
100+
101+
forma {
102+
new formae.Stack {
103+
label = stackName
104+
description = "Plugin SDK test for resolving an ECR repository URI (testRepo.res.repositoryUri) in an ECS TaskDefinition container's image"
105+
}
106+
107+
new formae.Target {
108+
label = "aws-target"
109+
config = new aws.Config {
110+
region = "us-east-1"
111+
}
112+
}
113+
114+
testRepo
115+
116+
executionRole
117+
118+
// TaskDefinition under test, declared last: the conformance harness's
119+
// findTargetResource falls back to the last resource. It is a singleton of
120+
// its type and a deletable leaf (nothing references it).
121+
testTaskDef
122+
}

0 commit comments

Comments
 (0)