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

Files changed (63) hide show
  1. cdk_factory/configurations/base_config.py +23 -24
  2. cdk_factory/configurations/cdk_config.py +6 -4
  3. cdk_factory/configurations/deployment.py +12 -0
  4. cdk_factory/configurations/devops.py +1 -1
  5. cdk_factory/configurations/pipeline_stage.py +29 -5
  6. cdk_factory/configurations/resources/acm.py +85 -0
  7. cdk_factory/configurations/resources/auto_scaling.py +7 -5
  8. cdk_factory/configurations/resources/cloudfront.py +7 -2
  9. cdk_factory/configurations/resources/ecr.py +1 -1
  10. cdk_factory/configurations/resources/ecs_cluster.py +108 -0
  11. cdk_factory/configurations/resources/ecs_service.py +17 -2
  12. cdk_factory/configurations/resources/load_balancer.py +17 -4
  13. cdk_factory/configurations/resources/monitoring.py +8 -3
  14. cdk_factory/configurations/resources/rds.py +305 -19
  15. cdk_factory/configurations/resources/rum.py +7 -2
  16. cdk_factory/configurations/resources/s3.py +1 -1
  17. cdk_factory/configurations/resources/security_group_full_stack.py +7 -8
  18. cdk_factory/configurations/resources/vpc.py +19 -0
  19. cdk_factory/configurations/workload.py +32 -2
  20. cdk_factory/constructs/ecr/ecr_construct.py +9 -2
  21. cdk_factory/constructs/lambdas/policies/policy_docs.py +4 -4
  22. cdk_factory/interfaces/istack.py +6 -3
  23. cdk_factory/interfaces/networked_stack_mixin.py +75 -0
  24. cdk_factory/interfaces/standardized_ssm_mixin.py +657 -0
  25. cdk_factory/interfaces/vpc_provider_mixin.py +210 -0
  26. cdk_factory/lambdas/edge/ip_gate/handler.py +42 -40
  27. cdk_factory/pipeline/pipeline_factory.py +222 -27
  28. cdk_factory/stack/stack_factory.py +34 -0
  29. cdk_factory/stack_library/__init__.py +3 -2
  30. cdk_factory/stack_library/acm/__init__.py +6 -0
  31. cdk_factory/stack_library/acm/acm_stack.py +169 -0
  32. cdk_factory/stack_library/api_gateway/api_gateway_stack.py +84 -59
  33. cdk_factory/stack_library/auto_scaling/auto_scaling_stack.py +366 -408
  34. cdk_factory/stack_library/code_artifact/code_artifact_stack.py +2 -2
  35. cdk_factory/stack_library/cognito/cognito_stack.py +152 -92
  36. cdk_factory/stack_library/dynamodb/dynamodb_stack.py +19 -15
  37. cdk_factory/stack_library/ecr/ecr_stack.py +2 -2
  38. cdk_factory/stack_library/ecs/__init__.py +12 -0
  39. cdk_factory/stack_library/ecs/ecs_cluster_stack.py +316 -0
  40. cdk_factory/stack_library/ecs/ecs_service_stack.py +20 -39
  41. cdk_factory/stack_library/lambda_edge/lambda_edge_stack.py +2 -2
  42. cdk_factory/stack_library/load_balancer/load_balancer_stack.py +151 -118
  43. cdk_factory/stack_library/rds/rds_stack.py +85 -74
  44. cdk_factory/stack_library/route53/route53_stack.py +8 -3
  45. cdk_factory/stack_library/rum/rum_stack.py +108 -91
  46. cdk_factory/stack_library/security_group/security_group_full_stack.py +9 -22
  47. cdk_factory/stack_library/security_group/security_group_stack.py +11 -11
  48. cdk_factory/stack_library/stack_base.py +5 -0
  49. cdk_factory/stack_library/vpc/vpc_stack.py +272 -124
  50. cdk_factory/stack_library/websites/static_website_stack.py +1 -1
  51. cdk_factory/utilities/api_gateway_integration_utility.py +24 -16
  52. cdk_factory/utilities/environment_services.py +5 -5
  53. cdk_factory/utilities/json_loading_utility.py +12 -3
  54. cdk_factory/validation/config_validator.py +483 -0
  55. cdk_factory/version.py +1 -1
  56. cdk_factory/workload/workload_factory.py +1 -0
  57. {cdk_factory-0.15.10.dist-info → cdk_factory-0.18.9.dist-info}/METADATA +1 -1
  58. {cdk_factory-0.15.10.dist-info → cdk_factory-0.18.9.dist-info}/RECORD +61 -54
  59. cdk_factory/interfaces/enhanced_ssm_parameter_mixin.py +0 -321
  60. cdk_factory/interfaces/ssm_parameter_mixin.py +0 -329
  61. {cdk_factory-0.15.10.dist-info → cdk_factory-0.18.9.dist-info}/WHEEL +0 -0
  62. {cdk_factory-0.15.10.dist-info → cdk_factory-0.18.9.dist-info}/entry_points.txt +0 -0
  63. {cdk_factory-0.15.10.dist-info → cdk_factory-0.18.9.dist-info}/licenses/LICENSE +0 -0
