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 CHANGED
@@ -25,14 +25,52 @@ class CdkAppFactory:
25
25
  config_path: str | None = None,
26
26
  outdir: str | None = None,
27
27
  add_env_context: bool = True,
28
+ auto_detect_project_root: bool = True,
29
+ is_pipeline: bool = False,
28
30
  ) -> None:
29
31
 
30
32
  self.args = args or CommandlineArgs()
31
- self.outdir = outdir or self.args.outdir
32
- self.app: aws_cdk.App = aws_cdk.App()
33
33
  self.runtime_directory = runtime_directory or str(Path(__file__).parent)
34
34
  self.config_path: str | None = config_path
35
35
  self.add_env_context = add_env_context
36
+ self._is_pipeline = is_pipeline
37
+
38
+ # Auto-detect outdir for CodeBuild compatibility
39
+ if outdir is None and self.args.outdir is None and auto_detect_project_root:
40
+ # Always detect project root first
41
+ project_root = self._detect_project_root()
42
+
43
+ # Check if we're in CodeBuild or building a pipeline
44
+ in_codebuild = bool(os.getenv('CODEBUILD_SRC_DIR'))
45
+
46
+ # Auto-detect if this is a pipeline deployment by checking config
47
+ is_pipeline_deployment = is_pipeline or self._check_if_pipeline_deployment(config_path)
48
+
49
+ if in_codebuild or is_pipeline_deployment:
50
+ # For pipelines, calculate relative path from runtime_directory to project_root/cdk.out
51
+ # This ensures cdk.out is always at project root, even when app.py is in subdirectory
52
+ runtime_path = Path(self.runtime_directory).resolve()
53
+ project_path = Path(project_root).resolve()
54
+ cdk_out_path = project_path / 'cdk.out'
55
+
56
+ try:
57
+ # Calculate relative path from runtime directory to project_root/cdk.out
58
+ relative_path = os.path.relpath(cdk_out_path, runtime_path)
59
+ self.outdir = relative_path
60
+ if in_codebuild:
61
+ print(f"📦 CodeBuild detected: using relative path '{relative_path}'")
62
+ else:
63
+ print(f"📦 Pipeline deployment detected: using relative path '{relative_path}'")
64
+ except ValueError:
65
+ # If paths are on different drives (Windows), fallback to absolute
66
+ self.outdir = str(cdk_out_path)
67
+ else:
68
+ # For local dev, use absolute path at project root
69
+ self.outdir = os.path.join(project_root, 'cdk.out')
70
+ else:
71
+ self.outdir = outdir or self.args.outdir
72
+
73
+ self.app: aws_cdk.App = aws_cdk.App(outdir=self.outdir)
36
74
 
