Skip to content

Commit 2d54d40

Browse files
authored
fix(provisioning): convert ECS TaskDefinition volume sub-configurations PascalCase to camelCase (#825)
1 parent 13e3851 commit 2d54d40

9 files changed

Lines changed: 567 additions & 9 deletions

File tree

docs/_generated/integ-coverage.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1727,10 +1727,14 @@
17271727
{
17281728
"resourceType": "AWS::EFS::AccessPoint",
17291729
"integs": [
1730+
"ecs-fargate",
17301731
"efs-lambda",
17311732
"efs-standalone"
17321733
],
17331734
"signals": {
1735+
"ecs-fargate": [
1736+
"l2"
1737+
],
17341738
"efs-lambda": [
17351739
"literal"
17361740
],
@@ -1743,6 +1747,7 @@
17431747
"resourceType": "AWS::EFS::FileSystem",
17441748
"integs": [
17451749
"drift-revert-vpc",
1750+
"ecs-fargate",
17461751
"efs-lambda",
17471752
"efs-standalone"
17481753
],
@@ -1751,6 +1756,9 @@
17511756
"l1",
17521757
"literal"
17531758
],
1759+
"ecs-fargate": [
1760+
"l2"
1761+
],
17541762
"efs-lambda": [
17551763
"l2",
17561764
"literal"
@@ -1765,6 +1773,7 @@
17651773
"resourceType": "AWS::EFS::MountTarget",
17661774
"integs": [
17671775
"drift-revert-vpc",
1776+
"ecs-fargate",
17681777
"efs-lambda",
17691778
"efs-standalone"
17701779
],
@@ -1773,6 +1782,9 @@
17731782
"l1",
17741783
"literal"
17751784
],
1785+
"ecs-fargate": [
1786+
"l2"
1787+
],
17761788
"efs-lambda": [
17771789
"l2",
17781790
"literal"

docs/_generated/integ-last-run.tsv

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,8 @@ kms-encryption 2026-06-10T10:20:55Z PASS standard coverage spread; 3 deleted 0
119119
sns-sqs-event 2026-06-10T10:20:55Z PASS verify.sh coverage spread; 19 deleted 0 errors
120120
vpc-lambda 2026-06-10T10:20:55Z PASS standard coverage spread; 16 deleted 0 errors, VPC/ENI clean
121121
drift-revert-vpc 2026-06-11T06:05:51Z PASS verify.sh order-normalize backport; 21 deleted 0 err 0 orphan
122-
ecs-fargate 2026-06-13T04:11:31Z PASS 353 verify.sh #807 propagation + #809 write-only UPDATE pass, 19 deleted 0 err
123122
microservices 2026-06-13T04:12:49Z PASS 39 standard #804 incremental destroy persistence; 19 deleted 0 err 0 orphan
124123
lambda 2026-06-13T04:41:51Z PASS 76 verify.sh #808 broad integ + cdkd events live-tested (deploy+destroy runs persisted); 9 deleted 0 err
125124
vpc-nat-gateway 2026-06-13T05:15:45Z PASS 328 standard #817 IGW/NAT delete-order; 21 deleted 0 err 0 orphan (NAT before IGW/EIP)
126125
cross-region-state-bucket 2026-06-13T06:16:49Z PASS 34 verify.sh #819 exports-index region-corrected client; no 301; 1 deleted 0 err, temp bucket cleaned
126+
ecs-fargate 2026-06-13T06:22:14Z PASS 369 verify.sh #815 EFS efsVolumeConfiguration camelCase reached AWS; 23 deleted 0 err

docs/changelog-cdkd.md

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

docs/integ-coverage.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,9 @@ Registered without an integ fixture, with an explicit `// allow-no-integ: <ratio
8989
| `AWS::ECS::Cluster` | [`drift-revert`](../tests/integration/drift-revert/) (l1)<br>[`ecs-fargate`](../tests/integration/ecs-fargate/) (l2)<br>[`local-ecs-service-connect`](../tests/integration/local-ecs-service-connect/) (l1)<br>[`local-start-alb`](../tests/integration/local-start-alb/) (l1,literal)<br>[`local-start-alb-from-state`](../tests/integration/local-start-alb-from-state/) (l2,literal)<br>[`local-start-service`](../tests/integration/local-start-service/) (l1)<br>[`local-start-service-watch-fast`](../tests/integration/local-start-service-watch-fast/) (l1) |
9090
| `AWS::ECS::Service` | [`ecs-fargate`](../tests/integration/ecs-fargate/) (l2,literal)<br>[`local-ecs-service-connect`](../tests/integration/local-ecs-service-connect/) (l1,literal)<br>[`local-start-alb`](../tests/integration/local-start-alb/) (l1,literal)<br>[`local-start-alb-from-state`](../tests/integration/local-start-alb-from-state/) (l2,literal)<br>[`local-start-service`](../tests/integration/local-start-service/) (l1,literal)<br>[`local-start-service-watch-fast`](../tests/integration/local-start-service-watch-fast/) (l1,literal) |
9191
| `AWS::ECS::TaskDefinition` | [`ecs-fargate`](../tests/integration/ecs-fargate/) (l2)<br>[`local-ecs-service-connect`](../tests/integration/local-ecs-service-connect/) (l1)<br>[`local-run-task`](../tests/integration/local-run-task/) (l2)<br>[`local-run-task-awsvpc`](../tests/integration/local-run-task-awsvpc/) (l2,literal)<br>[`local-run-task-from-state`](../tests/integration/local-run-task-from-state/) (l1,l2,literal)<br>[`local-run-task-multi-container`](../tests/integration/local-run-task-multi-container/) (l2)<br>[`local-start-alb`](../tests/integration/local-start-alb/) (l1,literal)<br>[`local-start-alb-from-state`](../tests/integration/local-start-alb-from-state/) (l2,literal)<br>[`local-start-service`](../tests/integration/local-start-service/) (l1)<br>[`local-start-service-watch-fast`](../tests/integration/local-start-service-watch-fast/) (l2) |
92-
| `AWS::EFS::AccessPoint` | [`efs-lambda`](../tests/integration/efs-lambda/) (literal)<br>[`efs-standalone`](../tests/integration/efs-standalone/) (literal) |
93-
| `AWS::EFS::FileSystem` | [`drift-revert-vpc`](../tests/integration/drift-revert-vpc/) (l1,literal)<br>[`efs-lambda`](../tests/integration/efs-lambda/) (l2,literal)<br>[`efs-standalone`](../tests/integration/efs-standalone/) (l2,literal) |
94-
| `AWS::EFS::MountTarget` | [`drift-revert-vpc`](../tests/integration/drift-revert-vpc/) (l1,literal)<br>[`efs-lambda`](../tests/integration/efs-lambda/) (l2,literal)<br>[`efs-standalone`](../tests/integration/efs-standalone/) (l2,literal) |
92+
| `AWS::EFS::AccessPoint` | [`ecs-fargate`](../tests/integration/ecs-fargate/) (l2)<br>[`efs-lambda`](../tests/integration/efs-lambda/) (literal)<br>[`efs-standalone`](../tests/integration/efs-standalone/) (literal) |
93+
| `AWS::EFS::FileSystem` | [`drift-revert-vpc`](../tests/integration/drift-revert-vpc/) (l1,literal)<br>[`ecs-fargate`](../tests/integration/ecs-fargate/) (l2)<br>[`efs-lambda`](../tests/integration/efs-lambda/) (l2,literal)<br>[`efs-standalone`](../tests/integration/efs-standalone/) (l2,literal) |
94+
| `AWS::EFS::MountTarget` | [`drift-revert-vpc`](../tests/integration/drift-revert-vpc/) (l1,literal)<br>[`ecs-fargate`](../tests/integration/ecs-fargate/) (l2)<br>[`efs-lambda`](../tests/integration/efs-lambda/) (l2,literal)<br>[`efs-standalone`](../tests/integration/efs-standalone/) (l2,literal) |
9595
| `AWS::ElastiCache::CacheCluster` | [`cache-streaming`](../tests/integration/cache-streaming/) (l1,l2) |
9696
| `AWS::ElastiCache::SubnetGroup` | [`cache-streaming`](../tests/integration/cache-streaming/) (l1,l2) |
9797
| `AWS::ElasticLoadBalancingV2::Listener` | [`alb`](../tests/integration/alb/) (literal)<br>[`local-start-alb`](../tests/integration/local-start-alb/) (l1,literal)<br>[`local-start-alb-from-state`](../tests/integration/local-start-alb-from-state/) (literal) |

src/provisioning/providers/ecs-provider.ts

Lines changed: 205 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@ import {
5252
type ApplicationProtocol,
5353
type LogDriver,
5454
type EFSVolumeConfiguration,
55+
type EFSAuthorizationConfig,
56+
type DockerVolumeConfiguration,
57+
type FSxWindowsFileServerVolumeConfiguration,
58+
type FSxWindowsFileServerAuthorizationConfig,
59+
type HostVolumeProperties,
60+
type Scope,
61+
type EFSTransitEncryption,
62+
type EFSAuthorizationConfigIAM,
5563
type AssignPublicIp,
5664
type ContainerCondition,
5765
type EnvironmentFileType,
@@ -1177,15 +1185,33 @@ export class ECSProvider implements ResourceProvider {
11771185
}
11781186

11791187
/**
1180-
* Convert CFn Volumes to ECS SDK format
1188+
* Convert CFn Volumes to ECS SDK format.
1189+
*
1190+
* Every nested volume-configuration block is PascalCase in the CFn
1191+
* template and camelCase in the ECS SDK input, so each sub-block runs
1192+
* through a dedicated converter — the same PascalCase->camelCase trap
1193+
* already fixed for the ContainerDefinitions sub-arrays
1194+
* (convertEnvironment / convertSecrets / convertMountPoints etc.).
1195+
* Before issue #815, `Host` / `EFSVolumeConfiguration` were cast through
1196+
* raw (so their nested keys reached the SDK still PascalCase) and
1197+
* `DockerVolumeConfiguration` / `FSxWindowsFileServerVolumeConfiguration`
1198+
* were not mapped at all (silently dropped).
11811199
*/
11821200
private convertVolumes(volumes?: Array<Record<string, unknown>>): Volume[] | undefined {
11831201
if (!volumes) return undefined;
11841202

11851203
return volumes.map((v) => ({
11861204
name: v['Name'] as string,
1187-
host: v['Host'] as { sourcePath?: string } | undefined,
1188-
efsVolumeConfiguration: v['EFSVolumeConfiguration'] as EFSVolumeConfiguration | undefined,
1205+
host: this.convertVolumeHost(v['Host'] as Record<string, unknown> | undefined),
1206+
dockerVolumeConfiguration: this.convertDockerVolumeConfiguration(
1207+
v['DockerVolumeConfiguration'] as Record<string, unknown> | undefined
1208+
),
1209+
efsVolumeConfiguration: this.convertEFSVolumeConfiguration(
1210+
v['EFSVolumeConfiguration'] as Record<string, unknown> | undefined
1211+
),
1212+
fsxWindowsFileServerVolumeConfiguration: this.convertFSxWindowsVolumeConfiguration(
1213+
v['FSxWindowsFileServerVolumeConfiguration'] as Record<string, unknown> | undefined
1214+
),
11891215
// ConfiguredAtLaunch marks the volume as attach-at-launch so a
11901216
// same-stack AWS::ECS::Service can carry a matching
11911217
// VolumeConfigurations entry (managed EBS volume). Dropping it made
@@ -1196,6 +1222,181 @@ export class ECSProvider implements ResourceProvider {
11961222
}));
11971223
}
11981224

1225+
/**
1226+
* Convert CFn Volumes[].Host to ECS SDK format.
1227+
* CFn: `{SourcePath}` -> SDK: `{sourcePath}`.
1228+
*/
1229+
private convertVolumeHost(host?: Record<string, unknown>): HostVolumeProperties | undefined {
1230+
if (!host) return undefined;
1231+
return {
1232+
sourcePath: host['SourcePath'] as string | undefined,
1233+
};
1234+
}
1235+
1236+
/**
1237+
* Convert CFn Volumes[].DockerVolumeConfiguration to ECS SDK format.
1238+
* CFn: `{Scope, Autoprovision, Driver, DriverOpts, Labels}`
1239+
* -> SDK: `{scope, autoprovision, driver, driverOpts, labels}`.
1240+
*/
1241+
private convertDockerVolumeConfiguration(
1242+
config?: Record<string, unknown>
1243+
): DockerVolumeConfiguration | undefined {
1244+
if (!config) return undefined;
1245+
return {
1246+
scope: config['Scope'] as Scope | undefined,
1247+
autoprovision: this.coerceBool(config['Autoprovision']),
1248+
driver: config['Driver'] as string | undefined,
1249+
driverOpts: config['DriverOpts'] as Record<string, string> | undefined,
1250+
labels: config['Labels'] as Record<string, string> | undefined,
1251+
};
1252+
}
1253+
1254+
/**
1255+
* Convert CFn Volumes[].EFSVolumeConfiguration to ECS SDK format.
1256+
* CFn: `{FilesystemId, RootDirectory, TransitEncryption,
1257+
* TransitEncryptionPort, AuthorizationConfig}`
1258+
* -> SDK: `{fileSystemId, rootDirectory, transitEncryption,
1259+
* transitEncryptionPort, authorizationConfig}`.
1260+
* Note the CFn property is `FilesystemId` (lowercase `s`) while the SDK
1261+
* field is `fileSystemId` — they are not a simple first-letter case flip.
1262+
*/
1263+
private convertEFSVolumeConfiguration(
1264+
config?: Record<string, unknown>
1265+
): EFSVolumeConfiguration | undefined {
1266+
if (!config) return undefined;
1267+
return {
1268+
fileSystemId: config['FilesystemId'] as string,
1269+
rootDirectory: config['RootDirectory'] as string | undefined,
1270+
transitEncryption: config['TransitEncryption'] as EFSTransitEncryption | undefined,
1271+
transitEncryptionPort:
1272+
config['TransitEncryptionPort'] !== undefined
1273+
? Number(config['TransitEncryptionPort'])
1274+
: undefined,
1275+
authorizationConfig: this.convertEFSAuthorizationConfig(
1276+
config['AuthorizationConfig'] as Record<string, unknown> | undefined
1277+
),
1278+
};
1279+
}
1280+
1281+
/**
1282+
* Convert CFn EFSVolumeConfiguration.AuthorizationConfig to ECS SDK format.
1283+
* CFn: `{AccessPointId, IAM}` -> SDK: `{accessPointId, iam}`.
1284+
* Note the CFn key is `IAM` (all caps), NOT `Iam` — not a simple
1285+
* first-letter case flip (verified against the CDK L1 `IAM` mapping).
1286+
*/
1287+
private convertEFSAuthorizationConfig(
1288+
config?: Record<string, unknown>
1289+
): EFSAuthorizationConfig | undefined {
1290+
if (!config) return undefined;
1291+
return {
1292+
accessPointId: config['AccessPointId'] as string | undefined,
1293+
iam: config['IAM'] as EFSAuthorizationConfigIAM | undefined,
1294+
};
1295+
}
1296+
1297+
/**
1298+
* Convert CFn Volumes[].FSxWindowsFileServerVolumeConfiguration to ECS
1299+
* SDK format.
1300+
* CFn: `{FileSystemId, RootDirectory, AuthorizationConfig}`
1301+
* -> SDK: `{fileSystemId, rootDirectory, authorizationConfig}`.
1302+
*/
1303+
private convertFSxWindowsVolumeConfiguration(
1304+
config?: Record<string, unknown>
1305+
): FSxWindowsFileServerVolumeConfiguration | undefined {
1306+
if (!config) return undefined;
1307+
return {
1308+
fileSystemId: config['FileSystemId'] as string,
1309+
rootDirectory: config['RootDirectory'] as string,
1310+
authorizationConfig: this.convertFSxWindowsAuthorizationConfig(
1311+
config['AuthorizationConfig'] as Record<string, unknown> | undefined
1312+
) as FSxWindowsFileServerAuthorizationConfig,
1313+
};
1314+
}
1315+
1316+
/**
1317+
* Convert CFn FSxWindowsFileServerVolumeConfiguration.AuthorizationConfig
1318+
* to ECS SDK format.
1319+
* CFn: `{CredentialsParameter, Domain}`
1320+
* -> SDK: `{credentialsParameter, domain}`.
1321+
*/
1322+
private convertFSxWindowsAuthorizationConfig(
1323+
config?: Record<string, unknown>
1324+
): FSxWindowsFileServerAuthorizationConfig | undefined {
1325+
if (!config) return undefined;
1326+
return {
1327+
credentialsParameter: config['CredentialsParameter'] as string,
1328+
domain: config['Domain'] as string,
1329+
};
1330+
}
1331+
1332+
/**
1333+
* Convert the camelCase SDK `volumes` shape returned by
1334+
* DescribeTaskDefinition back to the PascalCase CFn template form, so the
1335+
* `readCurrentState` snapshot matches the deploy-time template
1336+
* representation for drift comparison (issue #815). Only volume keys
1337+
* present on the SDK side are emitted, so a future field cdkd does not
1338+
* map cannot surface as phantom drift. TaskDefinitions are immutable
1339+
* replace-only today, so this is forward-looking normalization.
1340+
*/
1341+
private volumesToCfn(volumes?: Volume[]): Array<Record<string, unknown>> {
1342+
if (!volumes) return [];
1343+
return volumes.map((v) => {
1344+
const out: Record<string, unknown> = {};
1345+
if (v.name !== undefined) out['Name'] = v.name;
1346+
if (v.host !== undefined) {
1347+
const host: Record<string, unknown> = {};
1348+
if (v.host.sourcePath !== undefined) host['SourcePath'] = v.host.sourcePath;
1349+
out['Host'] = host;
1350+
}
1351+
if (v.dockerVolumeConfiguration !== undefined) {
1352+
const d = v.dockerVolumeConfiguration;
1353+
const docker: Record<string, unknown> = {};
1354+
if (d.scope !== undefined) docker['Scope'] = d.scope;
1355+
if (d.autoprovision !== undefined) docker['Autoprovision'] = d.autoprovision;
1356+
if (d.driver !== undefined) docker['Driver'] = d.driver;
1357+
if (d.driverOpts !== undefined) docker['DriverOpts'] = d.driverOpts;
1358+
if (d.labels !== undefined) docker['Labels'] = d.labels;
1359+
out['DockerVolumeConfiguration'] = docker;
1360+
}
1361+
if (v.efsVolumeConfiguration !== undefined) {
1362+
const e = v.efsVolumeConfiguration;
1363+
const efs: Record<string, unknown> = {};
1364+
if (e.fileSystemId !== undefined) efs['FilesystemId'] = e.fileSystemId;
1365+
if (e.rootDirectory !== undefined) efs['RootDirectory'] = e.rootDirectory;
1366+
if (e.transitEncryption !== undefined) efs['TransitEncryption'] = e.transitEncryption;
1367+
if (e.transitEncryptionPort !== undefined) {
1368+
efs['TransitEncryptionPort'] = e.transitEncryptionPort;
1369+
}
1370+
if (e.authorizationConfig !== undefined) {
1371+
const a = e.authorizationConfig;
1372+
const auth: Record<string, unknown> = {};
1373+
if (a.accessPointId !== undefined) auth['AccessPointId'] = a.accessPointId;
1374+
if (a.iam !== undefined) auth['IAM'] = a.iam;
1375+
efs['AuthorizationConfig'] = auth;
1376+
}
1377+
out['EFSVolumeConfiguration'] = efs;
1378+
}
1379+
if (v.fsxWindowsFileServerVolumeConfiguration !== undefined) {
1380+
const f = v.fsxWindowsFileServerVolumeConfiguration;
1381+
const fsx: Record<string, unknown> = {};
1382+
if (f.fileSystemId !== undefined) fsx['FileSystemId'] = f.fileSystemId;
1383+
if (f.rootDirectory !== undefined) fsx['RootDirectory'] = f.rootDirectory;
1384+
if (f.authorizationConfig !== undefined) {
1385+
const a = f.authorizationConfig;
1386+
const auth: Record<string, unknown> = {};
1387+
if (a.credentialsParameter !== undefined) {
1388+
auth['CredentialsParameter'] = a.credentialsParameter;
1389+
}
1390+
if (a.domain !== undefined) auth['Domain'] = a.domain;
1391+
fsx['AuthorizationConfig'] = auth;
1392+
}
1393+
out['FSxWindowsFileServerVolumeConfiguration'] = fsx;
1394+
}
1395+
if (v.configuredAtLaunch !== undefined) out['ConfiguredAtLaunch'] = v.configuredAtLaunch;
1396+
return out;
1397+
});
1398+
}
1399+
11991400
/**
12001401
* Coerce a CFn boolean property to a real boolean at the wire boundary.
12011402
* CFn templates can carry booleans as the strings "true" / "false"
@@ -1528,7 +1729,7 @@ export class ECSProvider implements ResourceProvider {
15281729
: [];
15291730
if (td.executionRoleArn !== undefined) result['ExecutionRoleArn'] = td.executionRoleArn;
15301731
if (td.taskRoleArn !== undefined) result['TaskRoleArn'] = td.taskRoleArn;
1531-
result['Volumes'] = td.volumes ?? [];
1732+
result['Volumes'] = this.volumesToCfn(td.volumes);
15321733
result['PlacementConstraints'] = td.placementConstraints ?? [];
15331734
if (td.runtimePlatform) result['RuntimePlatform'] = td.runtimePlatform;
15341735
if (td.proxyConfiguration) result['ProxyConfiguration'] = td.proxyConfiguration;

tests/integration/ecs-fargate/lib/ecs-fargate-stack.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as cdk from 'aws-cdk-lib';
22
import { Construct } from 'constructs';
33
import * as ec2 from 'aws-cdk-lib/aws-ec2';
44
import * as ecs from 'aws-cdk-lib/aws-ecs';
5+
import * as efs from 'aws-cdk-lib/aws-efs';
56
import * as logs from 'aws-cdk-lib/aws-logs';
67

78
/**
@@ -123,6 +124,41 @@ export class EcsFargateStack extends cdk.Stack {
123124
});
124125
taskDefinition.addVolume(ebsVolume);
125126

127+
// Exercise the #815 fix: a Volumes[].EFSVolumeConfiguration must reach
128+
// RegisterTaskDefinition with its nested keys converted PascalCase ->
129+
// camelCase. Before #815, convertVolumes cast EFSVolumeConfiguration
130+
// through raw, so the nested keys (FilesystemId / RootDirectory /
131+
// TransitEncryption / AuthorizationConfig.{AccessPointId, IAM}) reached
132+
// the SDK still PascalCase. The FileSystem itself is created in the
133+
// public subnets (the VPC has no private subnets to minimize cost) with
134+
// RemovalPolicy.DESTROY so destroy stays clean. No task ever launches
135+
// (desiredCount: 0), so the volume is never actually mounted — the
136+
// assertion is purely on the registered task definition's
137+
// efsVolumeConfiguration shape reaching AWS.
138+
const fileSystem = new efs.FileSystem(this, 'EfsFileSystem', {
139+
vpc,
140+
vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
141+
removalPolicy: cdk.RemovalPolicy.DESTROY,
142+
});
143+
const accessPoint = new efs.AccessPoint(this, 'EfsAccessPoint', {
144+
fileSystem,
145+
path: '/data',
146+
createAcl: { ownerGid: '1000', ownerUid: '1000', permissions: '755' },
147+
posixUser: { gid: '1000', uid: '1000' },
148+
});
149+
taskDefinition.addVolume({
150+
name: 'efs-data',
151+
efsVolumeConfiguration: {
152+
fileSystemId: fileSystem.fileSystemId,
153+
rootDirectory: '/',
154+
transitEncryption: 'ENABLED',
155+
authorizationConfig: {
156+
accessPointId: accessPoint.accessPointId,
157+
iam: 'ENABLED',
158+
},
159+
},
160+
});
161+
126162
// Create Fargate Service with desiredCount: 0 and Service Connect
127163
// This tests resource creation without actually running containers
128164
const service = new ecs.FargateService(this, 'Service', {

0 commit comments

Comments
 (0)