@@ -5,6 +5,7 @@ Geek Cafe Pipeline
5
5
  import os
6
6
  from pathlib import Path
7
7
  from typing import List, Dict, Any
8
+ import yaml
8
9
 
9
10
  import aws_cdk as cdk
10
11
  from aws_cdk import aws_codebuild as codebuild
@@ -84,6 +85,9 @@ class PipelineFactoryStack(IStack):
84
85
 
85
86
  self.deployment_waves: Dict[str, pipelines.Wave] = {}
86
87
 
88
+ # Cache created sources keyed by repo+branch to avoid duplicate node IDs
89
+ self._source_cache: Dict[str, pipelines.CodePipelineSource] = {}
90
+
87
91
  @property
88
92
  def aws_code_pipeline(self) -> pipelines.CodePipeline:
89
93
  """AWS Code Pipeline"""
@@ -114,7 +118,7 @@ class PipelineFactoryStack(IStack):
114
118
  f"\t🚨 Deployment for Environment: {deployment.environment} "
115
119
  f"is disabled."
116
120
  )
117
- if len(pipeline_deployments) == 0:
121
+ if not pipeline_deployments:
118
122
  print(f"\t⛔️ No Pipeline Deployments configured for {self.workload.name}.")
119
123
 
120
124
  return len(pipeline_deployments)
@@ -207,12 +211,12 @@ class PipelineFactoryStack(IStack):
207
211
  stage_config=stage, pipeline_stage=pipeline_stage, deployment=deployment
208
212
  )
209
213
  # add the stacks to a wave or a regular
210
- pre_steps = self._get_pre_steps(stage)
211
- post_steps = self._get_post_steps(stage)
214
+ pre_steps = self._get_pre_steps(stage, deployment)
215
+ post_steps = self._get_post_steps(stage, deployment)
212
216
  wave_name = stage.wave_name
213
217
 
214
218
  # if we don't have any stacks we'll need to use the wave
215
- if len(stage.stacks) == 0:
219
+ if not stage.stacks:
216
220
  wave_name = stage.name
217
221
 
218
222
  if wave_name:
@@ -237,16 +241,16 @@ class PipelineFactoryStack(IStack):
237
241
  )
238
242
 
239
243
  def _get_pre_steps(
240
- self, stage_config: PipelineStageConfig
244
+ self, stage_config: PipelineStageConfig, deployment: DeploymentConfig
241
245
  ) -> List[pipelines.ShellStep]:
242
- return self._get_steps("pre_steps", stage_config)
246
+ return self._get_steps("pre_steps", stage_config, deployment)
243
247
 
244
248
  def _get_post_steps(
245
- self, stage_config: PipelineStageConfig
249
+ self, stage_config: PipelineStageConfig, deployment: DeploymentConfig
246
250
  ) -> List[pipelines.ShellStep]:
247
- return self._get_steps("post_steps", stage_config)
251
+ return self._get_steps("post_steps", stage_config, deployment)
248
252
 
249
- def _get_steps(self, key: str, stage_config: PipelineStageConfig):
253
+ def _get_steps(self, key: str, stage_config: PipelineStageConfig, deployment: DeploymentConfig):
250
254
  """
251
255
  Gets the build steps from the config.json.
252
256
 
@@ -255,29 +259,219 @@ class PipelineFactoryStack(IStack):
255
259
  - A single multi-line string (treated as a single script block)
256
260
 
257
261
  This allows support for complex shell constructs like if blocks, loops, etc.
262
+
263
+ For builds with source/buildspec/environment, creates CodeBuildStep instead of ShellStep.
258
264
  """
