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.
- deploy/__init__.py +3 -0
- deploy/aws.py +108 -0
- deploy/cli/deploy.py +428 -0
- deploy/cli/templates.py +118 -0
- deploy/config.py +42 -0
- deploy/context.py +259 -0
- deploy/git.py +97 -0
- deploy/history.py +8 -0
- deploy/logging.py +74 -0
- deploy/management/__init__.py +0 -0
- deploy/management/base_command.py +12 -0
- deploy/management/commands/__init__.py +0 -0
- deploy/management/commands/deploy.py +6 -0
- deploy/management/commands/templates.py +6 -0
- deploy/renderables/tables.py +212 -0
- deploy/renderables/util.py +55 -0
- deploy/templates/Dockerfile.j2 +80 -0
- deploy/templates/terraform/deployments/{{ name }}/main.tf.j2 +31 -0
- deploy/templates/terraform/modules/application/alb.tf.j2 +123 -0
- deploy/templates/terraform/modules/application/ecr.tf.j2 +3 -0
- deploy/templates/terraform/modules/application/ecs.tf.j2 +155 -0
- deploy/templates/terraform/modules/application/elasticache.tf.j2 +34 -0
- deploy/templates/terraform/modules/application/iam.tf.j2 +204 -0
- deploy/templates/terraform/modules/application/locals.tf.j2 +49 -0
- deploy/templates/terraform/modules/application/outputs.tf.j2 +59 -0
- deploy/templates/terraform/modules/application/rds.tf.j2 +34 -0
- deploy/templates/terraform/modules/application/route53.tf.j2 +45 -0
- deploy/templates/terraform/modules/application/s3.tf.j2 +50 -0
- deploy/templates/terraform/modules/application/secrets_manager.tf.j2 +35 -0
- deploy/templates/terraform/modules/application/security_groups.tf.j2 +126 -0
- deploy/templates/terraform/modules/application/ses.tf.j2 +27 -0
- deploy/templates/terraform/modules/application/variables.tf.j2 +119 -0
- deploy/templates/terraform/modules/application/vpc.tf.j2 +44 -0
- deploy/templates/terraform/modules/shared/route53.tf.j2 +11 -0
- deploy/templates/terraform/modules/shared/ses.tf.j2 +3 -0
- deploy/templates/terraform/modules/shared/variables.tf.j2 +10 -0
- deploy/terraform.py +46 -0
- deploy/util.py +6 -0
- djd-0.1.0.dist-info/METADATA +86 -0
- djd-0.1.0.dist-info/RECORD +44 -0
- djd-0.1.0.dist-info/WHEEL +5 -0
- djd-0.1.0.dist-info/entry_points.txt +3 -0
- djd-0.1.0.dist-info/licenses/LICENSE +21 -0
- djd-0.1.0.dist-info/top_level.txt +1 -0
deploy/__init__.py
ADDED
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()
|
deploy/cli/templates.py
ADDED
|
@@ -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
|