@dga-itc/aws-cdk-constructs 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +219 -0
  2. package/dist/aws-cdk/constructs/acm.d.ts +28 -0
  3. package/dist/aws-cdk/constructs/acm.js +239 -0
  4. package/dist/aws-cdk/constructs/alb.d.ts +28 -0
  5. package/dist/aws-cdk/constructs/alb.js +304 -0
  6. package/dist/aws-cdk/constructs/bastion.d.ts +46 -0
  7. package/dist/aws-cdk/constructs/bastion.js +332 -0
  8. package/dist/aws-cdk/constructs/cloudfront.d.ts +45 -0
  9. package/dist/aws-cdk/constructs/cloudfront.js +261 -0
  10. package/dist/aws-cdk/constructs/ecr.d.ts +17 -0
  11. package/dist/aws-cdk/constructs/ecr.js +143 -0
  12. package/dist/aws-cdk/constructs/ecs-cluster.d.ts +21 -0
  13. package/dist/aws-cdk/constructs/ecs-cluster.js +124 -0
  14. package/dist/aws-cdk/constructs/ecs-service.d.ts +72 -0
  15. package/dist/aws-cdk/constructs/ecs-service.js +682 -0
  16. package/dist/aws-cdk/constructs/efs.d.ts +31 -0
  17. package/dist/aws-cdk/constructs/efs.js +241 -0
  18. package/dist/aws-cdk/constructs/elasticache.d.ts +35 -0
  19. package/dist/aws-cdk/constructs/elasticache.js +210 -0
  20. package/dist/aws-cdk/constructs/nacl.d.ts +37 -0
  21. package/dist/aws-cdk/constructs/nacl.js +88 -0
  22. package/dist/aws-cdk/constructs/nlb.d.ts +39 -0
  23. package/dist/aws-cdk/constructs/nlb.js +276 -0
  24. package/dist/aws-cdk/constructs/rds.d.ts +40 -0
  25. package/dist/aws-cdk/constructs/rds.js +320 -0
  26. package/dist/aws-cdk/constructs/self-signed-cert.d.ts +83 -0
  27. package/dist/aws-cdk/constructs/self-signed-cert.js +215 -0
  28. package/dist/aws-cdk/constructs/sqs.d.ts +30 -0
  29. package/dist/aws-cdk/constructs/sqs.js +268 -0
  30. package/dist/aws-cdk/constructs/vpc.d.ts +30 -0
  31. package/dist/aws-cdk/constructs/vpc.js +423 -0
  32. package/dist/aws-cdk/constructs/waf.d.ts +37 -0
  33. package/dist/aws-cdk/constructs/waf.js +350 -0
  34. package/dist/aws-cdk/interfaces/account-config.d.ts +18 -0
  35. package/dist/aws-cdk/interfaces/account-config.js +2 -0
  36. package/dist/aws-cdk/interfaces/acm-config.d.ts +94 -0
  37. package/dist/aws-cdk/interfaces/acm-config.js +14 -0
  38. package/dist/aws-cdk/interfaces/alb-config.d.ts +72 -0
  39. package/dist/aws-cdk/interfaces/alb-config.js +2 -0
  40. package/dist/aws-cdk/interfaces/bastion-config.d.ts +77 -0
  41. package/dist/aws-cdk/interfaces/bastion-config.js +10 -0
  42. package/dist/aws-cdk/interfaces/cloudfront-config.d.ts +154 -0
  43. package/dist/aws-cdk/interfaces/cloudfront-config.js +15 -0
  44. package/dist/aws-cdk/interfaces/ecr-config.d.ts +40 -0
  45. package/dist/aws-cdk/interfaces/ecr-config.js +2 -0
  46. package/dist/aws-cdk/interfaces/ecs-cluster-config.d.ts +30 -0
  47. package/dist/aws-cdk/interfaces/ecs-cluster-config.js +2 -0
  48. package/dist/aws-cdk/interfaces/ecs-service-config.d.ts +237 -0
  49. package/dist/aws-cdk/interfaces/ecs-service-config.js +2 -0
  50. package/dist/aws-cdk/interfaces/efs-config.d.ts +56 -0
  51. package/dist/aws-cdk/interfaces/efs-config.js +7 -0
  52. package/dist/aws-cdk/interfaces/elasticache-config.d.ts +56 -0
  53. package/dist/aws-cdk/interfaces/elasticache-config.js +7 -0
  54. package/dist/aws-cdk/interfaces/nacl-config.d.ts +1 -0
  55. package/dist/aws-cdk/interfaces/nacl-config.js +3 -0
  56. package/dist/aws-cdk/interfaces/nlb-config.d.ts +69 -0
  57. package/dist/aws-cdk/interfaces/nlb-config.js +2 -0
  58. package/dist/aws-cdk/interfaces/rds-config.d.ts +84 -0
  59. package/dist/aws-cdk/interfaces/rds-config.js +7 -0
  60. package/dist/aws-cdk/interfaces/sqs-config.d.ts +145 -0
  61. package/dist/aws-cdk/interfaces/sqs-config.js +12 -0
  62. package/dist/aws-cdk/interfaces/tag-config.d.ts +18 -0
  63. package/dist/aws-cdk/interfaces/tag-config.js +2 -0
  64. package/dist/aws-cdk/interfaces/vpc-config.d.ts +72 -0
  65. package/dist/aws-cdk/interfaces/vpc-config.js +2 -0
  66. package/dist/aws-cdk/interfaces/waf-config.d.ts +180 -0
  67. package/dist/aws-cdk/interfaces/waf-config.js +2 -0
  68. package/dist/aws-cdk/utils/priority-tracker.d.ts +60 -0
  69. package/dist/aws-cdk/utils/priority-tracker.js +131 -0
  70. package/dist/index.d.ts +33 -0
  71. package/dist/index.js +55 -0
  72. package/dist/terraform-cdk/constructs/alb-listener-rule.d.ts +33 -0
  73. package/dist/terraform-cdk/constructs/alb-listener-rule.js +81 -0
  74. package/dist/terraform-cdk/constructs/ecs-service.d.ts +29 -0
  75. package/dist/terraform-cdk/constructs/ecs-service.js +238 -0
  76. package/dist/terraform-cdk/interfaces/ecs-service-config.d.ts +53 -0
  77. package/dist/terraform-cdk/interfaces/ecs-service-config.js +25 -0
  78. package/dist/terraform-cdk/interfaces/infrastructure-refs.d.ts +16 -0
  79. package/dist/terraform-cdk/interfaces/infrastructure-refs.js +8 -0
  80. package/dist/terraform-cdk/utils/priority-tracker.d.ts +60 -0
  81. package/dist/terraform-cdk/utils/priority-tracker.js +131 -0
  82. package/package.json +46 -0
