cdk-factory 0.8.7__py3-none-any.whl → 0.9.0__py3-none-any.whl
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.
- cdk_factory/app.py +132 -2
- cdk_factory/cli.py +200 -0
- cdk_factory/configurations/resources/ecr.py +47 -2
- cdk_factory/configurations/resources/ecs_service.py +144 -0
- cdk_factory/constructs/ecr/ecr_construct.py +125 -53
- cdk_factory/pipeline/pipeline_factory.py +23 -2
- cdk_factory/stack_library/ecs/__init__.py +0 -0
- cdk_factory/stack_library/ecs/ecs_service_stack.py +525 -0
- cdk_factory/templates/README.md +99 -0
- cdk_factory/templates/app.py.template +36 -0
- cdk_factory/templates/cdk.json.template +73 -0
- cdk_factory/version.py +1 -1
- {cdk_factory-0.8.7.dist-info → cdk_factory-0.9.0.dist-info}/METADATA +1 -1
- {cdk_factory-0.8.7.dist-info → cdk_factory-0.9.0.dist-info}/RECORD +17 -9
- cdk_factory-0.9.0.dist-info/entry_points.txt +2 -0
- {cdk_factory-0.8.7.dist-info → cdk_factory-0.9.0.dist-info}/WHEEL +0 -0
- {cdk_factory-0.8.7.dist-info → cdk_factory-0.9.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ECS Service Stack Pattern for CDK-Factory
|
|
3
|
+
Supports Fargate and EC2 launch types with auto-scaling, load balancing, and blue-green deployments.
|
|
4
|
+
Maintainers: Eric Wilson
|
|
5
|
+
MIT License. See Project Root for the license information.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Dict, Any, List, Optional
|
|
9
|
+
|
|
10
|
+
import aws_cdk as cdk
|
|
11
|
+
from aws_cdk import (
|
|
12
|
+
aws_ecs as ecs,
|
|
13
|
+
aws_ec2 as ec2,
|
|
14
|
+
aws_logs as logs,
|
|
15
|
+
aws_iam as iam,
|
|
16
|
+
aws_elasticloadbalancingv2 as elbv2,
|
|
17
|
+
aws_applicationautoscaling as appscaling,
|
|
18
|
+
)
|
|
19
|
+
from aws_lambda_powertools import Logger
|
|
20
|
+
from constructs import Construct
|
|
21
|
+
|
|
22
|
+
from cdk_factory.configurations.deployment import DeploymentConfig
|
|
23
|
+
from cdk_factory.configurations.stack import StackConfig
|
|
24
|
+
from cdk_factory.configurations.resources.ecs_service import EcsServiceConfig
|
|
25
|
+
from cdk_factory.interfaces.istack import IStack
|
|
26
|
+
from cdk_factory.interfaces.enhanced_ssm_parameter_mixin import EnhancedSsmParameterMixin
|
|
27
|
+
from cdk_factory.stack.stack_module_registry import register_stack
|
|
28
|
+
from cdk_factory.workload.workload_factory import WorkloadConfig
|
|
29
|
+
|
|
30
|
+
logger = Logger(service="EcsServiceStack")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@register_stack("ecs_service_library_module")
|
|
34
|
+
@register_stack("ecs_service_stack")
|
|
35
|
+
@register_stack("fargate_service_stack")
|
|
36
|
+
class EcsServiceStack(IStack, EnhancedSsmParameterMixin):
|
|
37
|
+
"""
|
|
38
|
+
Reusable stack for ECS/Fargate services with Docker container support.
|
|
39
|
+
Supports blue-green deployments, maintenance mode, and auto-scaling.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, scope: Construct, id: str, **kwargs) -> None:
|
|
43
|
+
super().__init__(scope, id, **kwargs)
|
|
44
|
+
self.ecs_config: Optional[EcsServiceConfig] = None
|
|
45
|
+
self.stack_config: Optional[StackConfig] = None
|
|
46
|
+
self.deployment: Optional[DeploymentConfig] = None
|
|
47
|
+
self.workload: Optional[WorkloadConfig] = None
|
|
48
|
+
self.cluster: Optional[ecs.ICluster] = None
|
|
49
|
+
self.service: Optional[ecs.FargateService] = None
|
|
50
|
+
self.task_definition: Optional[ecs.FargateTaskDefinition] = None
|
|
51
|
+
self._vpc: Optional[ec2.IVpc] = None
|
|
52
|
+
|
|
53
|
+
def build(
|
|
54
|
+
self,
|
|
55
|
+
stack_config: StackConfig,
|
|
56
|
+
deployment: DeploymentConfig,
|
|
57
|
+
workload: WorkloadConfig,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Build the ECS Service stack"""
|
|
60
|
+
self._build(stack_config, deployment, workload)
|
|
61
|
+
|
|
62
|
+
def _build(
|
|
63
|
+
self,
|
|
64
|
+
stack_config: StackConfig,
|
|
65
|
+
deployment: DeploymentConfig,
|
|
66
|
+
workload: WorkloadConfig,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Internal build method for the ECS Service stack"""
|
|
69
|
+
self.stack_config = stack_config
|
|
70
|
+
self.deployment = deployment
|
|
71
|
+
self.workload = workload
|
|
72
|
+
|
|
73
|
+
# Load ECS configuration
|
|
74
|
+
self.ecs_config = EcsServiceConfig(
|
|
75
|
+
stack_config.dictionary.get("ecs_service", {})
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
service_name = deployment.build_resource_name(self.ecs_config.name)
|
|
79
|
+
|
|
80
|
+
# Load VPC
|
|
81
|
+
self._load_vpc()
|
|
82
|
+
|
|
83
|
+
# Create or load ECS cluster
|
|
84
|
+
self._create_or_load_cluster()
|
|
85
|
+
|
|
86
|
+
# Create task definition
|
|
87
|
+
self._create_task_definition(service_name)
|
|
88
|
+
|
|
89
|
+
# Create ECS service
|
|
90
|
+
self._create_service(service_name)
|
|
91
|
+
|
|
92
|
+
# Setup auto-scaling
|
|
93
|
+
if self.ecs_config.enable_auto_scaling:
|
|
94
|
+
self._setup_auto_scaling()
|
|
95
|
+
|
|
96
|
+
# Add outputs
|
|
97
|
+
self._add_outputs(service_name)
|
|
98
|
+
|
|
99
|
+
def _load_vpc(self) -> None:
|
|
100
|
+
"""Load VPC from configuration"""
|
|
101
|
+
vpc_id = self.ecs_config.vpc_id or self.workload.vpc_id
|
|
102
|
+
|
|
103
|
+
if not vpc_id:
|
|
104
|
+
raise ValueError("VPC ID is required for ECS service")
|
|
105
|
+
|
|
106
|
+
self._vpc = ec2.Vpc.from_lookup(
|
|
107
|
+
self,
|
|
108
|
+
"VPC",
|
|
109
|
+
vpc_id=vpc_id
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def _create_or_load_cluster(self) -> None:
|
|
113
|
+
"""Create a new ECS cluster or load an existing one"""
|
|
114
|
+
cluster_name = self.ecs_config.cluster_name
|
|
115
|
+
|
|
116
|
+
if cluster_name:
|
|
117
|
+
# Try to load existing cluster
|
|
118
|
+
try:
|
|
119
|
+
self.cluster = ecs.Cluster.from_cluster_attributes(
|
|
120
|
+
self,
|
|
121
|
+
"Cluster",
|
|
122
|
+
cluster_name=cluster_name,
|
|
123
|
+
vpc=self._vpc,
|
|
124
|
+
)
|
|
125
|
+
logger.info(f"Using existing cluster: {cluster_name}")
|
|
126
|
+
except Exception as e:
|
|
127
|
+
logger.warning(f"Could not load cluster {cluster_name}, creating new one: {e}")
|
|
128
|
+
self._create_new_cluster(cluster_name)
|
|
129
|
+
else:
|
|
130
|
+
# Create a new cluster with auto-generated name
|
|
131
|
+
cluster_name = f"{self.deployment.workload_name}-{self.deployment.environment}-cluster"
|
|
132
|
+
self._create_new_cluster(cluster_name)
|
|
133
|
+
|
|
134
|
+
def _create_new_cluster(self, cluster_name: str) -> None:
|
|
135
|
+
"""Create a new ECS cluster"""
|
|
136
|
+
self.cluster = ecs.Cluster(
|
|
137
|
+
self,
|
|
138
|
+
"Cluster",
|
|
139
|
+
cluster_name=cluster_name,
|
|
140
|
+
vpc=self._vpc,
|
|
141
|
+
container_insights=True,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
cdk.Tags.of(self.cluster).add("Name", cluster_name)
|
|
145
|
+
cdk.Tags.of(self.cluster).add("Environment", self.deployment.environment)
|
|
146
|
+
|
|
147
|
+
def _create_task_definition(self, service_name: str) -> None:
|
|
148
|
+
"""Create ECS task definition with container definitions"""
|
|
149
|
+
|
|
150
|
+
# Create task execution role
|
|
151
|
+
execution_role = iam.Role(
|
|
152
|
+
self,
|
|
153
|
+
"TaskExecutionRole",
|
|
154
|
+
assumed_by=iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
|
|
155
|
+
managed_policies=[
|
|
156
|
+
iam.ManagedPolicy.from_aws_managed_policy_name(
|
|
157
|
+
"service-role/AmazonECSTaskExecutionRolePolicy"
|
|
158
|
+
),
|
|
159
|
+
],
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Create task role for application permissions
|
|
163
|
+
task_role = iam.Role(
|
|
164
|
+
self,
|
|
165
|
+
"TaskRole",
|
|
166
|
+
assumed_by=iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Enable ECS Exec if configured
|
|
170
|
+
if self.ecs_config.enable_execute_command:
|
|
171
|
+
task_role.add_managed_policy(
|
|
172
|
+
iam.ManagedPolicy.from_aws_managed_policy_name(
|
|
173
|
+
"CloudWatchAgentServerPolicy"
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Create task definition based on launch type
|
|
178
|
+
if self.ecs_config.launch_type == "EC2":
|
|
179
|
+
# EC2 task definition
|
|
180
|
+
network_mode = self.ecs_config.task_definition.get("network_mode", "bridge")
|
|
181
|
+
self.task_definition = ecs.Ec2TaskDefinition(
|
|
182
|
+
self,
|
|
183
|
+
"TaskDefinition",
|
|
184
|
+
family=f"{service_name}-task",
|
|
185
|
+
network_mode=ecs.NetworkMode(network_mode.upper()) if network_mode else ecs.NetworkMode.BRIDGE,
|
|
186
|
+
execution_role=execution_role,
|
|
187
|
+
task_role=task_role,
|
|
188
|
+
)
|
|
189
|
+
else:
|
|
190
|
+
# Fargate task definition
|
|
191
|
+
self.task_definition = ecs.FargateTaskDefinition(
|
|
192
|
+
self,
|
|
193
|
+
"TaskDefinition",
|
|
194
|
+
family=f"{service_name}-task",
|
|
195
|
+
cpu=int(self.ecs_config.cpu),
|
|
196
|
+
memory_limit_mib=int(self.ecs_config.memory),
|
|
197
|
+
execution_role=execution_role,
|
|
198
|
+
task_role=task_role,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# Add containers
|
|
202
|
+
self._add_containers_to_task()
|
|
203
|
+
|
|
204
|
+
def _add_containers_to_task(self) -> None:
|
|
205
|
+
"""Add container definitions to the task"""
|
|
206
|
+
container_definitions = self.ecs_config.container_definitions
|
|
207
|
+
|
|
208
|
+
if not container_definitions:
|
|
209
|
+
raise ValueError("At least one container definition is required")
|
|
210
|
+
|
|
211
|
+
for idx, container_config in enumerate(container_definitions):
|
|
212
|
+
container_name = container_config.get("name", f"container-{idx}")
|
|
213
|
+
image_uri = container_config.get("image")
|
|
214
|
+
|
|
215
|
+
if not image_uri:
|
|
216
|
+
raise ValueError(f"Container image is required for {container_name}")
|
|
217
|
+
|
|
218
|
+
# Create log group for container
|
|
219
|
+
log_group = logs.LogGroup(
|
|
220
|
+
self,
|
|
221
|
+
f"LogGroup-{container_name}",
|
|
222
|
+
log_group_name=f"/ecs/{self.deployment.workload_name}/{self.deployment.environment}/{container_name}",
|
|
223
|
+
retention=logs.RetentionDays.ONE_WEEK,
|
|
224
|
+
removal_policy=cdk.RemovalPolicy.DESTROY,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# Build health check if configured
|
|
228
|
+
health_check_config = container_config.get("health_check")
|
|
229
|
+
health_check = None
|
|
230
|
+
if health_check_config:
|
|
231
|
+
health_check = ecs.HealthCheck(
|
|
232
|
+
command=health_check_config.get("command", ["CMD-SHELL", "exit 0"]),
|
|
233
|
+
interval=cdk.Duration.seconds(health_check_config.get("interval", 30)),
|
|
234
|
+
timeout=cdk.Duration.seconds(health_check_config.get("timeout", 5)),
|
|
235
|
+
retries=health_check_config.get("retries", 3),
|
|
236
|
+
start_period=cdk.Duration.seconds(health_check_config.get("start_period", 0)),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# Add container to task definition
|
|
240
|
+
container = self.task_definition.add_container(
|
|
241
|
+
container_name,
|
|
242
|
+
image=ecs.ContainerImage.from_registry(image_uri),
|
|
243
|
+
logging=ecs.LogDriver.aws_logs(
|
|
244
|
+
stream_prefix=container_name,
|
|
245
|
+
log_group=log_group,
|
|
246
|
+
),
|
|
247
|
+
environment=container_config.get("environment", {}),
|
|
248
|
+
secrets=self._load_secrets(container_config.get("secrets", {})),
|
|
249
|
+
cpu=container_config.get("cpu"),
|
|
250
|
+
memory_limit_mib=container_config.get("memory"),
|
|
251
|
+
memory_reservation_mib=container_config.get("memory_reservation"),
|
|
252
|
+
essential=container_config.get("essential", True),
|
|
253
|
+
health_check=health_check,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# Add port mappings
|
|
257
|
+
port_mappings = container_config.get("port_mappings", [])
|
|
258
|
+
for port_mapping in port_mappings:
|
|
259
|
+
container.add_port_mappings(
|
|
260
|
+
ecs.PortMapping(
|
|
261
|
+
container_port=port_mapping.get("container_port", 80),
|
|
262
|
+
protocol=ecs.Protocol.TCP,
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def _load_secrets(self, secrets_config: Dict[str, str]) -> Dict[str, ecs.Secret]:
|
|
267
|
+
"""Load secrets from Secrets Manager or SSM Parameter Store"""
|
|
268
|
+
secrets = {}
|
|
269
|
+
# Implement secret loading logic here
|
|
270
|
+
# This would integrate with AWS Secrets Manager or SSM Parameter Store
|
|
271
|
+
return secrets
|
|
272
|
+
|
|
273
|
+
def _create_service(self, service_name: str) -> None:
|
|
274
|
+
"""Create ECS service (Fargate or EC2)"""
|
|
275
|
+
|
|
276
|
+
# Load security groups
|
|
277
|
+
security_groups = self._load_security_groups()
|
|
278
|
+
|
|
279
|
+
# Load subnets
|
|
280
|
+
subnets = self._load_subnets()
|
|
281
|
+
|
|
282
|
+
# Create service based on launch type
|
|
283
|
+
if self.ecs_config.launch_type == "EC2":
|
|
284
|
+
self.service = ecs.Ec2Service(
|
|
285
|
+
self,
|
|
286
|
+
"Service",
|
|
287
|
+
service_name=service_name,
|
|
288
|
+
cluster=self.cluster,
|
|
289
|
+
task_definition=self.task_definition,
|
|
290
|
+
desired_count=self.ecs_config.desired_count,
|
|
291
|
+
enable_execute_command=self.ecs_config.enable_execute_command,
|
|
292
|
+
health_check_grace_period=cdk.Duration.seconds(
|
|
293
|
+
self.ecs_config.health_check_grace_period
|
|
294
|
+
) if self.ecs_config.target_group_arns else None,
|
|
295
|
+
circuit_breaker=ecs.DeploymentCircuitBreaker(rollback=True),
|
|
296
|
+
placement_strategies=self._get_placement_strategies(),
|
|
297
|
+
placement_constraints=self._get_placement_constraints(),
|
|
298
|
+
)
|
|
299
|
+
else:
|
|
300
|
+
# Fargate service
|
|
301
|
+
self.service = ecs.FargateService(
|
|
302
|
+
self,
|
|
303
|
+
"Service",
|
|
304
|
+
service_name=service_name,
|
|
305
|
+
cluster=self.cluster,
|
|
306
|
+
task_definition=self.task_definition,
|
|
307
|
+
desired_count=self.ecs_config.desired_count,
|
|
308
|
+
security_groups=security_groups,
|
|
309
|
+
vpc_subnets=ec2.SubnetSelection(subnets=subnets) if subnets else None,
|
|
310
|
+
assign_public_ip=self.ecs_config.assign_public_ip,
|
|
311
|
+
enable_execute_command=self.ecs_config.enable_execute_command,
|
|
312
|
+
health_check_grace_period=cdk.Duration.seconds(
|
|
313
|
+
self.ecs_config.health_check_grace_period
|
|
314
|
+
) if self.ecs_config.target_group_arns else None,
|
|
315
|
+
circuit_breaker=ecs.DeploymentCircuitBreaker(rollback=True),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
# Attach to load balancer target groups
|
|
319
|
+
self._attach_to_load_balancer()
|
|
320
|
+
|
|
321
|
+
# Apply tags
|
|
322
|
+
for key, value in self.ecs_config.tags.items():
|
|
323
|
+
cdk.Tags.of(self.service).add(key, value)
|
|
324
|
+
|
|
325
|
+
def _get_placement_strategies(self) -> List[ecs.PlacementStrategy]:
|
|
326
|
+
"""Get placement strategies for EC2 launch type"""
|
|
327
|
+
strategies = []
|
|
328
|
+
placement_config = self.ecs_config._config.get("placement_strategies", [])
|
|
329
|
+
|
|
330
|
+
for strategy in placement_config:
|
|
331
|
+
strategy_type = strategy.get("type", "spread")
|
|
332
|
+
field = strategy.get("field", "instanceId")
|
|
333
|
+
|
|
334
|
+
if strategy_type == "spread":
|
|
335
|
+
strategies.append(ecs.PlacementStrategy.spread_across(field))
|
|
336
|
+
elif strategy_type == "binpack":
|
|
337
|
+
strategies.append(ecs.PlacementStrategy.packed_by(field))
|
|
338
|
+
elif strategy_type == "random":
|
|
339
|
+
strategies.append(ecs.PlacementStrategy.randomly())
|
|
340
|
+
|
|
341
|
+
# Default strategy if none specified
|
|
342
|
+
if not strategies:
|
|
343
|
+
strategies = [
|
|
344
|
+
ecs.PlacementStrategy.spread_across_instances(),
|
|
345
|
+
ecs.PlacementStrategy.spread_across("attribute:ecs.availability-zone"),
|
|
346
|
+
]
|
|
347
|
+
|
|
348
|
+
return strategies
|
|
349
|
+
|
|
350
|
+
def _get_placement_constraints(self) -> List[ecs.PlacementConstraint]:
|
|
351
|
+
"""Get placement constraints for EC2 launch type"""
|
|
352
|
+
constraints = []
|
|
353
|
+
constraint_config = self.ecs_config._config.get("placement_constraints", [])
|
|
354
|
+
|
|
355
|
+
for constraint in constraint_config:
|
|
356
|
+
constraint_type = constraint.get("type")
|
|
357
|
+
expression = constraint.get("expression", "")
|
|
358
|
+
|
|
359
|
+
if constraint_type == "distinctInstance":
|
|
360
|
+
constraints.append(ecs.PlacementConstraint.distinct_instances())
|
|
361
|
+
elif constraint_type == "memberOf" and expression:
|
|
362
|
+
constraints.append(ecs.PlacementConstraint.member_of(expression))
|
|
363
|
+
|
|
364
|
+
return constraints
|
|
365
|
+
|
|
366
|
+
def _load_security_groups(self) -> List[ec2.ISecurityGroup]:
|
|
367
|
+
"""Load security groups from IDs"""
|
|
368
|
+
security_groups = []
|
|
369
|
+
|
|
370
|
+
for sg_id in self.ecs_config.security_group_ids:
|
|
371
|
+
sg = ec2.SecurityGroup.from_security_group_id(
|
|
372
|
+
self,
|
|
373
|
+
f"SG-{sg_id[:8]}",
|
|
374
|
+
security_group_id=sg_id,
|
|
375
|
+
)
|
|
376
|
+
security_groups.append(sg)
|
|
377
|
+
|
|
378
|
+
return security_groups
|
|
379
|
+
|
|
380
|
+
def _load_subnets(self) -> Optional[List[ec2.ISubnet]]:
|
|
381
|
+
"""Load subnets by subnet group name"""
|
|
382
|
+
subnet_group_name = self.ecs_config.subnet_group_name
|
|
383
|
+
|
|
384
|
+
if not subnet_group_name:
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
# This would need to be implemented based on your subnet naming convention
|
|
388
|
+
# For now, returning None to use default VPC subnets
|
|
389
|
+
return None
|
|
390
|
+
|
|
391
|
+
def _attach_to_load_balancer(self) -> None:
|
|
392
|
+
"""Attach service to load balancer target groups"""
|
|
393
|
+
target_group_arns = self.ecs_config.target_group_arns
|
|
394
|
+
|
|
395
|
+
if not target_group_arns:
|
|
396
|
+
# Try to load from SSM if configured
|
|
397
|
+
target_group_arns = self._load_target_groups_from_ssm()
|
|
398
|
+
|
|
399
|
+
for tg_arn in target_group_arns:
|
|
400
|
+
target_group = elbv2.ApplicationTargetGroup.from_target_group_attributes(
|
|
401
|
+
self,
|
|
402
|
+
f"TG-{tg_arn.split('/')[-1][:8]}",
|
|
403
|
+
target_group_arn=tg_arn,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
self.service.attach_to_application_target_group(target_group)
|
|
407
|
+
|
|
408
|
+
def _load_target_groups_from_ssm(self) -> List[str]:
|
|
409
|
+
"""Load target group ARNs from SSM parameters"""
|
|
410
|
+
target_group_arns = []
|
|
411
|
+
|
|
412
|
+
# Load SSM imports and look for target group ARNs
|
|
413
|
+
ssm_imports = self.ecs_config.ssm_imports
|
|
414
|
+
|
|
415
|
+
for param_key, param_name in ssm_imports.items():
|
|
416
|
+
if 'target_group' in param_key.lower() or 'tg' in param_key.lower():
|
|
417
|
+
try:
|
|
418
|
+
param_value = self.get_ssm_parameter_value(param_name)
|
|
419
|
+
if param_value and param_value.startswith('arn:'):
|
|
420
|
+
target_group_arns.append(param_value)
|
|
421
|
+
except Exception as e:
|
|
422
|
+
logger.warning(f"Could not load target group from SSM {param_name}: {e}")
|
|
423
|
+
|
|
424
|
+
return target_group_arns
|
|
425
|
+
|
|
426
|
+
def _setup_auto_scaling(self) -> None:
|
|
427
|
+
"""Configure auto-scaling for the ECS service"""
|
|
428
|
+
|
|
429
|
+
scalable_target = self.service.auto_scale_task_count(
|
|
430
|
+
min_capacity=self.ecs_config.min_capacity,
|
|
431
|
+
max_capacity=self.ecs_config.max_capacity,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
# CPU-based scaling
|
|
435
|
+
scalable_target.scale_on_cpu_utilization(
|
|
436
|
+
"CpuScaling",
|
|
437
|
+
target_utilization_percent=self.ecs_config.auto_scaling_target_cpu,
|
|
438
|
+
scale_in_cooldown=cdk.Duration.seconds(60),
|
|
439
|
+
scale_out_cooldown=cdk.Duration.seconds(60),
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
# Memory-based scaling
|
|
443
|
+
scalable_target.scale_on_memory_utilization(
|
|
444
|
+
"MemoryScaling",
|
|
445
|
+
target_utilization_percent=self.ecs_config.auto_scaling_target_memory,
|
|
446
|
+
scale_in_cooldown=cdk.Duration.seconds(60),
|
|
447
|
+
scale_out_cooldown=cdk.Duration.seconds(60),
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
def _add_outputs(self, service_name: str) -> None:
|
|
451
|
+
"""Add CloudFormation outputs"""
|
|
452
|
+
|
|
453
|
+
# Service name output
|
|
454
|
+
cdk.CfnOutput(
|
|
455
|
+
self,
|
|
456
|
+
"ServiceName",
|
|
457
|
+
value=self.service.service_name,
|
|
458
|
+
description=f"ECS Service Name: {service_name}",
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
# Service ARN output
|
|
462
|
+
cdk.CfnOutput(
|
|
463
|
+
self,
|
|
464
|
+
"ServiceArn",
|
|
465
|
+
value=self.service.service_arn,
|
|
466
|
+
description=f"ECS Service ARN: {service_name}",
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
# Cluster name output
|
|
470
|
+
cdk.CfnOutput(
|
|
471
|
+
self,
|
|
472
|
+
"ClusterName",
|
|
473
|
+
value=self.cluster.cluster_name,
|
|
474
|
+
description=f"ECS Cluster Name for {service_name}",
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
# Export to SSM if configured
|
|
478
|
+
self._export_to_ssm(service_name)
|
|
479
|
+
|
|
480
|
+
def _export_to_ssm(self, service_name: str) -> None:
|
|
481
|
+
"""Export resource ARNs and names to SSM Parameter Store"""
|
|
482
|
+
ssm_exports = self.ecs_config.ssm_exports
|
|
483
|
+
|
|
484
|
+
if not ssm_exports:
|
|
485
|
+
return
|
|
486
|
+
|
|
487
|
+
# Service name
|
|
488
|
+
if "service_name" in ssm_exports:
|
|
489
|
+
self.export_ssm_parameter(
|
|
490
|
+
scope=self,
|
|
491
|
+
id="ServiceNameParam",
|
|
492
|
+
value=self.service.service_name,
|
|
493
|
+
parameter_name=ssm_exports["service_name"],
|
|
494
|
+
description=f"ECS Service Name: {service_name}",
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
# Service ARN
|
|
498
|
+
if "service_arn" in ssm_exports:
|
|
499
|
+
self.export_ssm_parameter(
|
|
500
|
+
scope=self,
|
|
501
|
+
id="ServiceArnParam",
|
|
502
|
+
value=self.service.service_arn,
|
|
503
|
+
parameter_name=ssm_exports["service_arn"],
|
|
504
|
+
description=f"ECS Service ARN: {service_name}",
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
# Cluster name
|
|
508
|
+
if "cluster_name" in ssm_exports:
|
|
509
|
+
self.export_ssm_parameter(
|
|
510
|
+
scope=self,
|
|
511
|
+
id="ClusterNameParam",
|
|
512
|
+
value=self.cluster.cluster_name,
|
|
513
|
+
parameter_name=ssm_exports["cluster_name"],
|
|
514
|
+
description=f"ECS Cluster Name for {service_name}",
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
# Task definition ARN
|
|
518
|
+
if "task_definition_arn" in ssm_exports:
|
|
519
|
+
self.export_ssm_parameter(
|
|
520
|
+
scope=self,
|
|
521
|
+
id="TaskDefinitionArnParam",
|
|
522
|
+
value=self.task_definition.task_definition_arn,
|
|
523
|
+
parameter_name=ssm_exports["task_definition_arn"],
|
|
524
|
+
description=f"ECS Task Definition ARN for {service_name}",
|
|
525
|
+
)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# CDK Factory Templates
|
|
2
|
+
|
|
3
|
+
This directory contains templates for initializing new cdk-factory projects.
|
|
4
|
+
|
|
5
|
+
## Available Templates
|
|
6
|
+
|
|
7
|
+
- **`app.py.template`** - Standard application entry point
|
|
8
|
+
- **`cdk.json.template`** - CDK configuration file
|
|
9
|
+
|
|
10
|
+
## Usage
|
|
11
|
+
|
|
12
|
+
### Method 1: Using the CLI (Recommended)
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
# Install cdk-factory with CLI support
|
|
16
|
+
pip install cdk-factory
|
|
17
|
+
|
|
18
|
+
# Initialize a new project
|
|
19
|
+
cdk-factory init devops/cdk-iac --workload-name my-app --environment dev
|
|
20
|
+
|
|
21
|
+
# This creates:
|
|
22
|
+
# - devops/cdk-iac/app.py
|
|
23
|
+
# - devops/cdk-iac/cdk.json
|
|
24
|
+
# - devops/cdk-iac/config.json (minimal template)
|
|
25
|
+
# - devops/cdk-iac/.gitignore
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Method 2: Manual Copy
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# Copy templates manually
|
|
32
|
+
cp templates/app.py.template your-project/devops/cdk-iac/app.py
|
|
33
|
+
cp templates/cdk.json.template your-project/devops/cdk-iac/cdk.json
|
|
34
|
+
|
|
35
|
+
# Create config.json (see examples/)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Template Variables
|
|
39
|
+
|
|
40
|
+
The templates use minimal configuration. All settings are driven by:
|
|
41
|
+
|
|
42
|
+
1. **Environment Variables** - `AWS_ACCOUNT`, `AWS_REGION`, `WORKLOAD_NAME`, etc.
|
|
43
|
+
2. **CDK Context** - Pass via `-c` flag: `cdk deploy -c WorkloadName=my-app`
|
|
44
|
+
3. **config.json** - Your infrastructure configuration
|
|
45
|
+
|
|
46
|
+
## Project Structure
|
|
47
|
+
|
|
48
|
+
After initialization, your project should look like:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
your-project/
|
|
52
|
+
├── devops/
|
|
53
|
+
│ └── cdk-iac/
|
|
54
|
+
│ ├── app.py # Entry point (from template)
|
|
55
|
+
│ ├── cdk.json # CDK config (from template)
|
|
56
|
+
│ ├── config.json # Your infrastructure config
|
|
57
|
+
│ ├── .gitignore # Generated
|
|
58
|
+
│ └── commands/ # Your build scripts (optional)
|
|
59
|
+
│ ├── docker-build.sh
|
|
60
|
+
│ └── docker-build.py
|
|
61
|
+
├── src/ # Your application code
|
|
62
|
+
└── Dockerfile # Your docker config
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Customization
|
|
66
|
+
|
|
67
|
+
The templates are intentionally minimal. You can:
|
|
68
|
+
|
|
69
|
+
1. ✅ Add custom environment variables
|
|
70
|
+
2. ✅ Modify config.json structure
|
|
71
|
+
3. ✅ Add project-specific initialization in app.py
|
|
72
|
+
4. ❌ Don't modify core path resolution logic (it's environment-agnostic)
|
|
73
|
+
|
|
74
|
+
## Integration with pyproject.toml
|
|
75
|
+
|
|
76
|
+
To enable the CLI, update `pyproject.toml`:
|
|
77
|
+
|
|
78
|
+
```toml
|
|
79
|
+
[project.scripts]
|
|
80
|
+
cdk-factory = "cdk_factory.cli:main"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Or in `setup.py`:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
entry_points={
|
|
87
|
+
'console_scripts': [
|
|
88
|
+
'cdk-factory=cdk_factory.cli:main',
|
|
89
|
+
],
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Benefits
|
|
94
|
+
|
|
95
|
+
✅ **No boilerplate** - Standard entry point across all projects
|
|
96
|
+
✅ **Environment-agnostic** - Works locally and in CI/CD
|
|
97
|
+
✅ **Consistent** - All projects follow same pattern
|
|
98
|
+
✅ **Maintainable** - Updates to template benefit all projects
|
|
99
|
+
✅ **Simple** - Just 30 lines of code in app.py
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
CDK Factory Application Entry Point
|
|
4
|
+
|
|
5
|
+
This is the standard entry point for any cdk-factory based project.
|
|
6
|
+
All configuration is driven by environment variables and CDK context.
|
|
7
|
+
|
|
8
|
+
To use this template:
|
|
9
|
+
1. Copy to your project's CDK directory (e.g., devops/cdk-iac/app.py)
|
|
10
|
+
2. Update cdk.json to reference this file: "app": "python app.py"
|
|
11
|
+
3. Create your config.json
|
|
12
|
+
4. Run: cdk synth
|
|
13
|
+
|
|
14
|
+
Note: cdk.out is automatically placed at project root for CodeBuild compatibility.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from cdk_factory.app import CdkAppFactory
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if __name__ == "__main__":
|
|
23
|
+
# Runtime directory (where this file lives)
|
|
24
|
+
runtime_dir = str(Path(__file__).parent.resolve())
|
|
25
|
+
|
|
26
|
+
# Configuration (Priority: ENV > CDK Context > default)
|
|
27
|
+
config_path = os.getenv('CDK_CONFIG_PATH', 'config.json')
|
|
28
|
+
|
|
29
|
+
# Create and synth
|
|
30
|
+
# outdir is automatically set to project_root/cdk.out
|
|
31
|
+
factory = CdkAppFactory(
|
|
32
|
+
config_path=config_path,
|
|
33
|
+
runtime_directory=runtime_dir
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
factory.synth(cdk_app_file=__file__)
|