cdk-factory 0.8.7__py3-none-any.whl → 0.8.8__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.

Potentially problematic release.


This version of cdk-factory might be problematic. Click here for more details.

@@ -16,7 +16,7 @@ class ECRConfig(EnhancedBaseConfig):
16
16
  ) -> None:
17
17
  super().__init__(config, resource_type="ecr", resource_name=config.get("name", "ecr") if config else "ecr")
18
18
  self.__config = config
19
- self.__deployment = config
19
+ self.__deployment = deployment
20
20
  self.__ssm_prefix_template = config.get("ssm_prefix_template", None)
21
21
 
22
22
  @property
@@ -74,12 +74,13 @@ class ECRConfig(EnhancedBaseConfig):
74
74
  Clear out untagged images after x days. This helps save costs.
75
75
  Untagged images will stay forever if you don't clean them out.
76
76
  """
77
+ days = None
77
78
  if self.__config and isinstance(self.__config, dict):
78
79
  days = self.__config.get("auto_delete_untagged_images_in_days")
79
80
  if days:
80
81
  days = int(days)
81
82
 
82
- return None
83
+ return days
83
84
 
84
85
  @property
85
86
  def use_existing(self) -> bool:
@@ -118,6 +119,50 @@ class ECRConfig(EnhancedBaseConfig):
118
119
  if not value:
119
120
  raise RuntimeError("Region is not defined")
120
121
  return value
122
+
123
+ @property
124
+ def cross_account_access(self) -> dict:
125
+ """
126
+ Cross-account access configuration.
127
+
128
+ Example:
129
+ {
130
+ "enabled": true,
131
+ "accounts": ["123456789012", "987654321098"],
132
+ "services": [
133
+ {
134
+ "name": "lambda",
135
+ "actions": ["ecr:BatchGetImage", "ecr:GetDownloadUrlForLayer"],
136
+ "condition": {
137
+ "StringLike": {
138
+ "aws:sourceArn": "arn:aws:lambda:*:*:function:*"
139
+ }
140
+ }
141
+ },
142
+ {
143
+ "name": "ecs-tasks",
144
+ "service_principal": "ecs-tasks.amazonaws.com",
145
+ "actions": ["ecr:BatchGetImage", "ecr:GetDownloadUrlForLayer"]
146
+ },
147
+ {
148
+ "name": "codebuild",
149
+ "service_principal": "codebuild.amazonaws.com",
150
+ "actions": ["ecr:BatchGetImage", "ecr:GetDownloadUrlForLayer", "ecr:BatchCheckLayerAvailability"]
151
+ }
152
+ ]
153
+ }
154
+ """
155
+ if self.__config and isinstance(self.__config, dict):
156
+ return self.__config.get("cross_account_access", {})
157
+ return {}
158
+
159
+ @property
160
+ def cross_account_enabled(self) -> bool:
161
+ """Whether cross-account access is explicitly enabled"""
162
+ access_config = self.cross_account_access
163
+ if access_config:
164
+ return str(access_config.get("enabled", "true")).lower() == "true"
165
+ return True # Default to enabled for backward compatibility
121
166
 
122
167
  # SSM properties are now inherited from EnhancedBaseConfig
123
168
  # Keeping these for any direct access patterns in existing code
@@ -0,0 +1,144 @@
1
+ """
2
+ ECS Service Configuration
3
+ Maintainers: Eric Wilson
4
+ MIT License. See Project Root for the license information.
5
+ """
6
+
7
+ from typing import Dict, Any, List, Optional
8
+
9
+
10
+ class EcsServiceConfig:
11
+ """ECS Service Configuration"""
12
+
13
+ def __init__(self, config: Dict[str, Any]) -> None:
14
+ self._config = config
15
+
16
+ @property
17
+ def name(self) -> str:
18
+ """Service name"""
19
+ return self._config.get("name", "")
20
+
21
+ @property
22
+ def cluster_name(self) -> Optional[str]:
23
+ """ECS Cluster name"""
24
+ return self._config.get("cluster_name")
25
+
26
+ @property
27
+ def task_definition(self) -> Dict[str, Any]:
28
+ """Task definition configuration"""
29
+ return self._config.get("task_definition", {})
30
+
31
+ @property
32
+ def container_definitions(self) -> List[Dict[str, Any]]:
33
+ """Container definitions"""
34
+ return self.task_definition.get("containers", [])
35
+
36
+ @property
37
+ def cpu(self) -> str:
38
+ """Task CPU units"""
39
+ return self.task_definition.get("cpu", "256")
40
+
41
+ @property
42
+ def memory(self) -> str:
43
+ """Task memory (MB)"""
44
+ return self.task_definition.get("memory", "512")
45
+
46
+ @property
47
+ def launch_type(self) -> str:
48
+ """Launch type: FARGATE or EC2"""
49
+ return self._config.get("launch_type", "FARGATE")
50
+
51
+ @property
52
+ def desired_count(self) -> int:
53
+ """Desired number of tasks"""
54
+ return self._config.get("desired_count", 2)
55
+
56
+ @property
57
+ def min_capacity(self) -> int:
58
+ """Minimum number of tasks"""
59
+ return self._config.get("min_capacity", 1)
60
+
61
+ @property
62
+ def max_capacity(self) -> int:
63
+ """Maximum number of tasks"""
64
+ return self._config.get("max_capacity", 4)
65
+
66
+ @property
67
+ def vpc_id(self) -> Optional[str]:
68
+ """VPC ID"""
69
+ return self._config.get("vpc_id")
70
+
71
+ @property
72
+ def subnet_group_name(self) -> Optional[str]:
73
+ """Subnet group name for service placement"""
74
+ return self._config.get("subnet_group_name")
75
+
76
+ @property
77
+ def security_group_ids(self) -> List[str]:
78
+ """Security group IDs"""
79
+ return self._config.get("security_group_ids", [])
80
+
81
+ @property
82
+ def assign_public_ip(self) -> bool:
83
+ """Whether to assign public IP addresses"""
84
+ return self._config.get("assign_public_ip", False)
85
+
86
+ @property
87
+ def target_group_arns(self) -> List[str]:
88
+ """Target group ARNs for load balancing"""
89
+ return self._config.get("target_group_arns", [])
90
+
91
+ @property
92
+ def container_port(self) -> int:
93
+ """Container port for load balancer"""
94
+ return self._config.get("container_port", 80)
95
+
96
+ @property
97
+ def health_check_grace_period(self) -> int:
98
+ """Health check grace period in seconds"""
99
+ return self._config.get("health_check_grace_period", 60)
100
+
101
+ @property
102
+ def enable_execute_command(self) -> bool:
103
+ """Enable ECS Exec for debugging"""
104
+ return self._config.get("enable_execute_command", False)
105
+
106
+ @property
107
+ def enable_auto_scaling(self) -> bool:
108
+ """Enable auto-scaling"""
109
+ return self._config.get("enable_auto_scaling", True)
110
+
111
+ @property
112
+ def auto_scaling_target_cpu(self) -> int:
113
+ """Target CPU utilization percentage for auto-scaling"""
114
+ return self._config.get("auto_scaling_target_cpu", 70)
115
+
116
+ @property
117
+ def auto_scaling_target_memory(self) -> int:
118
+ """Target memory utilization percentage for auto-scaling"""
119
+ return self._config.get("auto_scaling_target_memory", 80)
120
+
121
+ @property
122
+ def tags(self) -> Dict[str, str]:
123
+ """Resource tags"""
124
+ return self._config.get("tags", {})
125
+
126
+ @property
127
+ def ssm_exports(self) -> Dict[str, str]:
128
+ """SSM parameter exports"""
129
+ return self._config.get("ssm_exports", {})
130
+
131
+ @property
132
+ def ssm_imports(self) -> Dict[str, str]:
133
+ """SSM parameter imports"""
134
+ return self._config.get("ssm_imports", {})
135
+
136
+ @property
137
+ def deployment_type(self) -> str:
138
+ """Deployment type: production, maintenance, or blue-green"""
139
+ return self._config.get("deployment_type", "production")
140
+
141
+ @property
142
+ def is_maintenance_mode(self) -> bool:
143
+ """Whether this is a maintenance mode deployment"""
144
+ return self.deployment_type == "maintenance"
@@ -33,6 +33,7 @@ class ECRConstruct(Construct, SsmParameterMixin):
33
33
 
34
34
  self.scope = scope
35
35
  self.deployment = deployment
36
+ self.repo = repo
36
37
  self.ecr_name = repo.name
37
38
  self.image_scan_on_push = repo.image_scan_on_push
38
39
  self.empty_on_delete = repo.empty_on_delete
@@ -98,28 +99,17 @@ class ECRConstruct(Construct, SsmParameterMixin):
98
99
  )
99
100
 
100
101
  # Add dependencies to ensure SSM parameters are created after the ECR repository
101
- for param in params.values():
102
- if param and param.node.default_child and isinstance(param.node.default_child, CfnResource):
103
- param.node.default_child.add_dependency(
104
- cast(CfnResource, self.ecr.node.default_child)
105
- )
102
+ if params:
103
+ for param in params.values():
104
+ if param and hasattr(param, 'node') and param.node.default_child and isinstance(param.node.default_child, CfnResource):
105
+ param.node.default_child.add_dependency(
106
+ cast(CfnResource, self.ecr.node.default_child)
107
+ )
106
108
 
107
109
  def __set_life_cycle_rules(self) -> None:
108
- # ToDo/FixMe: tag_pattern_list is not recognized in the current version in AWS
109
-
110
- try:
111
- # always keep images tagged as prod
112
- self.ecr.add_lifecycle_rule(
113
- tag_pattern_list=["prod*"], max_image_count=9999
114
- )
115
- except Exception as e: # pylint: disable=w0718
116
- if "unexpected keyword argument" in str(e):
117
- logger.warning(
118
- "tag_pattern_list is not available in this version of the aws cdk"
119
- )
120
- else:
121
- raise
122
-
110
+ # Note: tag_pattern_list is deprecated and causes circular dependencies in CDK synthesis
111
+ # Only add lifecycle rule for untagged images if configured
112
+
123
113
  if not self.auto_delete_untagged_images_in_days:
124
114
  return None
125
115
 
@@ -143,49 +133,131 @@ class ECRConstruct(Construct, SsmParameterMixin):
143
133
  )
144
134
 
145
135
  def __setup_cross_account_access_permissions(self):
146
- # Cross-account access policy
136
+ """
137
+ Setup cross-account access permissions with flexible configuration support.
138
+
139
+ Supports both legacy (default Lambda access) and new configurable approach.
140
+ """
141
+ # Check if cross-account access is disabled
142
+ if not self.repo.cross_account_enabled:
143
+ logger.info(f"Cross-account access disabled for {self.ecr_name}")
144
+ return
147
145
 
148
- if self.deployment.account == self.deployment.workload.get("devops", {}).get(
149
- "account"
150
- ):
151
- # we're in the same account as the "devops" so we don't need cross account
152
- # permisions
146
+ # Check if we're in the same account as devops
147
+ if self.deployment.account == self.deployment.workload.get("devops", {}).get("account"):
148
+ logger.info(f"Same account as devops, skipping cross-account permissions for {self.ecr_name}")
153
149
  return
154
150
 
155
- ecr = self.ecr or self.__get_ecr()
156
- cross_account_policy_statement = iam.PolicyStatement(
151
+ access_config = self.repo.cross_account_access
152
+
153
+ if access_config and access_config.get("services"):
154
+ # New configurable approach
155
+ logger.info(f"Setting up configurable cross-account access for {self.ecr_name}")
156
+ self.__setup_configurable_access(access_config)
157
+ else:
158
+ # Legacy approach - default Lambda access for backward compatibility
159
+ logger.info(f"Setting up legacy cross-account access (Lambda only) for {self.ecr_name}")
160
+ self.__setup_legacy_lambda_access()
161
+
162
+ def __setup_configurable_access(self, access_config: dict):
163
+ """Setup cross-account access using configuration"""
164
+
165
+ # Get list of accounts (default to deployment account)
166
+ accounts = access_config.get("accounts", [self.deployment.account])
167
+
168
+ # Add account principal policies if accounts are specified
169
+ if accounts:
170
+ self.__add_account_principal_policy(accounts)
171
+
172
+ # Add service-specific policies
173
+ services = access_config.get("services", [])
174
+ for service_config in services:
175
+ self.__add_service_principal_policy(service_config)
176
+
177
+ def __add_account_principal_policy(self, accounts: list):
178
+ """Add policy for AWS account principals"""
179
+ principals = [iam.AccountPrincipal(account) for account in accounts]
180
+
181
+ policy_statement = iam.PolicyStatement(
157
182
  actions=[
158
183
  "ecr:GetDownloadUrlForLayer",
159
184
  "ecr:BatchGetImage",
160
185
  "ecr:BatchCheckLayerAvailability",
161
186
  ],
162
- principals=[
163
- iam.AccountPrincipal(self.deployment.account)
164
- ], # Replace with the account ID of the Lambda function
165
- resources=[ecr.repository_arn],
187
+ principals=principals,
166
188
  effect=iam.Effect.ALLOW,
167
189
  )
168
190
 
169
- # Attach the policy to the ECR repository
170
- response = ecr.add_to_resource_policy(cross_account_policy_statement)
171
-
172
- # fails, we're not adding it this way
173
- assert response.statement_added
174
-
175
- response = self.ecr.add_to_resource_policy(
176
- iam.PolicyStatement(
177
- effect=iam.Effect.ALLOW,
178
- actions=["ecr:BatchGetImage", "ecr:GetDownloadUrlForLayer"],
179
- principals=[iam.ServicePrincipal("lambda.amazonaws.com")],
180
- conditions={
181
- "StringLike": {
182
- "aws:sourceArn": [
183
- f"arn:aws:lambda:{self.deployment.region}:{self.deployment.account}:function:*"
184
- ]
185
- }
186
- },
187
- resources=[ecr.repository_arn],
188
- )
191
+ response = self.ecr.add_to_resource_policy(policy_statement)
192
+ if not response.statement_added:
193
+ logger.warning(f"Failed to add account principal policy for {', '.join(accounts)}")
194
+ else:
195
+ logger.info(f"Added account principal policy for accounts: {', '.join(accounts)}")
196
+
197
+ def __add_service_principal_policy(self, service_config: dict):
198
+ """Add policy for service principal (Lambda, ECS, CodeBuild, etc.)"""
199
+ service_name = service_config.get("name", "unknown")
200
+ service_principal = service_config.get("service_principal")
201
+ actions = service_config.get("actions", ["ecr:BatchGetImage", "ecr:GetDownloadUrlForLayer"])
202
+ conditions = service_config.get("condition")
203
+
204
+ if not service_principal:
205
+ # Infer service principal from common service names
206
+ service_principal_map = {
207
+ "lambda": "lambda.amazonaws.com",
208
+ "ecs": "ecs-tasks.amazonaws.com",
209
+ "ecs-tasks": "ecs-tasks.amazonaws.com",
210
+ "codebuild": "codebuild.amazonaws.com",
211
+ "codepipeline": "codepipeline.amazonaws.com",
212
+ "ec2": "ec2.amazonaws.com",
213
+ }
214
+ service_principal = service_principal_map.get(service_name.lower())
215
+
216
+ if not service_principal:
217
+ logger.warning(f"Unknown service principal for service: {service_name}")
218
+ return
219
+
220
+ policy_statement = iam.PolicyStatement(
221
+ effect=iam.Effect.ALLOW,
222
+ actions=actions,
223
+ principals=[iam.ServicePrincipal(service_principal)],
224
+ )
225
+
226
+ # Add conditions if specified
227
+ if conditions:
228
+ for condition_key, condition_value in conditions.items():
229
+ policy_statement.add_condition(condition_key, condition_value)
230
+
231
+ response = self.ecr.add_to_resource_policy(policy_statement)
232
+ if not response.statement_added:
233
+ logger.warning(f"Failed to add service principal policy for {service_name}")
234
+ else:
235
+ logger.info(f"Added service principal policy for {service_name} ({service_principal})")
236
+
237
+ def __setup_legacy_lambda_access(self):
238
+ """Legacy method: Setup default Lambda-only cross-account access"""
239
+
240
+ # Add account principal policy
241
+ self.__add_account_principal_policy([self.deployment.account])
242
+
243
+ # Add Lambda service principal policy with default condition
244
+ lambda_policy = iam.PolicyStatement(
245
+ effect=iam.Effect.ALLOW,
246
+ actions=["ecr:BatchGetImage", "ecr:GetDownloadUrlForLayer"],
247
+ principals=[iam.ServicePrincipal("lambda.amazonaws.com")],
248
+ )
249
+
250
+ lambda_policy.add_condition(
251
+ "StringLike",
252
+ {
253
+ "aws:sourceArn": [
254
+ f"arn:aws:lambda:{self.deployment.region}:{self.deployment.account}:function:*"
255
+ ]
256
+ }
189
257
  )
190
258
 
191
- assert response.statement_added
259
+ response = self.ecr.add_to_resource_policy(lambda_policy)
260
+ if not response.statement_added:
261
+ logger.warning("Failed to add Lambda service principal policy")
262
+ else:
263
+ logger.info("Added legacy Lambda service principal policy")
@@ -246,7 +246,13 @@ class PipelineFactoryStack(cdk.Stack):
246
246
 
247
247
  def _get_steps(self, key: str, stage_config: PipelineStageConfig):
248
248
  """