@@ -0,0 +1,682 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.EcsServiceConstruct = void 0;
37
+ const constructs_1 = require("constructs");
38
+ const cdk = __importStar(require("aws-cdk-lib"));
39
+ const aws_cdk_lib_1 = require("aws-cdk-lib");
40
+ const ec2 = __importStar(require("aws-cdk-lib/aws-ec2"));
41
+ const ecs = __importStar(require("aws-cdk-lib/aws-ecs"));
42
+ const elbv2 = __importStar(require("aws-cdk-lib/aws-elasticloadbalancingv2"));
43
+ const logs = __importStar(require("aws-cdk-lib/aws-logs"));
44
+ const iam = __importStar(require("aws-cdk-lib/aws-iam"));
45
+ const ecr = __importStar(require("aws-cdk-lib/aws-ecr"));
46
+ /**
47
+ * ECS Service Construct - สร้าง Fargate Service + SecurityGroup + LogGroup + TargetGroup
48
+ *
49
+ * Modes:
50
+ * - web: สร้าง TargetGroup + ListenerRule ที่ ALB
51
+ * - worker: สร้างแค่ Service (ไม่มี ALB integration)
52
+ *
53
+ * อ้างอิง VpcConstruct, EcsClusterConstruct, AlbConstruct จาก stacks อื่น
54
+ */
55
+ class EcsServiceConstruct extends constructs_1.Construct {
56
+ constructor(scope, id, props) {
57
+ super(scope, id);
58
+ const { config, vpcConstruct, ecsClusterConstruct, albConstruct, efsConstruct, rdsConstruct, elastiCacheConstruct } = props;
59
+ this.isWorkerMode = config.serviceMode === 'worker';
60
+ this.removalPolicy = config.removalPolicy === 'retain'
61
+ ? aws_cdk_lib_1.RemovalPolicy.RETAIN
62
+ : aws_cdk_lib_1.RemovalPolicy.DESTROY;
63
+ // Validate: web mode requires ALB construct + routing
64
+ if (!this.isWorkerMode) {
65
+ if (!albConstruct) {
66
+ throw new Error(`ECS Service "${config.container.name}" is web mode but no ALB construct provided. ` +
67
+ `Set serviceMode: 'worker' or provide albStackName in source.`);
68
+ }
69
+ if (!config.routing) {
70
+ throw new Error(`ECS Service "${config.container.name}" is web mode but no routing config provided.`);
71
+ }
72
+ }
73
+ // A. Resolve VPC & Subnets
74
+ const { vpc, subnets } = this.resolveVpc(config, vpcConstruct);
75
+ // B. Create Security Group
76
+ this.securityGroup = this.createSecurityGroup(config, vpc, albConstruct);
77
+ // C. Create Log Group
78
+ this.logGroup = this.createLogGroup(config);
79
+ // D. Resolve IAM Roles
80
+ const { taskRole, executionRole } = this.resolveIamRoles(config);
81
+ // E. Create Task Definition
82
+ const taskDef = this.createTaskDefinition(config, taskRole, executionRole);
83
+ // F. Create Fargate Service
84
+ this.service = this.createFargateService(config, ecsClusterConstruct, taskDef, subnets);
85
+ // G. Create Target Group + Listener Rule (web mode only)
86
+ if (!this.isWorkerMode && albConstruct && config.routing) {
87
+ this.targetGroup = this.createTargetGroup(config, vpc);
88
+ this.createListenerRule(config, albConstruct);
89
+ }
90
+ // H. Setup upstream SG access (EFS / RDS / Redis)
91
+ this.setupUpstreamSecurityAccess(config, efsConstruct, rdsConstruct, elastiCacheConstruct);
92
+ // I. Apply Removal Policy
93
+ this.securityGroup.applyRemovalPolicy(this.removalPolicy);
94
+ this.logGroup.applyRemovalPolicy(this.removalPolicy);
95
+ // J. Outputs
96
+ this.createOutputs(config);
97
+ }
98
+ // ==========================================
99
+ // Upstream SG Access (EFS / RDS / Redis)
100
+ // ==========================================
101
+ /**
102
+ * เพิ่ม ingress rule ที่ EFS/RDS/Redis SG ให้ ECS SG เข้าถึงได้
103
+ * ใช้ CfnSecurityGroupIngress เพื่อหลีกเลี่ยง cyclic dependency
104
+ */
105
+ setupUpstreamSecurityAccess(config, efsConstruct, rdsConstruct, elastiCacheConstruct) {
106
+ // ECS → EFS (NFS port 2049)
107
+ if (efsConstruct) {
108
+ new ec2.CfnSecurityGroupIngress(this, 'EfsNfsIngress', {
109
+ groupId: efsConstruct.securityGroup.securityGroupId,
110
+ sourceSecurityGroupId: this.securityGroup.securityGroupId,
111
+ ipProtocol: 'tcp',
112
+ fromPort: 2049,
113
+ toPort: 2049,
114
+ description: `Allow NFS from ECS ${config.container.name}`,
115
+ });
116
+ }
117
+ // ECS → RDS (MySQL/MariaDB/PostgreSQL port)
118
+ if (rdsConstruct) {
119
+ const dbPort = config.container.environmentVariables?.WORDPRESS_DB_PORT
120
+ ? parseInt(config.container.environmentVariables.WORDPRESS_DB_PORT)
121
+ : 3306;
122
+ new ec2.CfnSecurityGroupIngress(this, 'RdsDbIngress', {
123
+ groupId: rdsConstruct.securityGroup.securityGroupId,
124
+ sourceSecurityGroupId: this.securityGroup.securityGroupId,
125
+ ipProtocol: 'tcp',
126
+ fromPort: dbPort,
127
+ toPort: dbPort,
128
+ description: `Allow DB from ECS ${config.container.name}`,
129
+ });
130
+ }
131
+ // ECS → Redis (port 6379)
132
+ if (elastiCacheConstruct) {
133
+ const redisPort = config.container.environmentVariables?.WP_REDIS_PORT
134
+ ? parseInt(config.container.environmentVariables.WP_REDIS_PORT)
135
+ : 6379;
136
+ new ec2.CfnSecurityGroupIngress(this, 'RedisIngress', {
137
+ groupId: elastiCacheConstruct.securityGroup.securityGroupId,
138
+ sourceSecurityGroupId: this.securityGroup.securityGroupId,
139
+ ipProtocol: 'tcp',
140
+ fromPort: redisPort,
141
+ toPort: redisPort,
142
+ description: `Allow Redis from ECS ${config.container.name}`,
143
+ });
144
+ }
145
+ }
146
+ // ==========================================
147
+ // Resolve VPC & Subnets
148
+ // ==========================================
149
+ resolveVpc(config, vpcConstruct) {
150
+ const subnetName = config.source.subnetName ?? 'private';
151
+ const cfnSubnets = vpcConstruct.subnetsByName.get(subnetName);
152
+ if (!cfnSubnets || cfnSubnets.length === 0) {
153
+ throw new Error(`No subnets found with name "${subnetName}" in VPC construct. ` +
154
+ `Available: ${Array.from(vpcConstruct.subnetsByName.keys()).join(', ')}`);
155
+ }
156
+ // Build subnets with routeTableId from VpcConstruct
157
+ const subnetIds = [];
158
+ const subnets = [];
159
+ const allAzs = new Set();
160
+ const routeTableIds = [];
161
+ vpcConstruct.subnets.forEach((cfnSubnet, subnetKey) => {
162
+ if (subnetKey.startsWith(`${subnetName}-`)) {
163
+ const az = subnetKey.replace(`${subnetName}-`, '');
164
+ const routeTableId = vpcConstruct.subnetRouteTableMap.get(subnetKey);
165
+ subnetIds.push(cfnSubnet.ref);
166
+ if (cfnSubnet.availabilityZone)
167
+ allAzs.add(cfnSubnet.availabilityZone);
168
+ if (routeTableId)
169
+ routeTableIds.push(routeTableId);
170
+ subnets.push(ec2.Subnet.fromSubnetAttributes(this, `ServiceSubnet-${az}`, {
171
+ subnetId: cfnSubnet.ref,
172
+ availabilityZone: cfnSubnet.availabilityZone,
173
+ routeTableId,
174
+ }));
175
+ }
176
+ });
177
+ const hasRtIds = routeTableIds.length === subnetIds.length;
178
+ const vpc = ec2.Vpc.fromVpcAttributes(this, 'ServiceVpc', {
179
+ vpcId: vpcConstruct.vpc.ref,
180
+ availabilityZones: Array.from(allAzs),
181
+ privateSubnetIds: subnetIds,
182
+ privateSubnetRouteTableIds: hasRtIds ? routeTableIds : undefined,
183
+ });
184
+ return { vpc, subnets };
185
+ }
186
+ // ==========================================
187
+ // Security Group
188
+ // ==========================================
189
+ createSecurityGroup(config, vpc, albConstruct) {
190
+ const sgConfig = config.securityGroup ?? {};
191
+ const serviceType = this.isWorkerMode ? 'worker' : 'web';
192
+ const sg = new ec2.SecurityGroup(this, 'ServiceSecurityGroup', {
193
+ vpc,
194
+ securityGroupName: config.naming?.securityGroupName,
195
+ description: `Security group for ${config.container.name} ECS ${serviceType} service`,
196
+ allowAllOutbound: true,
197
+ });
198
+ // Allow traffic from ALB (web mode only)
199
+ if (!this.isWorkerMode && albConstruct) {
200
+ const allowAll = sgConfig.allowAllFromAlb !== false; // default: true
201
+ if (allowAll) {
202
+ // ใช้ CfnSecurityGroupIngress เพื่อหลีกเลี่ยง circular dependency
203
+ new ec2.CfnSecurityGroupIngress(this, 'AlbToServiceIngress', {
204
+ ipProtocol: 'tcp',
205
+ fromPort: 0,
206
+ toPort: 65535,
207
+ groupId: sg.securityGroupId,
208
+ sourceSecurityGroupId: albConstruct.securityGroup.securityGroupId,
209
+ description: 'Allow all TCP traffic from ALB',
210
+ });
211
+ }
212
+ else {
213
+ new ec2.CfnSecurityGroupIngress(this, 'AlbToServiceIngress', {
214
+ ipProtocol: 'tcp',
215
+ fromPort: config.container.port,
216
+ toPort: config.container.port,
217
+ groupId: sg.securityGroupId,
218
+ sourceSecurityGroupId: albConstruct.securityGroup.securityGroupId,
219
+ description: `Allow traffic from ALB on container port ${config.container.port}`,
220
+ });
221
+ }
222
+ }
223
+ // Additional source security groups
224
+ if (sgConfig.additionalSourceSgIds) {
225
+ sgConfig.additionalSourceSgIds.forEach((sourceSgId, index) => {
226
+ new ec2.CfnSecurityGroupIngress(this, `AdditionalSgIngress${index + 1}`, {
227
+ ipProtocol: 'tcp',
228
+ fromPort: 0,
229
+ toPort: 65535,
230
+ groupId: sg.securityGroupId,
231
+ sourceSecurityGroupId: sourceSgId,
232
+ description: `Allow all TCP from additional SG ${index + 1}`,
233
+ });
234
+ });
235
+ }
236
+ // CIDR rules
237
+ if (sgConfig.allowFromCidrs) {
238
+ sgConfig.allowFromCidrs.forEach((cidr, index) => {
239
+ sg.addIngressRule(ec2.Peer.ipv4(cidr), ec2.Port.allTcp(), `Allow all TCP from CIDR ${index + 1}: ${cidr}`);
240
+ });
241
+ }
242
+ return sg;
243
+ }
244
+ // ==========================================
245
+ // IAM Roles
246
+ // ==========================================
247
+ resolveIamRoles(config) {
248
+ const iamConfig = config.iam;
249
+ let taskRole;
250
+ let executionRole;
251
+ // ── Task Execution Role ──
252
+ if (iamConfig?.executionRoleArn) {
253
+ executionRole = iam.Role.fromRoleArn(this, 'ImportedExecRole', iamConfig.executionRoleArn, {
254
+ mutable: false,
255
+ });
256
+ }
257
+ else if (iamConfig?.executionRolePresets && iamConfig.executionRolePresets.length > 0) {
258
+ // สร้าง Execution Role เอง + เพิ่ม preset policies
259
+ const execRole = new iam.Role(this, 'ExecutionRole', {
260
+ roleName: config.naming?.executionRoleName,
261
+ assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
262
+ description: `Execution Role for ${config.container.name} ECS service`,
263
+ managedPolicies: [
264
+ iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonECSTaskExecutionRolePolicy'),
265
+ ],
266
+ });
267
+ // Add execution role preset policies
268
+ this.applyExecutionRolePresets(execRole, iamConfig.executionRolePresets);
269
+ execRole.applyRemovalPolicy(this.removalPolicy);
270
+ executionRole = execRole;
271
+ }
272
+ // ถ้าไม่ระบุ CDK จะ auto-create พร้อม AmazonECSTaskExecutionRolePolicy
273
+ // ── Task Role ──
274
+ const hasPresets = iamConfig?.taskRolePresets && iamConfig.taskRolePresets.length > 0;
275
+ const hasInlinePolicies = iamConfig?.taskRolePolicies && iamConfig.taskRolePolicies.length > 0;
276
+ const hasManagedPolicies = iamConfig?.taskRoleManagedPolicyArns && iamConfig.taskRoleManagedPolicyArns.length > 0;
277
+ if (iamConfig?.taskRoleArn) {
278
+ // Import existing role
279
+ taskRole = iam.Role.fromRoleArn(this, 'ImportedTaskRole', iamConfig.taskRoleArn, {
280
+ mutable: false,
281
+ });
282
+ }
283
+ else if (hasPresets || hasInlinePolicies || hasManagedPolicies) {
284
+ // สร้าง Task Role พร้อม preset + inline + managed policies
285
+ const role = new iam.Role(this, 'TaskRole', {
286
+ roleName: config.naming?.taskRoleName,
287
+ assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
288
+ description: `Task Role for ${config.container.name} ECS service`,
289
+ });
290
+ // Apply preset policies
291
+ if (hasPresets) {
292
+ this.applyTaskRolePresets(role, iamConfig.taskRolePresets);
293
+ }
294
+ // Add inline policies
295
+ if (hasInlinePolicies) {
296
+ iamConfig.taskRolePolicies.forEach((policy, index) => {
297
+ role.addToPolicy(new iam.PolicyStatement({
298
+ effect: policy.effect === 'Deny' ? iam.Effect.DENY : iam.Effect.ALLOW,
299
+ actions: policy.actions,
300
+ resources: policy.resources,
301
+ }));
302
+ });
303
+ }
304
+ // Attach managed policies
305
+ if (hasManagedPolicies) {
306
+ iamConfig.taskRoleManagedPolicyArns.forEach((arn, index) => {
307
+ role.addManagedPolicy(iam.ManagedPolicy.fromManagedPolicyArn(this, `ManagedPolicy${index + 1}`, arn));
308
+ });
309
+ }
310
+ role.applyRemovalPolicy(this.removalPolicy);
311
+ taskRole = role;
312
+ }
313
+ // ถ้าไม่ระบุอะไรเลย CDK จะ auto-create empty role
314
+ return { taskRole, executionRole };
315
+ }
316
+ // ==========================================
317
+ // IAM Preset Policies
318
+ // ==========================================
319
+ /**
320
+ * Apply preset policies ให้ Task Role
321
+ * ใช้ AWS Managed Policies เป็นหลัก, fallback เป็น inline สำหรับ services ที่ไม่มี managed policy ที่เหมาะสม
322
+ */
323
+ applyTaskRolePresets(role, presets) {
324
+ // AWS Managed Policy ARNs mapping
325
+ const managedPolicyMap = {
326
+ 'ssm-parameters': 'arn:aws:iam::aws:policy/AmazonSSMReadOnlyAccess',
327
+ 'sqs': 'arn:aws:iam::aws:policy/AmazonSQSFullAccess',
328
+ 's3-read': 'arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess',
329
+ 's3-full': 'arn:aws:iam::aws:policy/AmazonS3FullAccess',
330
+ 'dynamodb': 'arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess',
331
+ 'sns': 'arn:aws:iam::aws:policy/AmazonSNSFullAccess',
332
+ 'ses': 'arn:aws:iam::aws:policy/AmazonSESFullAccess',
333
+ 'xray': 'arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess',
334
+ 'cloudwatch-metrics': 'arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy',
335
+ 'bedrock': 'arn:aws:iam::aws:policy/AmazonBedrockFullAccess',
336
+ };
337
+ // Inline policies สำหรับ services ที่ไม่มี managed policy ที่เหมาะสม
338
+ const inlinePolicyMap = {
339
+ 'secrets-manager': {
340
+ actions: [
341
+ 'secretsmanager:GetSecretValue',
342
+ 'secretsmanager:DescribeSecret',
343
+ 'secretsmanager:ListSecretVersionIds',
344
+ ],
345
+ resources: ['*'],
346
+ },
347
+ 'rds-connect': {
348
+ actions: ['rds-db:connect'],
349
+ resources: ['*'],
350
+ },
351
+ 'ecs-exec': {
352
+ actions: [
353
+ 'ssmmessages:CreateControlChannel',
354
+ 'ssmmessages:CreateDataChannel',
355
+ 'ssmmessages:OpenControlChannel',
356
+ 'ssmmessages:OpenDataChannel',
357
+ ],
358
+ resources: ['*'],
359
+ },
360
+ 'kms': {
361
+ actions: [
362
+ 'kms:Decrypt',
363
+ 'kms:GenerateDataKey',
364
+ 'kms:DescribeKey',
365
+ ],
366
+ resources: ['*'],
367
+ },
368
+ 'efs': {
369
+ actions: [
370
+ 'elasticfilesystem:ClientMount',
371
+ 'elasticfilesystem:ClientWrite',
372
+ 'elasticfilesystem:ClientRootAccess',
373
+ ],
374
+ resources: ['*'],
375
+ },
376
+ 'elasticache': {
377
+ actions: [
378
+ 'elasticache:Connect',
379
+ 'elasticache:DescribeCacheClusters',
380
+ 'elasticache:DescribeReplicationGroups',
381
+ 'elasticache:DescribeCacheSubnetGroups',
382
+ ],
383
+ resources: ['*'],
384
+ },
385
+ };
386
+ const allKnownPresets = [...Object.keys(managedPolicyMap), ...Object.keys(inlinePolicyMap)];
387
+ presets.forEach((presetName) => {
388
+ // ลองหา managed policy ก่อน
389
+ const managedPolicyArn = managedPolicyMap[presetName];
390
+ if (managedPolicyArn) {
391
+ role.addManagedPolicy(iam.ManagedPolicy.fromManagedPolicyArn(this, `ManagedPolicy-${presetName}`, managedPolicyArn));
392
+ return;
393
+ }
394
+ // ถ้าไม่มี managed policy ใช้ inline
395
+ const inlinePolicy = inlinePolicyMap[presetName];
396
+ if (inlinePolicy) {
397
+ role.addToPolicy(new iam.PolicyStatement({
398
+ effect: iam.Effect.ALLOW,
399
+ actions: inlinePolicy.actions,
400
+ resources: inlinePolicy.resources,
401
+ }));
402
+ return;
403
+ }
404
+ throw new Error(`Unknown Task Role preset policy: "${presetName}". Available: ${allKnownPresets.join(', ')}`);
405
+ });
406
+ }
407
+ /**
408
+ * Apply preset policies ให้ Execution Role
409
+ */
410
+ applyExecutionRolePresets(role, presets) {
411
+ const presetMap = {
412
+ 'exec-secrets-injection': {
413
+ actions: [
414
+ 'secretsmanager:GetSecretValue',
415
+ ],
416
+ resources: ['*'],
417
+ },
418
+ 'exec-ssm-injection': {
419
+ actions: [
420
+ 'ssm:GetParameters',
421
+ ],
422
+ resources: ['*'],
423
+ },
424
+ 'exec-kms': {
425
+ actions: [
426
+ 'kms:Decrypt',
427
+ ],
428
+ resources: ['*'],
429
+ },
430
+ };
431
+ presets.forEach((presetName) => {
432
+ const preset = presetMap[presetName];
433
+ if (!preset) {
434
+ throw new Error(`Unknown Execution Role preset policy: "${presetName}". Available: ${Object.keys(presetMap).join(', ')}`);
435
+ }
436
+ role.addToPolicy(new iam.PolicyStatement({
437
+ effect: iam.Effect.ALLOW,
438
+ actions: preset.actions,
439
+ resources: preset.resources,
440
+ }));
441
+ });
442
+ }
443
+ // ==========================================
444
+ // Log Group
445
+ // ==========================================
446
+ createLogGroup(config) {
447
+ const logConfig = config.logging ?? {};
448
+ const logRemoval = logConfig.removalPolicy === 'retain'
449
+ ? aws_cdk_lib_1.RemovalPolicy.RETAIN
450
+ : aws_cdk_lib_1.RemovalPolicy.DESTROY;
451
+ return new logs.LogGroup(this, 'LogGroup', {
452
+ logGroupName: `/ecs/${config.stackName}/${config.container.name}`,
453
+ retention: logConfig.retention ?? logs.RetentionDays.ONE_WEEK,
454
+ removalPolicy: logRemoval,
455
+ });
456
+ }
457
+ // ==========================================
458
+ // Task Definition
459
+ // ==========================================
460
+ createTaskDefinition(config, taskRole, executionRole) {
461
+ const { container } = config;
462
+ // Use custom family name or generate from container name + stack name
463
+ const familyName = config.naming?.taskDefinitionFamily ?? `${container.name}-${config.stackName}`;
464
+ const taskDef = new ecs.FargateTaskDefinition(this, 'TaskDefinition', {
465
+ family: familyName,
466
+ cpu: container.cpu,
467
+ memoryLimitMiB: container.memory,
468
+ ephemeralStorageGiB: container.ephemeralStorageGiB,
469
+ taskRole,
470
+ executionRole,
471
+ });
472
+ const image = this.resolveContainerImage(container.image);
473
+ // initProcessEnabled จำเป็นสำหรับ ECS Exec (ใช้ tini เป็น PID 1)
474
+ const initProcessEnabled = container.initProcessEnabled ?? config.enableExecuteCommand ?? true;
475
+ const containerDef = taskDef.addContainer('MainContainer', {
476
+ image,
477
+ containerName: container.name,
478
+ logging: ecs.LogDrivers.awsLogs({
479
+ logGroup: this.logGroup,
480
+ streamPrefix: 'ecs',
481
+ }),
482
+ environment: container.environmentVariables,
483
+ command: container.command,
484
+ secrets: container.secrets
485
+ ? Object.fromEntries(Object.entries(container.secrets).map(([envName, secretValue]) => {
486
+ // Support format: 'arn:...:secret:name-suffix:jsonField'
487
+ // Secrets Manager ARN has exactly 6 parts separated by ':'
488
+ // arn:aws:secretsmanager:region:account:secret:name-suffix
489
+ // If there's a 7th part, it's the JSON field name
490
+ //
491
+ // When the ARN is a CDK Token (cross-stack reference), we can't split by ':'
492
+ // because the token is not a real ARN string at synth time.
493
+ // Use a separator that won't appear in ARNs: '::' (double colon)
494
+ let secretArn;
495
+ let jsonField;
496
+ if (secretValue.includes('::')) {
497
+ // New format: 'arn:...:secret:name::jsonField' (double colon separator)
498
+ const separatorIndex = secretValue.indexOf('::');
499
+ secretArn = secretValue.substring(0, separatorIndex);
500
+ jsonField = secretValue.substring(separatorIndex + 2);
501
+ }
502
+ else if (!cdk.Token.isUnresolved(secretValue)) {
503
+ // Legacy format: only works with resolved (literal) strings
504
+ const parts = secretValue.split(':');
505
+ if (parts.length > 7) {
506
+ secretArn = parts.slice(0, 7).join(':');
507
+ jsonField = parts.slice(7).join(':');
508
+ }
509
+ else {
510
+ secretArn = secretValue;
511
+ }
512
+ }
513
+ else {
514
+ // Token without '::' separator — treat entire value as ARN (no JSON field)
515
+ secretArn = secretValue;
516
+ }
517
+ const secret = cdk.aws_secretsmanager.Secret.fromSecretCompleteArn(this, `Secret-${envName}`, secretArn);
518
+ return [
519
+ envName,
520
+ jsonField
521
+ ? ecs.Secret.fromSecretsManager(secret, jsonField)
522
+ : ecs.Secret.fromSecretsManager(secret),
523
+ ];
524
+ }))
525
+ : undefined,
526
+ linuxParameters: new ecs.LinuxParameters(this, 'LinuxParams', {
527
+ initProcessEnabled,
528
+ }),
529
+ });
530
+ containerDef.addPortMappings({
531
+ containerPort: container.port,
532
+ protocol: ecs.Protocol.TCP,
533
+ });
534
+ return taskDef;
535
+ }
536
+ // ==========================================
537
+ // Resolve Container Image
538
+ // ==========================================
539
+ /**
540
+ * ถ้า image URL เป็น ECR pattern (*.dkr.ecr.*.amazonaws.com/repo:tag)
541
+ * ใช้ fromEcrRepository เพื่อให้ CDK auto-grant ECR pull policy
542
+ * ไม่งั้นใช้ fromRegistry ปกติ (Docker Hub, public ECR, etc.)
543
+ */
544
+ resolveContainerImage(imageUri) {
545
+ // Local path pattern: starts with './', '../', or '/' → use fromAsset (CDK builds & pushes to ECR)
546
+ if (imageUri.startsWith('./') || imageUri.startsWith('../') || imageUri.startsWith('/')) {
547
+ return ecs.ContainerImage.fromAsset(imageUri);
548
+ }
549
+ // ECR private pattern: {account}.dkr.ecr.{region}.amazonaws.com/{repo}:{tag}
550
+ const ecrMatch = imageUri.match(/^(\d+)\.dkr\.ecr\.([^.]+)\.amazonaws\.com\/([^:]+)(?::(.+))?$/);
551
+ if (ecrMatch) {
552
+ const [, , region, repoName, tag] = ecrMatch;
553
+ const repo = ecr.Repository.fromRepositoryAttributes(this, 'EcrRepo', {
554
+ repositoryArn: `arn:aws:ecr:${region}:${cdk.Stack.of(this).account}:repository/${repoName}`,
555
+ repositoryName: repoName,
556
+ });
557
+ return ecs.ContainerImage.fromEcrRepository(repo, tag ?? 'latest');
558
+ }
559
+ return ecs.ContainerImage.fromRegistry(imageUri);
560
+ }
561
+ // ==========================================
562
+ // Fargate Service
563
+ // ==========================================
564
+ createFargateService(config, ecsClusterConstruct, taskDef, subnets) {
565
+ return new ecs.FargateService(this, 'FargateService', {
566
+ cluster: ecsClusterConstruct.cluster,
567
+ taskDefinition: taskDef,
568
+ desiredCount: config.container.desiredCount,
569
+ securityGroups: [this.securityGroup],
570
+ vpcSubnets: { subnets },
571
+ serviceName: `${config.container.name}`,
572
+ enableExecuteCommand: config.enableExecuteCommand ?? true,
573
+ circuitBreaker: (config.circuitBreakerEnabled ?? true) ? { rollback: true } : undefined,
574
+ minHealthyPercent: config.minHealthyPercent ?? 50,
575
+ maxHealthyPercent: config.maxHealthyPercent ?? 200,
576
+ assignPublicIp: config.assignPublicIp ?? false,
577
+ });
578
+ }
579
+ // ==========================================
580
+ // Target Group (web mode only)
581
+ // ==========================================
582
+ createTargetGroup(config, vpc) {
583
+ const routing = config.routing;
584
+ // Target Group name: use custom name or generate from container name (max 32 chars)
585
+ let tgName;
586
+ if (config.naming?.targetGroupName) {
587
+ tgName = config.naming.targetGroupName.substring(0, 32);
588
+ }
589
+ else {
590
+ const tgNameBase = `${config.container.name}`;
591
+ tgName = tgNameBase.length > 29
592
+ ? `${tgNameBase.substring(0, 29)}-tg`
593
+ : `${tgNameBase}-tg`;
594
+ }
595
+ return new elbv2.ApplicationTargetGroup(this, 'TargetGroup', {
596
+ vpc,
597
+ targetGroupName: tgName,
598
+ port: config.container.port,
599
+ protocol: elbv2.ApplicationProtocol.HTTP,
600
+ targets: [this.service],
601
+ targetType: elbv2.TargetType.IP,
602
+ healthCheck: {
603
+ path: routing.healthCheckPath,
604
+ interval: cdk.Duration.seconds(routing.healthCheckInterval ?? 30),
605
+ timeout: cdk.Duration.seconds(routing.healthCheckTimeout ?? 5),
606
+ healthyThresholdCount: routing.healthyThresholdCount ?? 2,
607
+ unhealthyThresholdCount: routing.unhealthyThresholdCount ?? 3,
608
+ },
609
+ deregistrationDelay: cdk.Duration.seconds(routing.deregistrationDelay ?? 30),
610
+ });
611
+ }
612
+ // ==========================================
613
+ // Listener Rule (web mode only)
614
+ // ==========================================
615
+ createListenerRule(config, albConstruct) {
616
+ const routing = config.routing;
617
+ // ใช้ HTTPS listener (ถ้ามี), fallback ไป HTTP
618
+ const listener = albConstruct.httpsListener ?? albConstruct.httpListener;
619
+ if (!listener) {
620
+ throw new Error('ALB has no listeners available for ECS Service routing');
621
+ }
622
+ const conditions = [];
623
+ // Host header condition (optional)
624
+ if (routing.domainName && routing.domainName !== '*') {
625
+ conditions.push(elbv2.ListenerCondition.hostHeaders([routing.domainName]));
626
+ }
627
+ // Path pattern condition
628
+ conditions.push(elbv2.ListenerCondition.pathPatterns([routing.pathPattern]));
629
+ return new elbv2.ApplicationListenerRule(this, 'ListenerRule', {
630
+ listener,
631
+ priority: routing.priority,
632
+ conditions,
633
+ targetGroups: [this.targetGroup],
634
+ });
635
+ }
636
+ // ==========================================
637
+ // Outputs
638
+ // ==========================================
639
+ createOutputs(config) {
640
+ const stack = cdk.Stack.of(this);
641
+ const prefix = config.stackName;
642
+ const svcName = config.container.name;
643
+ new cdk.CfnOutput(stack, `${svcName}-ServiceArn`, {
644
+ value: this.service.serviceArn,
645
+ description: `ECS Service ARN (${svcName})`,
646
+ exportName: `${prefix}-${svcName}-Service-Arn`,
647
+ });
648
+ new cdk.CfnOutput(stack, `${svcName}-ServiceName`, {
649
+ value: this.service.serviceName,
650
+ description: `ECS Service Name (${svcName})`,
651
+ exportName: `${prefix}-${svcName}-Service-Name`,
652
+ });
653
+ if (!this.isWorkerMode && this.targetGroup) {
654
+ new cdk.CfnOutput(stack, `${svcName}-TargetGroupArn`, {
655
+ value: this.targetGroup.targetGroupArn,
656
+ description: `Target Group ARN (${svcName})`,
657
+ exportName: `${prefix}-${svcName}-TargetGroup-Arn`,
658
+ });
659
+ }
660
+ new cdk.CfnOutput(stack, `${svcName}-SecurityGroupId`, {
661
+ value: this.securityGroup.securityGroupId,
662
+ description: `Service Security Group ID (${svcName})`,
663
+ exportName: `${prefix}-${svcName}-SecurityGroup-Id`,
664
+ });
665
+ new cdk.CfnOutput(stack, `${svcName}-ServiceMode`, {
666
+ value: this.isWorkerMode ? 'worker' : 'web',
667
+ description: `Service Mode (${svcName})`,
668
+ });
669
+ // ECS Exec command
670
+ if (config.enableExecuteCommand !== false) {
671
+ new cdk.CfnOutput(stack, `${svcName}-EcsExecCommand`, {
672
+ value: `aws ecs execute-command --cluster ${config.source.ecsClusterStackName} --task TASK_ID --container ${svcName} --interactive --command "/bin/sh"`,
673
+ description: `ECS Exec command - replace TASK_ID with actual task ID (${svcName})`,
674
+ });
675
+ new cdk.CfnOutput(stack, `${svcName}-ListTasksCommand`, {
676
+ value: `aws ecs list-tasks --cluster ${config.source.ecsClusterStackName} --service-name ${svcName} --query 'taskArns[0]' --output text`,
677
+ description: `List running tasks to get TASK_ID (${svcName})`,
678
+ });
679
+ }
680
+ }
681
+ }
682
+ exports.EcsServiceConstruct = EcsServiceConstruct;