259
- shell_steps: List[pipelines.ShellStep] = []
265
+ shell_steps: List[pipelines.Step] = []
266
+
267
+ # Only process builds if this stage explicitly defines them
268
+ if not stage_config.dictionary.get("builds"):
269
+ return shell_steps
260
270
 
261
271
  for build in stage_config.builds:
262
272
  if str(build.get("enabled", "true")).lower() == "true":
263
- steps = build.get(key, [])
264
- step: Dict[str, Any]
265
- for step in steps:
266
- step_id = step.get("id") or step.get("name")
267
- commands = step.get("commands", [])
268
-
269
- # Normalize commands to a list
270
- # If commands is a single string, wrap it in a list
271
- if isinstance(commands, str):
272
- commands = [commands]
273
-
274
- shell_step = pipelines.ShellStep(
275
- id=step_id,
276
- commands=commands,
277
- )
278
- shell_steps.append(shell_step)
273
+ # Check if this is a CodeBuild step (has source, buildspec, or environment)
274
+ if build.get("source") or build.get("buildspec") or build.get("environment"):
275
+ # Create CodeBuildStep for external builds
276
+ codebuild_step = self._create_codebuild_step(build, key, deployment, stage_config.name)
277
+ if codebuild_step:
278
+ shell_steps.append(codebuild_step)
279
+ else:
280
+ # Create traditional ShellStep for inline commands
281
+ steps = build.get(key, [])
282
+ step: Dict[str, Any]
283
+ for step in steps:
284
+ step_id = step.get("id") or step.get("name")
285
+ commands = step.get("commands", [])
286
+
287
+ # Normalize commands to a list
288
+ # If commands is a single string, wrap it in a list
289
+ if isinstance(commands, str):
290
+ commands = [commands]
291
+
292
+ shell_step = pipelines.ShellStep(
293
+ id=step_id,
294
+ commands=commands,
295
+ )
296
+ shell_steps.append(shell_step)
279
297
 
280
298
  return shell_steps
