djd 0.1.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.
Files changed (44) hide show
  1. deploy/__init__.py +3 -0
  2. deploy/aws.py +108 -0
  3. deploy/cli/deploy.py +428 -0
  4. deploy/cli/templates.py +118 -0
  5. deploy/config.py +42 -0
  6. deploy/context.py +259 -0
  7. deploy/git.py +97 -0
  8. deploy/history.py +8 -0
  9. deploy/logging.py +74 -0
  10. deploy/management/__init__.py +0 -0
  11. deploy/management/base_command.py +12 -0
  12. deploy/management/commands/__init__.py +0 -0
  13. deploy/management/commands/deploy.py +6 -0
  14. deploy/management/commands/templates.py +6 -0
  15. deploy/renderables/tables.py +212 -0
  16. deploy/renderables/util.py +55 -0
  17. deploy/templates/Dockerfile.j2 +80 -0
  18. deploy/templates/terraform/deployments/{{ name }}/main.tf.j2 +31 -0
  19. deploy/templates/terraform/modules/application/alb.tf.j2 +123 -0
  20. deploy/templates/terraform/modules/application/ecr.tf.j2 +3 -0
  21. deploy/templates/terraform/modules/application/ecs.tf.j2 +155 -0
  22. deploy/templates/terraform/modules/application/elasticache.tf.j2 +34 -0
  23. deploy/templates/terraform/modules/application/iam.tf.j2 +204 -0
  24. deploy/templates/terraform/modules/application/locals.tf.j2 +49 -0
  25. deploy/templates/terraform/modules/application/outputs.tf.j2 +59 -0
  26. deploy/templates/terraform/modules/application/rds.tf.j2 +34 -0
  27. deploy/templates/terraform/modules/application/route53.tf.j2 +45 -0
  28. deploy/templates/terraform/modules/application/s3.tf.j2 +50 -0
  29. deploy/templates/terraform/modules/application/secrets_manager.tf.j2 +35 -0
  30. deploy/templates/terraform/modules/application/security_groups.tf.j2 +126 -0
  31. deploy/templates/terraform/modules/application/ses.tf.j2 +27 -0
  32. deploy/templates/terraform/modules/application/variables.tf.j2 +119 -0
  33. deploy/templates/terraform/modules/application/vpc.tf.j2 +44 -0
  34. deploy/templates/terraform/modules/shared/route53.tf.j2 +11 -0
  35. deploy/templates/terraform/modules/shared/ses.tf.j2 +3 -0
  36. deploy/templates/terraform/modules/shared/variables.tf.j2 +10 -0
  37. deploy/terraform.py +46 -0
  38. deploy/util.py +6 -0
  39. djd-0.1.0.dist-info/METADATA +86 -0
  40. djd-0.1.0.dist-info/RECORD +44 -0
  41. djd-0.1.0.dist-info/WHEEL +5 -0
  42. djd-0.1.0.dist-info/entry_points.txt +3 -0
  43. djd-0.1.0.dist-info/licenses/LICENSE +21 -0
  44. djd-0.1.0.dist-info/top_level.txt +1 -0
