Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/_generated/integ-coverage.json
Original file line number Diff line number Diff line change
Expand Up @@ -1727,10 +1727,14 @@
{
"resourceType": "AWS::EFS::AccessPoint",
"integs": [
"ecs-fargate",
"efs-lambda",
"efs-standalone"
],
"signals": {
"ecs-fargate": [
"l2"
],
"efs-lambda": [
"literal"
],
Expand All @@ -1743,6 +1747,7 @@
"resourceType": "AWS::EFS::FileSystem",
"integs": [
"drift-revert-vpc",
"ecs-fargate",
"efs-lambda",
"efs-standalone"
],
Expand All @@ -1751,6 +1756,9 @@
"l1",
"literal"
],
"ecs-fargate": [
"l2"
],
"efs-lambda": [
"l2",
"literal"
Expand All @@ -1765,6 +1773,7 @@
"resourceType": "AWS::EFS::MountTarget",
"integs": [
"drift-revert-vpc",
"ecs-fargate",
"efs-lambda",
"efs-standalone"
],
Expand All @@ -1773,6 +1782,9 @@
"l1",
"literal"
],
"ecs-fargate": [
"l2"
],
"efs-lambda": [
"l2",
"literal"
Expand Down
2 changes: 1 addition & 1 deletion docs/_generated/integ-last-run.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,8 @@ kms-encryption 2026-06-10T10:20:55Z PASS standard coverage spread; 3 deleted 0
sns-sqs-event 2026-06-10T10:20:55Z PASS verify.sh coverage spread; 19 deleted 0 errors
vpc-lambda 2026-06-10T10:20:55Z PASS standard coverage spread; 16 deleted 0 errors, VPC/ENI clean
drift-revert-vpc 2026-06-11T06:05:51Z PASS verify.sh order-normalize backport; 21 deleted 0 err 0 orphan
ecs-fargate 2026-06-13T04:11:31Z PASS 353 verify.sh #807 propagation + #809 write-only UPDATE pass, 19 deleted 0 err
microservices 2026-06-13T04:12:49Z PASS 39 standard #804 incremental destroy persistence; 19 deleted 0 err 0 orphan
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
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)
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
ecs-fargate 2026-06-13T06:22:14Z PASS 369 verify.sh #815 EFS efsVolumeConfiguration camelCase reached AWS; 23 deleted 0 err
1 change: 1 addition & 0 deletions docs/changelog-cdkd.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions docs/integ-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,9 @@ Registered without an integ fixture, with an explicit `// allow-no-integ: <ratio
| `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) |
| `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) |
| `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) |
| `AWS::EFS::AccessPoint` | [`efs-lambda`](../tests/integration/efs-lambda/) (literal)<br>[`efs-standalone`](../tests/integration/efs-standalone/) (literal) |
| `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) |
| `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) |
| `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) |
| `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) |
| `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) |
| `AWS::ElastiCache::CacheCluster` | [`cache-streaming`](../tests/integration/cache-streaming/) (l1,l2) |
| `AWS::ElastiCache::SubnetGroup` | [`cache-streaming`](../tests/integration/cache-streaming/) (l1,l2) |
| `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) |
Expand Down
209 changes: 205 additions & 4 deletions src/provisioning/providers/ecs-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ import {
type ApplicationProtocol,
type LogDriver,
type EFSVolumeConfiguration,
type EFSAuthorizationConfig,
type DockerVolumeConfiguration,
type FSxWindowsFileServerVolumeConfiguration,
type FSxWindowsFileServerAuthorizationConfig,
type HostVolumeProperties,
type Scope,
type EFSTransitEncryption,
type EFSAuthorizationConfigIAM,
type AssignPublicIp,
type ContainerCondition,
type EnvironmentFileType,
Expand Down Expand Up @@ -1177,15 +1185,33 @@ export class ECSProvider implements ResourceProvider {
}

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

return volumes.map((v) => ({
name: v['Name'] as string,
host: v['Host'] as { sourcePath?: string } | undefined,
efsVolumeConfiguration: v['EFSVolumeConfiguration'] as EFSVolumeConfiguration | undefined,
host: this.convertVolumeHost(v['Host'] as Record<string, unknown> | undefined),
dockerVolumeConfiguration: this.convertDockerVolumeConfiguration(
v['DockerVolumeConfiguration'] as Record<string, unknown> | undefined
),
efsVolumeConfiguration: this.convertEFSVolumeConfiguration(
v['EFSVolumeConfiguration'] as Record<string, unknown> | undefined
),
fsxWindowsFileServerVolumeConfiguration: this.convertFSxWindowsVolumeConfiguration(
v['FSxWindowsFileServerVolumeConfiguration'] as Record<string, unknown> | undefined
),
// ConfiguredAtLaunch marks the volume as attach-at-launch so a
// same-stack AWS::ECS::Service can carry a matching
// VolumeConfigurations entry (managed EBS volume). Dropping it made
Expand All @@ -1196,6 +1222,181 @@ export class ECSProvider implements ResourceProvider {
}));
}

/**
* Convert CFn Volumes[].Host to ECS SDK format.
* CFn: `{SourcePath}` -> SDK: `{sourcePath}`.
*/
private convertVolumeHost(host?: Record<string, unknown>): HostVolumeProperties | undefined {
if (!host) return undefined;
return {
sourcePath: host['SourcePath'] as string | undefined,
};
}

/**
* Convert CFn Volumes[].DockerVolumeConfiguration to ECS SDK format.
* CFn: `{Scope, Autoprovision, Driver, DriverOpts, Labels}`
* -> SDK: `{scope, autoprovision, driver, driverOpts, labels}`.
*/
private convertDockerVolumeConfiguration(
config?: Record<string, unknown>
): DockerVolumeConfiguration | undefined {
if (!config) return undefined;
return {
scope: config['Scope'] as Scope | undefined,
autoprovision: this.coerceBool(config['Autoprovision']),
driver: config['Driver'] as string | undefined,
driverOpts: config['DriverOpts'] as Record<string, string> | undefined,
labels: config['Labels'] as Record<string, string> | undefined,
};
}

/**
* Convert CFn Volumes[].EFSVolumeConfiguration to ECS SDK format.
* CFn: `{FilesystemId, RootDirectory, TransitEncryption,
* TransitEncryptionPort, AuthorizationConfig}`
* -> SDK: `{fileSystemId, rootDirectory, transitEncryption,
* transitEncryptionPort, authorizationConfig}`.
* Note the CFn property is `FilesystemId` (lowercase `s`) while the SDK
* field is `fileSystemId` — they are not a simple first-letter case flip.
*/
private convertEFSVolumeConfiguration(
config?: Record<string, unknown>
): EFSVolumeConfiguration | undefined {
if (!config) return undefined;
return {
fileSystemId: config['FilesystemId'] as string,
rootDirectory: config['RootDirectory'] as string | undefined,
transitEncryption: config['TransitEncryption'] as EFSTransitEncryption | undefined,
transitEncryptionPort:
config['TransitEncryptionPort'] !== undefined
? Number(config['TransitEncryptionPort'])
: undefined,
authorizationConfig: this.convertEFSAuthorizationConfig(
config['AuthorizationConfig'] as Record<string, unknown> | undefined
),
};
}

/**
* Convert CFn EFSVolumeConfiguration.AuthorizationConfig to ECS SDK format.
* CFn: `{AccessPointId, IAM}` -> SDK: `{accessPointId, iam}`.
* Note the CFn key is `IAM` (all caps), NOT `Iam` — not a simple
* first-letter case flip (verified against the CDK L1 `IAM` mapping).
*/
private convertEFSAuthorizationConfig(
config?: Record<string, unknown>
): EFSAuthorizationConfig | undefined {
if (!config) return undefined;
return {
accessPointId: config['AccessPointId'] as string | undefined,
iam: config['IAM'] as EFSAuthorizationConfigIAM | undefined,
};
}

/**
* Convert CFn Volumes[].FSxWindowsFileServerVolumeConfiguration to ECS
* SDK format.
* CFn: `{FileSystemId, RootDirectory, AuthorizationConfig}`
* -> SDK: `{fileSystemId, rootDirectory, authorizationConfig}`.
*/
private convertFSxWindowsVolumeConfiguration(
config?: Record<string, unknown>
): FSxWindowsFileServerVolumeConfiguration | undefined {
if (!config) return undefined;
return {
fileSystemId: config['FileSystemId'] as string,
rootDirectory: config['RootDirectory'] as string,
authorizationConfig: this.convertFSxWindowsAuthorizationConfig(
config['AuthorizationConfig'] as Record<string, unknown> | undefined
) as FSxWindowsFileServerAuthorizationConfig,
};
}

/**
* Convert CFn FSxWindowsFileServerVolumeConfiguration.AuthorizationConfig
* to ECS SDK format.
* CFn: `{CredentialsParameter, Domain}`
* -> SDK: `{credentialsParameter, domain}`.
*/
private convertFSxWindowsAuthorizationConfig(
config?: Record<string, unknown>
): FSxWindowsFileServerAuthorizationConfig | undefined {
if (!config) return undefined;
return {
credentialsParameter: config['CredentialsParameter'] as string,
domain: config['Domain'] as string,
};
}

/**
* Convert the camelCase SDK `volumes` shape returned by
* DescribeTaskDefinition back to the PascalCase CFn template form, so the
* `readCurrentState` snapshot matches the deploy-time template
* representation for drift comparison (issue #815). Only volume keys
* present on the SDK side are emitted, so a future field cdkd does not
* map cannot surface as phantom drift. TaskDefinitions are immutable
* replace-only today, so this is forward-looking normalization.
*/
private volumesToCfn(volumes?: Volume[]): Array<Record<string, unknown>> {
if (!volumes) return [];
return volumes.map((v) => {
const out: Record<string, unknown> = {};
if (v.name !== undefined) out['Name'] = v.name;
if (v.host !== undefined) {
const host: Record<string, unknown> = {};
if (v.host.sourcePath !== undefined) host['SourcePath'] = v.host.sourcePath;
out['Host'] = host;
}
if (v.dockerVolumeConfiguration !== undefined) {
const d = v.dockerVolumeConfiguration;
const docker: Record<string, unknown> = {};
if (d.scope !== undefined) docker['Scope'] = d.scope;
if (d.autoprovision !== undefined) docker['Autoprovision'] = d.autoprovision;
if (d.driver !== undefined) docker['Driver'] = d.driver;
if (d.driverOpts !== undefined) docker['DriverOpts'] = d.driverOpts;
if (d.labels !== undefined) docker['Labels'] = d.labels;
out['DockerVolumeConfiguration'] = docker;
}
if (v.efsVolumeConfiguration !== undefined) {
const e = v.efsVolumeConfiguration;
const efs: Record<string, unknown> = {};
if (e.fileSystemId !== undefined) efs['FilesystemId'] = e.fileSystemId;
if (e.rootDirectory !== undefined) efs['RootDirectory'] = e.rootDirectory;
if (e.transitEncryption !== undefined) efs['TransitEncryption'] = e.transitEncryption;
if (e.transitEncryptionPort !== undefined) {
efs['TransitEncryptionPort'] = e.transitEncryptionPort;
}
if (e.authorizationConfig !== undefined) {
const a = e.authorizationConfig;
const auth: Record<string, unknown> = {};
if (a.accessPointId !== undefined) auth['AccessPointId'] = a.accessPointId;
if (a.iam !== undefined) auth['IAM'] = a.iam;
efs['AuthorizationConfig'] = auth;
}
out['EFSVolumeConfiguration'] = efs;
}
if (v.fsxWindowsFileServerVolumeConfiguration !== undefined) {
const f = v.fsxWindowsFileServerVolumeConfiguration;
const fsx: Record<string, unknown> = {};
if (f.fileSystemId !== undefined) fsx['FileSystemId'] = f.fileSystemId;
if (f.rootDirectory !== undefined) fsx['RootDirectory'] = f.rootDirectory;
if (f.authorizationConfig !== undefined) {
const a = f.authorizationConfig;
const auth: Record<string, unknown> = {};
if (a.credentialsParameter !== undefined) {
auth['CredentialsParameter'] = a.credentialsParameter;
}
if (a.domain !== undefined) auth['Domain'] = a.domain;
fsx['AuthorizationConfig'] = auth;
}
out['FSxWindowsFileServerVolumeConfiguration'] = fsx;
}
if (v.configuredAtLaunch !== undefined) out['ConfiguredAtLaunch'] = v.configuredAtLaunch;
return out;
});
}

/**
* Coerce a CFn boolean property to a real boolean at the wire boundary.
* CFn templates can carry booleans as the strings "true" / "false"
Expand Down Expand Up @@ -1528,7 +1729,7 @@ export class ECSProvider implements ResourceProvider {
: [];
if (td.executionRoleArn !== undefined) result['ExecutionRoleArn'] = td.executionRoleArn;
if (td.taskRoleArn !== undefined) result['TaskRoleArn'] = td.taskRoleArn;
result['Volumes'] = td.volumes ?? [];
result['Volumes'] = this.volumesToCfn(td.volumes);
result['PlacementConstraints'] = td.placementConstraints ?? [];
if (td.runtimePlatform) result['RuntimePlatform'] = td.runtimePlatform;
if (td.proxyConfiguration) result['ProxyConfiguration'] = td.proxyConfiguration;
Expand Down
36 changes: 36 additions & 0 deletions tests/integration/ecs-fargate/lib/ecs-fargate-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as efs from 'aws-cdk-lib/aws-efs';
import * as logs from 'aws-cdk-lib/aws-logs';

/**
Expand Down Expand Up @@ -123,6 +124,41 @@ export class EcsFargateStack extends cdk.Stack {
});
taskDefinition.addVolume(ebsVolume);

// Exercise the #815 fix: a Volumes[].EFSVolumeConfiguration must reach
// RegisterTaskDefinition with its nested keys converted PascalCase ->
// camelCase. Before #815, convertVolumes cast EFSVolumeConfiguration
// through raw, so the nested keys (FilesystemId / RootDirectory /
// TransitEncryption / AuthorizationConfig.{AccessPointId, IAM}) reached
// the SDK still PascalCase. The FileSystem itself is created in the
// public subnets (the VPC has no private subnets to minimize cost) with
// RemovalPolicy.DESTROY so destroy stays clean. No task ever launches
// (desiredCount: 0), so the volume is never actually mounted — the
// assertion is purely on the registered task definition's
// efsVolumeConfiguration shape reaching AWS.
const fileSystem = new efs.FileSystem(this, 'EfsFileSystem', {
vpc,
vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
const accessPoint = new efs.AccessPoint(this, 'EfsAccessPoint', {
fileSystem,
path: '/data',
createAcl: { ownerGid: '1000', ownerUid: '1000', permissions: '755' },
posixUser: { gid: '1000', uid: '1000' },
});
taskDefinition.addVolume({
name: 'efs-data',
efsVolumeConfiguration: {
fileSystemId: fileSystem.fileSystemId,
rootDirectory: '/',
transitEncryption: 'ENABLED',
authorizationConfig: {
accessPointId: accessPoint.accessPointId,
iam: 'ENABLED',
},
},
});

// Create Fargate Service with desiredCount: 0 and Service Connect
// This tests resource creation without actually running containers
const service = new ecs.FargateService(this, 'Service', {
Expand Down
Loading