37
75
  def synth(
38
76
  self,
@@ -84,6 +122,98 @@ class CdkAppFactory:
84
122
 
85
123
  return assembly
86
124
 
125
+ def _detect_project_root(self) -> str:
126
+ """
127
+ Detect project root directory for proper cdk.out placement
128
+
129
+ Priority:
130
+ 1. CODEBUILD_SRC_DIR (CodeBuild environment)
131
+ 2. Find project markers (pyproject.toml, package.json, .git, etc.)
132
+ 3. Assume devops/cdk-iac structure (go up 2 levels)
133
+ 4. Fallback to runtime_directory
134
+
135
+ Returns:
136
+ str: Absolute path to project root
137
+ """
138
+ # Priority 1: CodeBuild environment (most reliable)
139
+ codebuild_src = os.getenv("CODEBUILD_SRC_DIR")
140
+ if codebuild_src:
141
+ return str(Path(codebuild_src).resolve())
142
+
143
+ # Priority 2: Look for project root markers
144
+ # CodeBuild often gets zip without .git, so check multiple markers
145
+ current = Path(self.runtime_directory).resolve()
146
+
147
+ # Walk up the directory tree looking for root markers
148
+ for parent in [current] + list(current.parents):
149
+ # Check for common project root indicators
150
+ root_markers = [
151
+ ".git", # Git repo (local dev)
152
+ "pyproject.toml", # Python project root
153
+ "package.json", # Node project root
154
+ "Cargo.toml", # Rust project root
155
+ ".gitignore", # Often at root
156
+ "README.md", # Often at root
157
+ "requirements.txt", # Python dependencies
158
+ ]
159
+
160
+ # If we find multiple markers at this level, it's likely the root
161
+ markers_found = sum(
162
+ 1 for marker in root_markers if (parent / marker).exists()
163
+ )
164
+ if markers_found >= 2 and parent != current:
165
+ return str(parent)
166
+
167
+ # Priority 3: Assume devops/cdk-iac structure
168
+ # If runtime_directory ends with devops/cdk-iac, go up 2 levels
169
+ parts = current.parts
170
+ if len(parts) >= 2 and parts[-2:] == ("devops", "cdk-iac"):
171
+ return str(current.parent.parent)
172
+
173
+ # Also try just 'cdk-iac' or 'devops'
174
+ if len(parts) >= 1 and parts[-1] in (
175
+ "cdk-iac",
176
+ "devops",
177
+ "infrastructure",
178
+ "iac",
179
+ ):
180
+ # Go up until we're not in these directories
181
+ potential_root = current.parent
182
+ while potential_root.name in ("devops", "cdk-iac", "infrastructure", "iac"):
183
+ potential_root = potential_root.parent
184
+ return str(potential_root)
185
+
186
+ # Priority 4: Fallback to runtime_directory
187
+ return str(current)
188
+
189
+ def _check_if_pipeline_deployment(self, config_path: str | None) -> bool:
190
+ """
191
+ Check if the configuration includes pipeline deployments with CI/CD enabled.
192
+ Returns True if pipelines are detected, False otherwise.
193
+ """
194
+ if not config_path or not os.path.exists(config_path):
195
+ return False
196
+
197
+ try:
198
+ import json
199
+ with open(config_path, 'r') as f:
200
+ config = json.load(f)
201
+
202
+ # Check for workload.deployments with CI/CD enabled
203
+ workload = config.get('workload', {})
204
+ deployments = workload.get('deployments', [])
205
+
206
+ for deployment in deployments:
207
+ devops = deployment.get('devops', {})
208
+ ci_cd = devops.get('ci_cd', {})
209
+ if ci_cd.get('enabled', False):
210
+ return True
211
+
212
+ return False
213
+ except:
214
+ # If we can't read/parse the config, assume not a pipeline
215
+ return False
216
+
87
217
 
88
218
  if __name__ == "__main__":
89
219
  # deploy_test()
cdk_factory/cli.py ADDED
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ CDK Factory CLI
4
+
5
+ Provides convenience commands for initializing and managing cdk-factory projects.
6
+ """
7
+
8
+ import argparse
9
+ import os
10
+ import shutil
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+
15
+ class CdkFactoryCLI:
16
+ """CLI for cdk-factory project management"""
17
+
18
+ def __init__(self):
19
+ self.package_root = Path(__file__).parent.resolve()
20
+ self.templates_dir = self.package_root / "templates"
21
+
22
+ # Verify templates directory exists
23
+ if not self.templates_dir.exists():
24
+ raise RuntimeError(
25
+ f"Templates directory not found at {self.templates_dir}. "
26
+ "Please ensure cdk-factory is properly installed."
27
+ )
28
+
29
+ def init_project(
30
+ self,
31
+ target_dir: str,
32
+ workload_name: Optional[str] = None,
33
+ environment: Optional[str] = None,
34
+ ) -> None:
35
+ """
36
+ Initialize a new cdk-factory project
37
+
38
+ Args:
39
+ target_dir: Directory to initialize (e.g., devops/cdk-iac)
40
+ workload_name: Name of the workload (optional)
41
+ environment: Environment name (optional)
42
+ """
43
+ target_path = Path(target_dir).resolve()
44
+
45
+ if not target_path.exists():
46
+ target_path.mkdir(parents=True, exist_ok=True)
47
+ print(f"✅ Created directory: {target_path}")
48
+
49
+ # Copy app.py template
50
+ app_template = self.templates_dir / "app.py.template"
51
+ app_dest = target_path / "app.py"
52
+
53
+ if app_dest.exists():
54
+ response = input(f"⚠️ {app_dest} already exists. Overwrite? (y/N): ")
55
+ if response.lower() != 'y':
56
+ print("Skipped app.py")
57
+ else:
58
+ shutil.copy(app_template, app_dest)
59
+ print(f"✅ Created {app_dest}")
60
+ else:
61
+ shutil.copy(app_template, app_dest)
62
+ print(f"✅ Created {app_dest}")
63
+
64
+ # Copy cdk.json template
65
+ cdk_json_template = self.templates_dir / "cdk.json.template"
66
+ cdk_json_dest = target_path / "cdk.json"
67
+
68
+ if cdk_json_dest.exists():
69
+ print(f"⚠️ {cdk_json_dest} already exists. Skipping.")
70
+ else:
71
+ shutil.copy(cdk_json_template, cdk_json_dest)
72
+ print(f"✅ Created {cdk_json_dest}")
73
+
74
+ # Create minimal config.json
75
+ config_dest = target_path / "config.json"
76
+ if config_dest.exists():
77
+ print(f"⚠️ {config_dest} already exists. Skipping.")
78
+ else:
79
+ self._create_minimal_config(
80
+ config_dest,
81
+ workload_name=workload_name,
82
+ environment=environment
83
+ )
84
+ print(f"✅ Created {config_dest}")
85
+
86
+ # Create .gitignore
87
+ gitignore_dest = target_path / ".gitignore"
88
+ if not gitignore_dest.exists():
89
+ gitignore_dest.write_text("cdk.out/\n*.swp\n.DS_Store\n__pycache__/\n")
90
+ print(f"✅ Created {gitignore_dest}")
91
+
92
+ print("\n✨ Project initialized successfully!")
93
+ print(f"\nNext steps:")
94
+ print(f"1. cd {target_path}")
95
+ print(f"2. Edit config.json to configure your infrastructure")
96
+ print(f"3. Run: cdk synth")
97
+ print(f"4. Run: cdk deploy")
98
+
99
+ def _create_minimal_config(
100
+ self,
101
+ path: Path,
102
+ workload_name: Optional[str] = None,
103
+ environment: Optional[str] = None
104
+ ) -> None:
105
+ """Create a minimal config.json template"""
106
+ config = {
107
+ "cdk": {
108
+ "parameters": [
109
+ {
110
+ "placeholder": "{{ENVIRONMENT}}",
111
+ "env_var_name": "ENVIRONMENT",
112
+ "cdk_parameter_name": "Environment"
113
+ },
114
+ {
115
+ "placeholder": "{{WORKLOAD_NAME}}",
116
+ "env_var_name": "WORKLOAD_NAME",
117
+ "cdk_parameter_name": "WorkloadName"
118
+ },
119
+ {
120
+ "placeholder": "{{AWS_ACCOUNT}}",
121
+ "env_var_name": "AWS_ACCOUNT",
122
+ "cdk_parameter_name": "AccountNumber"
123
+ },
124
+ {
125
+ "placeholder": "{{AWS_REGION}}",
126
+ "env_var_name": "AWS_REGION",
127
+ "cdk_parameter_name": "AccountRegion"
128
+ }
129
+ ]
130
+ },
131
+ "workload": {
132
+ "name": workload_name or "{{WORKLOAD_NAME}}",
133
+ "environment": environment or "{{ENVIRONMENT}}",
134
+ "deployments": []
135
+ }
136
+ }
137
+
138
+ import json
139
+ path.write_text(json.dumps(config, indent=2))
140
+
141
+ def list_templates(self) -> None:
142
+ """List available templates"""
143
+ print("Available templates:")
144
+ if self.templates_dir.exists():
145
+ for template in self.templates_dir.glob("*.template"):
146
+ print(f" - {template.name}")
147
+ else:
148
+ print(" No templates found")
149
+
150
+
151
+ def main():
152
+ """CLI entry point"""
153
+ parser = argparse.ArgumentParser(
154
+ description="CDK Factory CLI - Initialize and manage cdk-factory projects"
155
+ )
156
+
157
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
158
+
159
+ # Init command
160
+ init_parser = subparsers.add_parser(
161
+ "init",
162
+ help="Initialize a new cdk-factory project"
163
+ )
164
+ init_parser.add_argument(
165
+ "directory",
166
+ help="Target directory (e.g., devops/cdk-iac)"
167
+ )
168
+ init_parser.add_argument(
169
+ "--workload-name",
170
+ help="Workload name"
171
+ )
172
+ init_parser.add_argument(
173
+ "--environment",
174
+ help="Environment (dev, prod, etc.)"
175
+ )
176
+
177
+ # List templates command
178
+ subparsers.add_parser(
179
+ "list-templates",
180
+ help="List available templates"
181
+ )
182
+
183
+ args = parser.parse_args()
184
+
185
+ cli = CdkFactoryCLI()
186
+
187
+ if args.command == "init":
188
+ cli.init_project(
189
+ args.directory,
190
+ workload_name=args.workload_name,
191
+ environment=args.environment
192
+ )
193
+ elif args.command == "list-templates":
194
+ cli.list_templates()
195
+ else:
196
+ parser.print_help()
197
+
198
+
199
+ if __name__ == "__main__":
200
+ main()
@@ -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"