299
+
300
+ def _create_codebuild_step(self, build: Dict[str, Any], key: str, deployment: DeploymentConfig, stage_name: str) -> pipelines.CodeBuildStep:
301
+ """
302
+ Creates a CodeBuildStep for builds that specify source, buildspec, or environment.
303
+
304
+ Supports:
305
+ - External GitHub repositories (public and private)
306
+ - Custom buildspec files
307
+ - Environment configuration (compute type, image, privileged mode)
308
+ - Environment variables
309
+ - GitHub authentication via CodeConnections
310
+ """
311
+ build_name = build.get("name", "custom-build")
312
+
313
+ # Parse source configuration
314
+ source_config = build.get("source", {})
315
+ source_type = source_config.get("type", "GITHUB").upper()
316
+ source_location = source_config.get("location")
317
+ source_branch = source_config.get("branch", "main")
318
+
319
+ # Determine if this is the right step type (pre or post)
320
+ # Only create the step if it's supposed to run at this point
321
+ # For now, assume CodeBuild steps run as pre_steps by default
322
+ if key != "pre_steps":
323
+ return None
324
+
325
+ if not source_location:
326
+ logger.warning(f"Build '{build_name}' has no source location specified, skipping")
327
+ return None
328
+
329
+ # Parse buildspec
330
+ # If a buildspec path is specified, try to load it locally and convert to from_object()
331
+ buildspec_path = build.get("buildspec")
332
+ buildspec = None
333
+
334
+ if buildspec_path:
335
+ try:
336
+ candidate = Path(buildspec_path)
337
+ if not candidate.is_file():
338
+ # Try relative to cwd
339
+ candidate = Path(os.getcwd()) / buildspec_path
340
+ if candidate.is_file():
341
+ with candidate.open("r", encoding="utf-8") as f:
342
+ yml = yaml.safe_load(f)
343
+ if isinstance(yml, dict):
344
+ buildspec = codebuild.BuildSpec.from_object(yml)
345
+ else:
346
+ raise ValueError("Parsed buildspec YAML is not a dictionary")
347
+ else:
348
+ raise FileNotFoundError(f"Buildspec file not found: {buildspec_path}")
349
+ except Exception as exc:
350
+ raise RuntimeError(f"Failed to load buildspec from '{buildspec_path}': {exc}")
351
+ else:
352
+ # No buildspec specified - check for inline commands
353
+ inline_commands = build.get("commands", [])
354
+ if isinstance(inline_commands, str):
355
+ inline_commands = [inline_commands]
356
+ if inline_commands:
357
+ # Create inline buildspec from commands
358
+ buildspec = codebuild.BuildSpec.from_object({
359
+ "version": "0.2",
360
+ "phases": {
361
+ "build": {
362
+ "commands": inline_commands
363
+ }
364
+ }
365
+ })
366
+
367
+ # Parse environment configuration
368
+ env_config = build.get("environment", {})
369
+ compute_type_str = env_config.get("compute_type", "BUILD_GENERAL1_SMALL")
370
+
371
+ # Map string to CDK enum
372
+ compute_type_map = {
373
+ "BUILD_GENERAL1_SMALL": codebuild.ComputeType.SMALL,
374
+ "BUILD_GENERAL1_MEDIUM": codebuild.ComputeType.MEDIUM,
375
+ "BUILD_GENERAL1_LARGE": codebuild.ComputeType.LARGE,
376
+ "BUILD_GENERAL1_2XLARGE": codebuild.ComputeType.X2_LARGE,
377
+ }
378
+ compute_type = compute_type_map.get(compute_type_str, codebuild.ComputeType.SMALL)
379
+
380
+ build_image_str = env_config.get("image", "aws/codebuild/standard:7.0")
381
+ privileged_mode = env_config.get("privileged_mode", False)
382
+
383
+ # Parse build image
384
+ if build_image_str.startswith("aws/codebuild/standard:"):
385
+ version = build_image_str.split(":")[-1]
386
+ build_image = codebuild.LinuxBuildImage.from_code_build_image_id(f"aws/codebuild/standard:{version}")
387
+ else:
388
+ build_image = codebuild.LinuxBuildImage.from_code_build_image_id(build_image_str)
389
+
390
+ # Parse environment variables
391
+ env_vars_list = build.get("environment_variables", [])
392
+ env_vars = {}
393
+ for env_var in env_vars_list:
394
+ var_name = env_var.get("name")
395
+ var_value = env_var.get("value")
396
+ var_type = env_var.get("type", "PLAINTEXT")
397
+
398
+ if var_name and var_value is not None:
399
+ if var_type == "PLAINTEXT":
400
+ env_vars[var_name] = codebuild.BuildEnvironmentVariable(value=str(var_value))
401
+ elif var_type == "PARAMETER_STORE":
402
+ env_vars[var_name] = codebuild.BuildEnvironmentVariable(
403
+ value=str(var_value),
404
+ type=codebuild.BuildEnvironmentVariableType.PARAMETER_STORE
405
+ )
406
+ elif var_type == "SECRETS_MANAGER":
407
+ env_vars[var_name] = codebuild.BuildEnvironmentVariable(
408
+ value=str(var_value),
409
+ type=codebuild.BuildEnvironmentVariableType.SECRETS_MANAGER
410
+ )
411
+
412
+ # Create build environment
413
+ build_environment = codebuild.BuildEnvironment(
414
+ build_image=build_image,
415
+ compute_type=compute_type,
416
+ privileged=privileged_mode,
417
+ environment_variables=env_vars
418
+ )
419
+
420
+ # Determine input source
421
+ if source_type == "GITHUB":
422
+ # GitHub source - supports both public and private repos
423
+ # For private repos, use the workload's code repository connection
424
+ repo_string = self._parse_github_repo_string(source_location)
425
+ cache_key = f"{repo_string}:{source_branch}"
426
+ input_source = self._source_cache.get(cache_key)
427
+ if not input_source:
428
+ input_source = pipelines.CodePipelineSource.connection(
429
+ repo_string=repo_string,
430
+ branch=source_branch,
431
+ connection_arn=self.workload.devops.code_repository.connector_arn,
432
+ action_name=f"{build_name}",
433
+ )
434
+ self._source_cache[cache_key] = input_source
435
+ else:
436
+ logger.warning(f"Unsupported source type '{source_type}' for build '{build_name}'")
437
+ return None
438
+
439
+ # Create CodeBuildStep
440
+ logger.info(f"Creating CodeBuildStep '{build_name}' with source from {source_location}")
441
+
442
+ # CodeBuildStep requires 'commands' param; when using a buildspec (repo or inline)
443
+ # we pass an empty list so only the buildspec runs
444
+ commands: List[str] = []
445
+
446
+ codebuild_step = pipelines.CodeBuildStep(
447
+ id=f"{build_name}-{stage_name}",
448
+ input=input_source,
449
+ commands=commands,
450
+ build_environment=build_environment,
451
+ partial_build_spec=buildspec,
452
+ )
453
+
454
+ return codebuild_step
455
+
456
+ def _parse_github_repo_string(self, location: str) -> str:
457
+ """
458
+ Converts GitHub URL to org/repo format.
459
+
460
+ Examples:
461
+ - https://github.com/geekcafe/myrepo.git -> geekcafe/myrepo
462
+ - https://github.com/geekcafe/myrepo -> geekcafe/myrepo
463
+ - geekcafe/myrepo -> geekcafe/myrepo
464
+ """
465
+ if location.startswith("https://github.com/"):
466
+ # Remove https://github.com/ prefix
467
+ repo_string = location.replace("https://github.com/", "")
468
+ # Remove .git suffix if present
469
+ if repo_string.endswith(".git"):
470
+ repo_string = repo_string[:-4]
471
+ return repo_string
472
+ else:
473
+ # Assume it's already in org/repo format
474
+ return location
281
475
 