249
- Gets the build steps from the config.json
249
+ Gets the build steps from the config.json.
250
+
251
+ Commands can be:
252
+ - A list of strings (each string is a separate command)
253
+ - A single multi-line string (treated as a single script block)
254
+
255
+ This allows support for complex shell constructs like if blocks, loops, etc.
250
256
  """
251
257
  shell_steps: List[pipelines.ShellStep] = []
252
258
 
@@ -257,6 +263,12 @@ class PipelineFactoryStack(cdk.Stack):
257
263
  for step in steps:
258
264
  step_id = step.get("id") or step.get("name")
259
265
  commands = step.get("commands", [])
266
+
267
+ # Normalize commands to a list
268
+ # If commands is a single string, wrap it in a list
269
+ if isinstance(commands, str):
270
+ commands = [commands]
271
+
260
272
  shell_step = pipelines.ShellStep(
261
273
  id=step_id,
262
274
  commands=commands,
File without changes
@@ -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
+ )
cdk_factory/version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.8.7"
1
+ __version__ = "0.8.8"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cdk_factory
3
- Version: 0.8.7
3
+ Version: 0.8.8
4
4
  Summary: CDK Factory. A QuickStarter and best practices setup for CDK projects
5
5
  Author-email: Eric Wilson <eric.wilson@geekcafe.com>
6
6
  License: MIT License
@@ -1,7 +1,7 @@
1
1
  cdk_factory/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
2
  cdk_factory/app.py,sha256=xv863N7O6HPKznB68_t7O4la9JacrkG87t9TjoDUk7s,2827
3
3
  cdk_factory/cdk.json,sha256=SKZKhJ2PBpFH78j-F8S3VDYW-lf76--Q2I3ON-ZIQfw,3106
4
- cdk_factory/version.py,sha256=vTwvdJOZi8jZb9U-Em7-d50qNDNPS2z51IXqRoojeNM,22
4
+ cdk_factory/version.py,sha256=S5bBAK8bL7bybaXGJQuNE98fa3H65zGjTASMiyKGJGw,22
5
5
  cdk_factory/builds/README.md,sha256=9BBWd7bXpyKdMU_g2UljhQwrC9i5O_Tvkb6oPvndoZk,90
6
6
  cdk_factory/commands/command_loader.py,sha256=QbLquuP_AdxtlxlDy-2IWCQ6D-7qa58aphnDPtp_uTs,3744
7
7
  cdk_factory/configurations/base_config.py,sha256=JKjhNsy0RCUZy1s8n5D_aXXI-upR9izaLtCTfKYiV9k,9624
@@ -28,7 +28,8 @@ cdk_factory/configurations/resources/code_repository.py,sha256=rAM6cbMtNR_S4TfiB
28
28
  cdk_factory/configurations/resources/cognito.py,sha256=udX2AJ1ITLhy4f1XiJQwrva6F4i9NdbSm0zTg5PGku8,10649
29
29
  cdk_factory/configurations/resources/docker.py,sha256=hUbuxkuhcQu9LnLX7I8_57eTmHefEAGVnOHO37MkqC4,2166
30
30
  cdk_factory/configurations/resources/dynamodb.py,sha256=HsZMOaRwfuNPwKIzokeeE3f5zAQLTB5hRb_GzYq2ibg,2903
31
- cdk_factory/configurations/resources/ecr.py,sha256=LT2m13SsmxymaQXOtYTEbXwNWXIPURkoAR6py8pNrTE,6584
31
+ cdk_factory/configurations/resources/ecr.py,sha256=o9hHzEBVPoxUvWZGXGbRJ-98FmP6fMLY5a1-qg42jL0,8253
32
+ cdk_factory/configurations/resources/ecs_service.py,sha256=kEzSlasDejobl1ZxZtgtM7kVYF11AT3vCGsDInQNY3I,4319
32
33
  cdk_factory/configurations/resources/exisiting.py,sha256=EVOLnkB-DGfTlmDgyQ5DD5k2zYfpFxqI3gugDR7mifI,478
33
34
  cdk_factory/configurations/resources/lambda_function.py,sha256=VENZ9-ABJ5mjcN8J8wdLH4KHDYr1kWO0iFDH0B2mJXA,14659
34
35
  cdk_factory/configurations/resources/lambda_layers.py,sha256=gVeP_-LC3Eq0lkPaG_JfFUwboM5evRPr99SfKj53m7A,633
@@ -47,7 +48,7 @@ cdk_factory/configurations/resources/security_group_full_stack.py,sha256=x5MIMCa
47
48
  cdk_factory/configurations/resources/sqs.py,sha256=fAh2dqttJ6PX46enFRULuiLEu3TEj0Vb2xntAOgUpYE,4346
48
49
  cdk_factory/configurations/resources/vpc.py,sha256=sNn6w76bHFwmt6N76gZZhqpsuNB9860C1SZu6tebaXY,3835
49
50
  cdk_factory/constructs/cloudfront/cloudfront_distribution_construct.py,sha256=7uDkDbFigeUOuYgsQ9ghA4dGlx63rUCa6Iiq4RWokEA,14627
50
- cdk_factory/constructs/ecr/ecr_construct.py,sha256=s0egnh4U7qgCSn_LLq1soRLWSxsFjrTx8P591AjkN84,7025
51
+ cdk_factory/constructs/ecr/ecr_construct.py,sha256=JLz3gWrsjlM0XghvbgxuoGlF-VIo_7IYxtgX7mTkidE,10660
51
52
  cdk_factory/constructs/lambdas/lambda_function_construct.py,sha256=SQ5SEXn4kezVAzXuv_A_JB3o_svyBXOMi-htvfB9HQs,4516
52
53
  cdk_factory/constructs/lambdas/lambda_function_docker_construct.py,sha256=aSyn3eh1YnuIahZ7CbZ5WswwPL8u70ZibMoS24QQifc,9907
53
54
  cdk_factory/constructs/lambdas/lambda_function_role_construct.py,sha256=nJoxKv-4CWugX-ZkAzoG8wrfSsHqXqiMZnGRqSyS4Po,1453
@@ -62,7 +63,7 @@ cdk_factory/interfaces/istack.py,sha256=bhTBs-o9FgKwvJMSuwxjUV6D3nUlvZHVzfm27jP9
62
63
  cdk_factory/interfaces/live_ssm_resolver.py,sha256=3FIr9a02SXqZmbFs3RT0WxczWEQR_CF7QSt7kWbDrVE,8163
63
64
  cdk_factory/interfaces/ssm_parameter_mixin.py,sha256=uA2j8HmAOpuEA9ynRj51s0WjUHMVLsbLQN-QS9NKyHA,12089
64
65
  cdk_factory/lambdas/health_handler.py,sha256=dd40ykKMxWCFEIyp2ZdQvAGNjw_ylI9CSm1N24Hp2ME,196
65
- cdk_factory/pipeline/pipeline_factory.py,sha256=DtvGlCjq1uNafwtNevbpwgIR4Du8UoaTVqfgqC_FePU,15430
66
+ cdk_factory/pipeline/pipeline_factory.py,sha256=OuL1pOWjThIMDYqZEBbqLzg8KK9A84qtjRMPJwnX-kk,15956
66
67
  cdk_factory/pipeline/stage.py,sha256=Be7ExMB9A-linRM18IQDOzQ-cP_I2_ThRNzlT4FIrUg,437
67
68
  cdk_factory/pipeline/security/policies.py,sha256=H3-S6nipz3UtF9Pc5eJYr4-aREUTCaJWMjOUyd6Rdv4,4406
68
69
  cdk_factory/pipeline/security/roles.py,sha256=ZB_O5H_BXgotvVspS2kVad9EMcY-a_-vU7Nm1_Z5MB8,4985
@@ -84,6 +85,8 @@ cdk_factory/stack_library/cognito/cognito_stack.py,sha256=zEHkKVCIeyZywPs_GIMXCX
84
85
  cdk_factory/stack_library/dynamodb/dynamodb_stack.py,sha256=TVyOrUhgaSuN8uymkpaQcpOaSA0lkYJ8QUMgakTCKus,6771
85
86
  cdk_factory/stack_library/ecr/README.md,sha256=xw2wPx9WN03Y4BBwqvbi9lAFGNyaD1FUNpqxVJX14Oo,179
86
87
  cdk_factory/stack_library/ecr/ecr_stack.py,sha256=1xA68sxFVyqreYjXrP_7U9I8RF9RtFeR6KeEfSWuC2U,2118
88
+ cdk_factory/stack_library/ecs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
89
+ cdk_factory/stack_library/ecs/ecs_service_stack.py,sha256=mFanYtkZ9GpgT8g31D9tj-2yYbmmcuHN7ADaRD-ri9o,20638
87
90
  cdk_factory/stack_library/load_balancer/__init__.py,sha256=wZpKw2OecLJGdF5mPayCYAEhu2H3c2gJFFIxwXftGDU,52
88
91
  cdk_factory/stack_library/load_balancer/load_balancer_stack.py,sha256=t5JUe5lMUbQCRFZR08k8nO-g-53yWY8gKB9v8ZnedBs,24391
89
92
  cdk_factory/stack_library/rds/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -112,7 +115,7 @@ cdk_factory/utilities/lambda_function_utilities.py,sha256=S1GvBsY_q2cyUiaud3HORJ
112
115
  cdk_factory/utilities/os_execute.py,sha256=5Op0LY_8Y-pUm04y1k8MTpNrmQvcLmQHPQITEP7EuSU,1019
113
116
  cdk_factory/utils/api_gateway_utilities.py,sha256=If7Xu5s_UxmuV-kL3JkXxPLBdSVUKoLtohm0IUFoiV8,4378
114
117
  cdk_factory/workload/workload_factory.py,sha256=yBUDGIuB8-5p_mGcVFxsD2ZoZIziak3yh3LL3JvS0M4,5903
115
- cdk_factory-0.8.7.dist-info/METADATA,sha256=WGWpDzr9pYOJtD_F35pKGQZF9Fb-BUBI5AAhj652bOw,2450
116
- cdk_factory-0.8.7.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
117
- cdk_factory-0.8.7.dist-info/licenses/LICENSE,sha256=NOtdOeLwg2il_XBJdXUPFPX8JlV4dqTdDGAd2-khxT8,1066
118
- cdk_factory-0.8.7.dist-info/RECORD,,
118
+ cdk_factory-0.8.8.dist-info/METADATA,sha256=snclFyCZwqRqLmxauiDny56lUUnERMnVSNYvMGKZWG8,2450
119
+ cdk_factory-0.8.8.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
120
+ cdk_factory-0.8.8.dist-info/licenses/LICENSE,sha256=NOtdOeLwg2il_XBJdXUPFPX8JlV4dqTdDGAd2-khxT8,1066
121
+ cdk_factory-0.8.8.dist-info/RECORD,,