deploy/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from deploy.logging import initialize_logging
2
+
3
+ initialize_logging(log_name="deploy")
deploy/aws.py ADDED
@@ -0,0 +1,108 @@
1
+ import logging
2
+ import os
3
+ import pty
4
+ import subprocess
5
+ import traceback
6
+
7
+ from botocore.exceptions import BotoCoreError, ClientError
8
+
9
+ from deploy.config import AWS_REGION
10
+
11
+
12
+ def describe_service_deployments(client, cluster: str, services: list[str]):
13
+ describe_services_response = client.describe_services(cluster=cluster, services=services)
14
+
15
+ all_deployment_updates = []
16
+ for response in describe_services_response["services"]:
17
+ service_name = response["serviceName"]
18
+ deployment_updates = response["deployments"]
19
+ latest_deployment_id = next(reversed(sorted(deployment_updates, key=lambda x: x["createdAt"])))["id"]
20
+
21
+ for deployment_update in deployment_updates:
22
+ deployment_update["service"] = service_name
23
+ deployment_update["is_latest"] = latest_deployment_id == deployment_update["id"]
24
+ all_deployment_updates.append(deployment_update)
25
+
26
+ return all_deployment_updates
27
+
28
+
29
+ def describe_service_tasks(client, cluster: str, services: list[str] | None = None):
30
+ task_ids = []
31
+ if services:
32
+ for service in services:
33
+ list_tasks_response = client.list_tasks(cluster=cluster, serviceName=service)
34
+ task_ids += list_tasks_response["taskArns"]
35
+ else:
36
+ list_tasks_response = client.list_tasks(cluster=cluster)
37
+ task_ids += list_tasks_response["taskArns"]
38
+
39
+ tasks = []
40
+ if task_ids:
41
+ describe_tasks_response = client.describe_tasks(cluster=cluster, tasks=task_ids)
42
+ tasks = describe_tasks_response["tasks"]
43
+
44
+ return tasks
45
+
46
+
47
+ def execute_command(client, command: str, cluster: str, service: str, interactive: bool = False):
48
+ list_tasks_response = client.list_tasks(cluster=cluster, serviceName=service)
49
+ task_ids = list_tasks_response["taskArns"]
50
+
51
+ # TODO: exec command in most recent task (sort first)
52
+
53
+ if len(task_ids):
54
+ task_id = task_ids[0]
55
+
56
+ logging.info("Starting Interactive Session...")
57
+ cmd = ["aws", "ecs", "execute-command", "--cluster", cluster, "--region",
58
+ AWS_REGION, "--task", task_id, "--interactive", "--command", f"'{command}'"]
59
+ logging.info(f'Running Command: \n"{(" ").join(cmd)}"')
60
+
61
+ try:
62
+ if interactive:
63
+ pty.spawn(cmd)
64
+ else:
65
+ subprocess.run(
66
+ cmd,
67
+ check=True,
68
+ text=True,
69
+ capture_output=True
70
+ )
71
+
72
+ except subprocess.CalledProcessError as e:
73
+ traceback.print_exception(e)
74
+ logging.error("Command failed")
75
+
76
+ else:
77
+ logging.info("No running task")
78
+
79
+
80
+ def copy_ecr_image(
81
+ client,
82
+ source_repo_name: str,
83
+ target_repo_name: str,
84
+ image_tag: str = "latest",
85
+ ):
86
+ try:
87
+ # Get the image manifest from the source repo
88
+ get_response = client.batch_get_image(repositoryName=source_repo_name, imageIds=[{"imageTag": image_tag}])
89
+
90
+ if not get_response["images"]:
91
+ raise RuntimeError("Image not found in source repo")
92
+
93
+ image = get_response["images"][0]
94
+ manifest = image["imageManifest"]
95
+ media_type = image.get("imageManifestMediaType", "application/vnd.docker.distribution.manifest.v2+json")
96
+
97
+ # Push the manifest to the target repo
98
+ client.put_image(
99
+ repositoryName=target_repo_name,
100
+ imageManifest=manifest,
101
+ imageTag=image_tag,
102
+ imageManifestMediaType=media_type,
103
+ )
104
+
105
+ except (ClientError, BotoCoreError):
106
+ return False
107
+
108
+ return True
deploy/cli/deploy.py ADDED
@@ -0,0 +1,428 @@
1
+ import logging
2
+ import os
3
+ import subprocess
4
+ import time
5
+ from collections import defaultdict
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from deploy.config import (
10
+ AWS_REGION,
11
+ DEFAULT_SERVICES,
12
+ DOCKERFILE_PATH,
13
+ GIT_COPY_PATH,
14
+ MIGRATION_TIMEOUT_SECONDS,
15
+ TERRAFORM_PATH,
16
+ )
17
+
18
+ from deploy.context import DeployContext, DeployContextObj
19
+ from deploy.history import DeployHistoryType
20
+ from deploy.renderables.tables import (
21
+ DeployHistoryTable,
22
+ ECSDeploymentStatusTable,
23
+ ECSTaskStatusTable,
24
+ )
25
+ from deploy.renderables.util import RenderableDatetime
26
+ from rich import print
27
+
28
+ app = typer.Typer(name="deploy", help="Manage AWS ECS deployments.")
29
+
30
+
31
+ @app.callback()
32
+ def initialize(
33
+ ctx: typer.Context,
34
+ environment: str,
35
+ debug: bool = False,
36
+ confirm: bool = False,
37
+ refresh: float = 1.0,
38
+ docker_context_path: Path = GIT_COPY_PATH,
39
+ github_workflow: bool = False,
40
+ ):
41
+ ctx.obj = DeployContextObj(
42
+ environment,
43
+ debug=debug,
44
+ confirm=confirm,
45
+ refresh=refresh,
46
+ docker_context_path=docker_context_path,
47
+ github_workflow=github_workflow,
48
+ )
49
+
50
+
51
+ @app.command(help="Run a generic Terraform command for the specified deployment.")
52
+ def tf(
53
+ ctx: DeployContext,
54
+ command: str = typer.Argument(..., help="Terraform subcommand (e.g. plan, apply)"),
55
+ terraform_args: list[str] = typer.Argument(None, help="Extra args to pass to terraform", hidden=True),
56
+ ):
57
+ subprocess.run(
58
+ ["terraform", command] + (terraform_args or []),
59
+ cwd=os.path.join(TERRAFORM_PATH, "deployments", ctx.obj.environment),
60
+ check=True,
61
+ )
62
+
63
+
64
+ @app.command(help="Log-in to AWS ECR instance with Docker.")
65
+ def docker_login(
66
+ ctx: DeployContext,
67
+ ):
68
+ ecr_image_uri = ctx.obj.terraform["ecr_image_uri"]
69
+
70
+ logging.info("Logging into ECR...")
71
+ password = subprocess.check_output(
72
+ ["aws", "ecr", "get-login-password", "--region", AWS_REGION],
73
+ text=True,
74
+ )
75
+ subprocess.check_output(
76
+ ["docker", "login", "--username", "AWS", "--password-stdin", ecr_image_uri],
77
+ input=password,
78
+ text=True,
79
+ )
80
+ logging.info("Logged in to ECR successfully")
81
+
82
+
83
+ @app.command(help="Build docker image for specified environment.")
84
+ def docker_build(
85
+ ctx: DeployContext,
86
+ branch: str = "main",
87
+ push: bool = True,
88
+ cache: bool = True,
89
+ ) -> bool:
90
+ ctx.invoke(docker_login, ctx=ctx)
91
+
92
+ # Determine ECR Repo
93
+ ecr_image_uri = ctx.obj.terraform["ecr_image_uri"]
94
+ ecr_cache_uri = ecr_image_uri.replace(":latest", ":cache")
95
+
96
+ # Build and upload docker image
97
+ logging.info(f'Building docker image "{ecr_image_uri}"...')
98
+ build_command = [
99
+ "docker", "buildx", "build",
100
+ str(ctx.obj.docker_context_path),
101
+ "--platform=linux/amd64",
102
+ "--provenance=false",
103
+ "--sbom=false",
104
+ "-t", ecr_image_uri,
105
+ ]
106
+
107
+ # Pull latest code
108
+ if ctx.obj.docker_context_path != ".":
109
+ ctx.obj.git.pull(branch)
110
+ git_info = ctx.obj.git.get_info()
111
+ build_command += [
112
+ "--build-arg",
113
+ f"COMMIT_HASH_MAIN={git_info['commit']['hexsha']}",
114
+ ]
115
+
116
+ if cache:
117
+ build_command += [
118
+ f"--cache-from=type=registry,ref={ecr_cache_uri}",
119
+ f"--cache-to=type=registry,ref={ecr_cache_uri},mode=max,image-manifest=true,oci-mediatypes=true",
120
+ ]
121
+ else:
122
+ build_command += ["--no-cache"]
123
+
124
+ if push:
125
+ build_command += ["--push"]
126
+
127
+ build_command += ["-f", DOCKERFILE_PATH]
128
+
129
+ logging.info(f"Running: {(' ').join(build_command)}")
130
+
131
+ build_process = subprocess.run(build_command)
132
+ if build_process.returncode != 0:
133
+ logging.error("Failed to build image.")
134
+ return False
135
+
136
+ if push:
137
+ ctx.obj.log(DeployHistoryType.docker_image)
138
+
139
+ return True
140
+
141
+
142
+ @app.command(help="Show ECS Services for a specified environment.")
143
+ def show_services(
144
+ ctx: DeployContext,
145
+ services: list[str] = typer.Argument(None, help="List of ECS services to target"),
146
+ ):
147
+ deployments = ctx.obj.describe_service_deployments(services)
148
+ print(ECSDeploymentStatusTable(deployments))
149
+
150
+
151
+ @app.command(help="Show tasks for a specified environment.")
152
+ def show_tasks(
153
+ ctx: DeployContext,
154
+ services: list[str] = typer.Argument(None, help="List of ECS services to target"),
155
+ ):
156
+ tasks = ctx.obj.describe_service_tasks(services)
157
+ print(ECSTaskStatusTable(tasks))
158
+
159
+
160
+ @app.command(help="Watch deployments for a specified environment.")
161
+ def watch_deployments(
162
+ ctx: DeployContext,
163
+ services: list[str] = typer.Argument(None, help="List of ECS services to target"),
164
+ ):
165
+ status = defaultdict(dict)
166
+ status_keys_watched = ["status", "rolloutState", "runningCount", "desiredCount"]
167
+
168
+ table = ECSDeploymentStatusTable()
169
+ watched_deployments = set()
170
+ deployment_updates = ctx.obj.describe_service_deployments(services)
171
+ for deployment_update in reversed(sorted(deployment_updates, key=lambda x: x["createdAt"])):
172
+ status[deployment_update["id"]] = deployment_update
173
+ table.add_entry(deployment_update)
174
+ if deployment_update.get("is_latest"):
175
+ watched_deployments.add(deployment_update["id"])
176
+
177
+ print(table)
178
+
179
+ try:
180
+ while True:
181
+ changed = False
182
+ removed = set(status.keys())
183
+
184
+ deployment_updates = ctx.obj.describe_service_deployments(services)
185
+ for deployment_update in deployment_updates:
186
+ # Add deployment to status (Another task has added this deployment)
187
+ if deployment_update["id"] not in removed:
188
+ status[deployment_update["id"]] = deployment_update
189
+ changed = True
190
+
191
+ else:
192
+ removed.remove(deployment_update["id"])
193
+
194
+ deployment = status[deployment_update["id"]]
195
+ for key in status_keys_watched:
196
+ if deployment_update[key] != deployment.get(key, None):
197
+ deployment[key] = deployment_update[key]
198
+ changed = True
199
+
200
+ for removed_deployment in removed:
201
+ status.pop(removed_deployment)
202
+
203
+ if changed:
204
+ table = ECSDeploymentStatusTable(show_header=False, title=RenderableDatetime.now())
205
+ for deployment in reversed(sorted(status.values(), key=lambda x: x["createdAt"])):
206
+ table.add_entry(deployment)
207
+
208
+ # Check if watched deployments are successful
209
+ if (
210
+ deployment["rolloutState"] == "COMPLETED"
211
+ and deployment["id"] in watched_deployments
212
+ and deployment["runningCount"] == deployment["desiredCount"]
213
+ ):
214
+ watched_deployments.remove(deployment["id"])
215
+
216
+ print(table)
217
+
218
+ # Terminate when all watched deployments are complete
219
+ if not watched_deployments:
220
+ logging.info('Deployment completed successfully')
221
+ return True
222
+
223
+ time.sleep(ctx.obj.refresh)
224
+
225
+ except KeyboardInterrupt:
226
+ logging.info('Skipping "watch_deployments"')
227
+ return False
228
+
229
+
230
+ @app.command(help="Deploy specified EC2 service.")
231
+ def force_deploy(
232
+ ctx: DeployContext,
233
+ services: list[str] = typer.Argument(None, help="List of ECS services to target"),
234
+ ):
235
+ logging.info(f"Force-deploying environment {ctx.obj.environment}")
236
+
237
+ if ctx.obj.confirm:
238
+ deployments = ctx.obj.describe_service_deployments(services)
239
+ print(ECSDeploymentStatusTable(deployments))
240
+
241
+ if ctx.obj.get_confirmation("Force deploy?"):
242
+ logging.info("Updating Service(s)...")
243
+
244
+ for service in services:
245
+ ctx.obj.update_service(service=service, forceNewDeployment=True)
246
+
247
+ # Poll for deployment updating successfully
248
+ if ctx.invoke(watch_deployments, ctx=ctx, services=services):
249
+ ctx.obj.log(DeployHistoryType.deployment)
250
+ pass
251
+
252
+
253
+ @app.command(help="Show the current deployment state and history")
254
+ def history(
255
+ ctx: DeployContext,
256
+ raw: bool = False,
257
+ ):
258
+ logging.info(f'Environment "{ctx.obj.environment}" deployment history:')
259
+
260
+ if raw:
261
+ print(ctx.obj.history)
262
+ else:
263
+ entries = ctx.obj.history[ctx.obj.environment]
264
+ print(DeployHistoryTable(entries))
265
+
266
+
267
+ @app.command(help="Open an interactive shell in the latest task instance in the specified environment.")
268
+ def ssh(
269
+ ctx: DeployContext,
270
+ service: str = "web",
271
+ ):
272
+ ctx.obj.execute_command("/bin/bash", service, interactive=True)
273
+
274
+
275
+ @app.command(help="Run a django management command in the latest task instance in the specified environment.")
276
+ def manage(
277
+ ctx: DeployContext,
278
+ service: str = "web",
279
+ command: list[str] = typer.Argument(..., help="Django management command, (e.g., 'shell_plus', etc.)"),
280
+ ):
281
+ # TODO: Allow the user the option to kick off a new container using `run_task` for long-running jobs
282
+ cmd = f"uv run manage.py {(' '.join(command))}"
283
+ ctx.obj.execute_command(cmd, service, interactive=True)
284
+
285
+
286
+ @app.command(help="Run migrations on a specified deployment database.")
287
+ def run_migrations(
288
+ ctx: DeployContext,
289
+ service: str = "web",
290
+ ):
291
+ deployments = ctx.obj.describe_service_deployments(services=[service])
292
+
293
+ if ctx.obj.confirm or ctx.obj.debug:
294
+ print(ECSDeploymentStatusTable(deployments))
295
+
296
+ if ctx.obj.get_confirmation("Run Migration?"):
297
+ run_task_response = ctx.obj.run_task(service, "uv run manage.py migrate --no-input")
298
+ if not run_task_response:
299
+ logging.info("Migration Failed")
300
+ return False
301
+
302
+ logging.info("Waiting for task to start...")
303
+
304
+ migration_task_arn = run_task_response["tasks"][0]["taskArn"]
305
+ describe_tasks_response = ctx.obj.describe_tasks(task_arns=[migration_task_arn])
306
+ task = describe_tasks_response["tasks"][0]
307
+ status_keys_watched = ["lastStatus", "healthStatus"]
308
+ print(ECSTaskStatusTable(entries=[task]))
309
+
310
+ started, stopped = None, None
311
+ start = time.perf_counter()
312
+ while time.perf_counter() - start < MIGRATION_TIMEOUT_SECONDS:
313
+ describe_tasks_response = ctx.obj.describe_tasks(task_arns=[migration_task_arn])
314
+ new_task = describe_tasks_response["tasks"][0]
315
+
316
+ if any([task[key] != new_task[key] for key in status_keys_watched]):
317
+ print(
318
+ ECSTaskStatusTable(
319
+ entries=[new_task],
320
+ show_header=False,
321
+ title=RenderableDatetime.now(),
322
+ )
323
+ )
324
+
325
+ task = new_task
326
+
327
+ if not started:
328
+ started = task.get("startedAt")
329
+ if started:
330
+ logging.info(f"Task started at {started}")
331
+
332
+ if not stopped:
333
+ stopped = task.get("stoppedAt")
334
+ if stopped:
335
+ logging.info(f"Task stopped at {stopped}")
336
+
337
+ if task.get("stopCode") == "EssentialContainerExited":
338
+ logging.info("Migration Successful")
339
+ ctx.obj.log(DeployHistoryType.migration)
340
+ return True
341
+
342
+ elif task.get("stopCode") == "TaskFailedToStart":
343
+ logging.info("Migration task failed to start. Exiting...")
344
+ break
345
+
346
+ elif task.get("stopCode"):
347
+ logging.info("Migration Task stopcode:")
348
+ logging.info(task.get("stopCode"))
349
+ break
350
+
351
+ time.sleep(ctx.obj.refresh)
352
+
353
+ logging.info("Migration Failed")
354
+ return False
355
+
356
+
357
+ @app.command(help="Set selected service to desired_count = 0.")
358
+ def stop_service(
359
+ ctx: DeployContext,
360
+ environment: str,
361
+ services: list[str] = typer.Argument(None, help="List of ECS services to target"),
362
+ ):
363
+ logging.info(f"Stopping service(s) {services} on environment {environment}")
364
+
365
+ if ctx.obj.confirm or ctx.obj.debug:
366
+ deployments = ctx.obj.describe_service_deployments(services)
367
+ print(ECSDeploymentStatusTable(deployments))
368
+
369
+ if ctx.obj.get_confirmation("Stop Service(s)?"):
370
+ logging.info("Updating Service(s)...")
371
+
372
+ for service in services:
373
+ ctx.obj.update_service(service=service, desiredCount=0)
374
+
375
+
376
+ @app.command(help="Run entire deployment process.")
377
+ def run_deployment(
378
+ ctx: DeployContext,
379
+ service: str = "web",
380
+ services: list[str] = typer.Argument(None, help="List of ECS services to target"),
381
+ branch: str = "main",
382
+ push: bool = True,
383
+ cache: bool = True,
384
+ migration: bool = True,
385
+ build: bool = True,
386
+ promote_image: str | None = None,
387
+ ):
388
+ services = services or DEFAULT_SERVICES
389
+
390
+ if ctx.obj.github_workflow:
391
+ ctx.obj.git.dispatch_github_workflow(
392
+ "deploy",
393
+ {
394
+ "services": (" ").join(services),
395
+ "environment": ctx.obj.environment,
396
+ "branch": branch,
397
+ "migration": migration,
398
+ },
399
+ )
400
+ return True
401
+
402
+ # Build docker container from git code, or promote an image from a separate ECR repo
403
+ if promote_image:
404
+ ctx.obj.promote_image(promote_image)
405
+ elif build:
406
+ build_result = ctx.invoke(docker_build, ctx=ctx, branch=branch, push=push, cache=cache)
407
+ if not build_result:
408
+ logging.error("Failed to build image, aborting deployment.")
409
+ return
410
+
411
+ # Poll for the migration task completing successfully (abort whole deploy if it fails)
412
+ # Force deploy immediately if no-migrations is set
413
+ if migration:
414
+ migration_result = ctx.invoke(
415
+ run_migrations,
416
+ ctx=ctx,
417
+ service=service,
418
+ )
419
+ if not migration_result:
420
+ logging.error("Failed to run migrations, aborting deployment.")
421
+ return
422
+
423
+ # Force a new deployment on selected services
424
+ ctx.invoke(force_deploy, ctx=ctx, services=services)
425
+
426
+
427
+ def main():
428
+ app()
@@ -0,0 +1,118 @@
1
+ import logging
2
+ from pathlib import Path
3
+
4
+ from rich import print
5
+ import typer
6
+ from deploy.config import AWS_REGION
7
+ from deploy.renderables.util import render_diff
8
+ from deploy.terraform import TEMPLATES_DIR, diff_templates, render_templates
9
+
10
+ app = typer.Typer(name="templates", help="Manage infrastructure templates. (AWS, Terraform, etc.)")
11
+
12
+
13
+ @app.command(help="Initialize deployment by copying template files to local repository.")
14
+ def init(
15
+ name: str,
16
+ aws_profile: str = "default",
17
+ aws_state_bucket: str = "terraform-states",
18
+ tf_module: str = "application",
19
+ ):
20
+ context = {
21
+ "name": name,
22
+ "aws_profile": aws_profile,
23
+ "aws_region": AWS_REGION,
24
+ "aws_state_bucket": aws_state_bucket,
25
+ "tf_module": tf_module,
26
+ }
27
+
28
+ for rendered_content, output_path in render_templates(context):
29
+ # If rendered path already exists, skip
30
+ if output_path.exists():
31
+ logging.warning(f"Skipped {output_path} (already exists)")
32
+ continue
33
+
34
+ # Ensure parent directories exist
35
+ output_path.parent.mkdir(parents=True, exist_ok=True)
36
+
37
+ # Write the file
38
+ output_path.write_text(rendered_content)
39
+ logging.info(f"Created file {output_path}")
40
+
41
+ logging.info(f"Deployment '{name}' created")
42
+
43
+
44
+ @app.command(help="Add a subset of templates to local repository.")
45
+ def add(
46
+ templates_dir: str,
47
+ aws_profile: str = "default",
48
+ aws_state_bucket: str = "terraform-states",
49
+ tf_module: str = "application",
50
+ ):
51
+ context = {
52
+ "aws_profile": aws_profile,
53
+ "aws_region": AWS_REGION,
54
+ "aws_state_bucket": aws_state_bucket,
55
+ "tf_module": tf_module,
56
+ }
57
+
58
+ for rendered_content, output_path in render_templates(context, templates_dir):
59
+ # If rendered path already exists, skip
60
+ if output_path.exists():
61
+ logging.warning(f"Skipped {output_path} (already exists)")
62
+ continue
63
+
64
+ # Ensure parent directories exist
65
+ output_path.parent.mkdir(parents=True, exist_ok=True)
66
+
67
+ # Write the file
68
+ output_path.write_text(rendered_content)
69
+ logging.info(f"Created file {output_path}")
70
+
71
+ logging.info(f"Added templates from '{templates_dir}'")
72
+
73
+
74
+ @app.command(help="Update deployment templates, overwriting files.")
75
+ def update(
76
+ name: str,
77
+ templates_dir: str,
78
+ aws_profile: str = "default",
79
+ aws_state_bucket: str = "terraform-states",
80
+ tf_module: str = "application",
81
+ dry_run: bool = False,
82
+ ):
83
+ context = {
84
+ "name": name,
85
+ "aws_profile": aws_profile,
86
+ "aws_region": AWS_REGION,
87
+ "aws_state_bucket": aws_state_bucket,
88
+ "tf_module": tf_module,
89
+ }
90
+
91
+ for rendered_content, output_path in render_templates(context, templates_dir):
92
+ if "main.tf" in str(output_path):
93
+ continue
94
+
95
+ # Ensure parent directories exist
96
+ output_path.parent.mkdir(parents=True, exist_ok=True)
97
+
98
+ # Check for existing file and print diff if it exists
99
+ modified_files, created_files = 0, 0
100
+ if output_path.exists():
101
+ if diff := diff_templates(rendered_content, output_path):
102
+ if not dry_run:
103
+ output_path.write_text(rendered_content)
104
+ modified_files += 1
105
+ logging.info(f'Modified "{output_path}"')
106
+ print(render_diff(diff, str(output_path)))
107
+ else:
108
+ if not dry_run:
109
+ output_path.write_text(rendered_content)
110
+ created_files += 1
111
+ logging.info(f'Created file "{output_path}"')
112
+
113
+ logging.info(f"{modified_files} files were modified and {created_files} files were created.")
114
+ logging.info(f"Deployment '{name}' Updated")
115
+
116
+
117
+ def main():
118
+ app()
deploy/config.py ADDED
@@ -0,0 +1,42 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ from dotenv import load_dotenv
5
+
6
+ load_dotenv()
7
+
8
+ # Load .env from config/.env as a fallback
9
+ config_env_path = Path("config/.env")
10
+ if config_env_path.exists():
11
+ load_dotenv(dotenv_path=config_env_path, override=False)
12
+
13
+
14
+ # Use env variables if set, otherwise fall back to defaults
15
+ DEPLOY_PATH = Path(os.getenv("DEPLOY_PATH", ".deploy"))
16
+ STATE_PATH = Path(os.getenv("STATE_PATH", DEPLOY_PATH / Path("state.json")))
17
+ HISTORY_PATH = Path(os.getenv("HISTORY_PATH", DEPLOY_PATH / Path("history.json")))
18
+ GIT_COPY_PATH = Path(os.getenv("GIT_COPY_PATH", ".git-copy"))
19
+ TERRAFORM_PATH = Path(os.getenv("TERRAFORM_PATH", "terraform"))
20
+ AWS_REGION = os.getenv("AWS_REGION", "us-east-1")
21
+ AWS_PROFILE = os.getenv("AWS_PROFILE", "zag-dev-cli")
22
+ MIGRATION_TIMEOUT_SECONDS = float(os.getenv("MIGRATION_TIMEOUT_SECONDS", 10 * 60))
23
+ REMOTE_REPO_NAME = os.getenv("REMOTE_REPO_NAME", "zagaran/sample-django-app")
24
+ REMOTE_REPO_URL = os.getenv("REMOTE_REPO_URL", f"git@github.com:{REMOTE_REPO_NAME}.git")
25
+ DOCKERFILE_PATH = os.getenv("DOCKERFILE_PATH", "Dockerfile")
26
+ DEFAULT_SERVICES = os.getenv("DEFAULT_SERVICES", "web,worker").split(",")
27
+
28
+ # Documentation --
29
+ # Definitely set AWS_PROFILE, AWS_REGION, and REMOTE_REPO_NAME
30
+
31
+ # TODO: Fix getting `settings.py` from django -- this should be optional somehow
32
+ # # Assume user runs code from their project root, otherwise they need to set `PROJECT_ROOT` variable
33
+ # PROJECT_ROOT = os.environ.get("PROJECT_ROOT") or os.getcwd()
34
+ # if PROJECT_ROOT not in sys.path:
35
+ # sys.path.insert(0, PROJECT_ROOT)
36
+ #
37
+ # os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
38
+ #
39
+ # try:
40
+ # django.setup()
41
+ # except ModuleNotFoundError:
42
+ # pass