282
476
  def __setup_stacks(
283
477
  self,
@@ -305,6 +499,7 @@ class PipelineFactoryStack(IStack):
305
499
  scope=pipeline_stage,
306
500
  id=stack_config.name,
307
501
  deployment=deployment,
502
+ stack_config=stack_config,
308
503
  add_env_context=self.add_env_context,
309
504
  **kwargs,
310
505
  )
@@ -324,7 +519,7 @@ class PipelineFactoryStack(IStack):
324
519
  f"\t\t ⚠️ Stack {stack_config.name} is disabled in stage: {stage_config.name}"
325
520
  )
326
521
 
327
- if len(cf_stacks) == 0:
522
+ if not cf_stacks:
328
523
  print(f"\t\t ⚠️ No stacks added to stage: {stage_config.name}")
329
524
  print(f"\t\t ⚠️ Internal Stack Count: {len(stage_config.stacks)}")
330
525
 
@@ -11,10 +11,28 @@ from cdk_factory.interfaces.istack import IStack
11
11
  from cdk_factory.stack.stack_module_loader import ModuleLoader
12
12
  from cdk_factory.stack.stack_module_registry import modules
13
13
  from cdk_factory.configurations.deployment import DeploymentConfig
14
+ from cdk_factory.configurations.stack import StackConfig
14
15
 
15
16
 
16
17
  class StackFactory:
17
18
  """Stack Factory"""
19
+
20
+ # Default descriptions by module type
21
+ DEFAULT_DESCRIPTIONS = {
22
+ "vpc_stack": "VPC infrastructure with public and private subnets across multiple availability zones",
23
+ "security_group_stack": "Security groups for network access control",
24
+ "security_group_full_stack": "Security groups for ALB, ECS, RDS, and monitoring",
25
+ "rds_stack": "Managed relational database instance with automated backups",
26
+ "s3_bucket_stack": "S3 bucket for object storage",
27
+ "media_bucket_stack": "S3 bucket for media asset storage with CDN integration",
28
+ "static_website_stack": "Static website hosted on S3 with CloudFront distribution",
29
+ "ecs_service_stack": "ECS service with auto-scaling and load balancing",
30
+ "lambda_stack": "Lambda function for serverless compute",
31
+ "api_gateway_stack": "API Gateway for REST API endpoints",
32
+ "cloudfront_stack": "CloudFront CDN distribution",
33
+ "monitoring_stack": "CloudWatch monitoring, alarms, and dashboards",
34
+ "ecr_stack": "Elastic Container Registry for Docker images",
35
+ }
18
36
 
19
37
  def __init__(self):
20
38
  ml: ModuleLoader = ModuleLoader()
@@ -27,6 +45,7 @@ class StackFactory:
27
45
  scope,
28
46
  id: str, # pylint: disable=redefined-builtin
29
47
  deployment: Optional[DeploymentConfig] = None,
48
+ stack_config: Optional[StackConfig] = None,
30
49
  add_env_context: bool = True,
31
50
  **kwargs,
32
51
  ) -> IStack:
@@ -40,6 +59,12 @@ class StackFactory:
40
59
  if deployment and add_env_context:
41
60
  env_kwargs = self._get_environment_kwargs(deployment)
42
61
  kwargs.update(env_kwargs)
62
+
63
+ # Add description if not already provided in kwargs
64
+ if "description" not in kwargs:
65
+ description = self._get_stack_description(module_name, stack_config)
66
+ if description:
67
+ kwargs["description"] = description
43
68
 
44
69
  module = stack_class(scope=scope, id=id, **kwargs)
45
70
 
@@ -52,3 +77,12 @@ class StackFactory:
52
77
  region=deployment.region
53
78
  )
54
79
  return {"env": env}
80
+
81
+ def _get_stack_description(self, module_name: str, stack_config: Optional[StackConfig] = None) -> Optional[str]:
82
+ """Get stack description from config or default"""
83
+ # First check if stack_config has a description
84
+ if stack_config and stack_config.description:
85
+ return stack_config.description
86
+
87
+ # Otherwise use default description based on module type
88
+ return self.DEFAULT_DESCRIPTIONS.get(module_name)
@@ -12,9 +12,10 @@ paths = []
12
12
 
13
13
  try:
14
14
  paths = __path__
15
- except: # noqa: E722, pylint: disable=bare-except
15
+ except (AttributeError, ImportError) as e:
16
+ # Fallback to using os.path if __path__ is not available
16
17
  import os
17
-
18
+
18
19
  paths.append(os.path.dirname(__file__))
19
20
 
20
21
 
@@ -0,0 +1,6 @@
1
+ """
2
+ ACM Stack Module
3
+ """
4
+ from .acm_stack import AcmStack
5
+
6
+ __all__ = ["AcmStack"]
@@ -0,0 +1,169 @@
1
+ """
2
+ ACM (AWS Certificate Manager) Stack Pattern for CDK-Factory
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
+ import aws_cdk as cdk
10
+ from aws_cdk import aws_certificatemanager as acm
11
+ from aws_cdk import aws_route53 as route53
12
+ from aws_lambda_powertools import Logger
13
+ from constructs import Construct
14
+
15
+ from cdk_factory.configurations.deployment import DeploymentConfig
16
+ from cdk_factory.configurations.stack import StackConfig
17
+ from cdk_factory.configurations.resources.acm import AcmConfig
18
+ from cdk_factory.interfaces.istack import IStack
19
+ from cdk_factory.interfaces.standardized_ssm_mixin import StandardizedSsmMixin
20
+ from cdk_factory.stack.stack_module_registry import register_stack
21
+ from cdk_factory.workload.workload_factory import WorkloadConfig
22
+
23
+ logger = Logger(service="AcmStack")
24
+
25
+
26
+ @register_stack("acm_stack")
27
+ @register_stack("certificate_stack")
28
+ class AcmStack(IStack, StandardizedSsmMixin):
29
+ """
30
+ Reusable stack for AWS Certificate Manager.
31
+ Supports creating ACM certificates with DNS validation via Route53.
32
+ """
33
+
34
+ def __init__(self, scope: Construct, id: str, **kwargs) -> None:
35
+ super().__init__(scope, id, **kwargs)
36
+ self.acm_config = None
37
+ self.stack_config = None
38
+ self.deployment = None
39
+ self.workload = None
40
+ self.certificate = None
41
+ self.hosted_zone = None
42
+
43
+ def build(
44
+ self,
45
+ stack_config: StackConfig,
46
+ deployment: DeploymentConfig,
47
+ workload: WorkloadConfig,
48
+ ) -> None:
49
+ """Build the ACM Certificate stack"""
50
+ self._build(stack_config, deployment, workload)
51
+
52
+ def _build(
53
+ self,
54
+ stack_config: StackConfig,
55
+ deployment: DeploymentConfig,
56
+ workload: WorkloadConfig,
57
+ ) -> None:
58
+ """Internal build method for the ACM Certificate stack"""
59
+ self.stack_config = stack_config
60
+ self.deployment = deployment
61
+ self.workload = workload
62
+ self.acm_config = AcmConfig(
63
+ stack_config.dictionary.get("certificate", {}), deployment
64
+ )
65
+
66
+ cert_name = deployment.build_resource_name(self.acm_config.name)
67
+
68
+ # Get or import hosted zone for DNS validation
69
+ if self.acm_config.hosted_zone_id:
70
+ self.hosted_zone = self._get_hosted_zone()
71
+
72
+ # Create the certificate
73
+ self.certificate = self._create_certificate(cert_name)
74
+
75
+ # Export certificate ARN to SSM
76
+ self._export_certificate_arn(cert_name)
77
+
78
+ # Add outputs
79
+ self._add_outputs(cert_name)
80
+
81
+ def _get_hosted_zone(self) -> route53.IHostedZone:
82
+ """Get the Route53 hosted zone for DNS validation"""
83
+ if self.acm_config.hosted_zone_id:
84
+ return route53.HostedZone.from_hosted_zone_attributes(
85
+ self,
86
+ "HostedZone",
87
+ hosted_zone_id=self.acm_config.hosted_zone_id,
88
+ zone_name=self.acm_config.domain_name,
89
+ )
90
+ else:
91
+ raise ValueError(
92
+ "hosted_zone_id is required for DNS validation. "
93
+ "Provide it in the certificate configuration."
94
+ )
95
+
96
+ def _create_certificate(self, cert_name: str) -> acm.Certificate:
97
+ """Create an ACM certificate with DNS validation"""
98
+
99
+ # Prepare certificate properties
100
+ cert_props = {
101
+ "domain_name": self.acm_config.domain_name,
102
+ }
103
+
104
+ # Add DNS validation if hosted zone is available
105
+ if self.hosted_zone:
106
+ cert_props["validation"] = acm.CertificateValidation.from_dns(
107
+ self.hosted_zone
108
+ )
109
+
110
+ # Add subject alternative names if provided
111
+ if self.acm_config.subject_alternative_names:
112
+ cert_props["subject_alternative_names"] = (
113
+ self.acm_config.subject_alternative_names
114
+ )
115
+
116
+ certificate = acm.Certificate(
117
+ self,
118
+ cert_name,
119
+ **cert_props
120
+ )
121
+
122
+ # Add tags
123
+ for key, value in self.acm_config.tags.items():
124
+ cdk.Tags.of(certificate).add(key, value)
125
+
126
+ logger.info(f"Created certificate for domain: {self.acm_config.domain_name}")
127
+
128
+ return certificate
129
+
130
+ def _export_certificate_arn(self, cert_name: str) -> None:
131
+ """Export certificate ARN to SSM Parameter Store"""
132
+ ssm_exports = self.acm_config.ssm_exports
133
+
134
+ if not ssm_exports:
135
+ logger.debug("No SSM exports configured for certificate")
136
+ return
137
+
138
+ # Export certificate ARN
139
+ if "certificate_arn" in ssm_exports:
140
+ param_name = ssm_exports["certificate_arn"]
141
+ if not param_name.startswith("/"):
142
+ param_name = f"/{param_name}"
143
+
144
+ self.export_ssm_parameter(
145
+ scope=self,
146
+ id=f"{cert_name}-cert-arn-param",
147
+ value=self.certificate.certificate_arn,
148
+ parameter_name=param_name,
149
+ description=f"Certificate ARN for {self.acm_config.domain_name}",
150
+ )
151
+ logger.info(f"Exported certificate ARN to SSM: {param_name}")
152
+
153
+ def _add_outputs(self, cert_name: str) -> None:
154
+ """Add CloudFormation outputs"""
155
+ cdk.CfnOutput(
156
+ self,
157
+ "CertificateArn",
158
+ value=self.certificate.certificate_arn,
159
+ description=f"Certificate ARN for {self.acm_config.domain_name}",
160
+ export_name=f"{cert_name}-arn",
161
+ )
162
+
163
+ cdk.CfnOutput(
164
+ self,
165
+ "DomainName",
166
+ value=self.acm_config.domain_name,
167
+ description="Primary domain name for the certificate",
168
+ export_name=f"{cert_name}-domain",
169
+ )