dbt-platform-helper 15.9.0__py3-none-any.whl → 15.11.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.

Potentially problematic release.


This version of dbt-platform-helper might be problematic. Click here for more details.

Files changed (26) hide show
  1. dbt_platform_helper/COMMANDS.md +1 -51
  2. dbt_platform_helper/commands/internal.py +134 -0
  3. dbt_platform_helper/constants.py +14 -0
  4. dbt_platform_helper/domain/conduit.py +1 -1
  5. dbt_platform_helper/domain/config.py +30 -1
  6. dbt_platform_helper/domain/maintenance_page.py +10 -8
  7. dbt_platform_helper/domain/service.py +317 -53
  8. dbt_platform_helper/domain/update_alb_rules.py +346 -0
  9. dbt_platform_helper/entities/platform_config_schema.py +4 -5
  10. dbt_platform_helper/entities/service.py +139 -13
  11. dbt_platform_helper/providers/aws/exceptions.py +5 -0
  12. dbt_platform_helper/providers/aws/sso_auth.py +14 -0
  13. dbt_platform_helper/providers/config.py +0 -11
  14. dbt_platform_helper/providers/ecs.py +104 -11
  15. dbt_platform_helper/providers/load_balancers.py +86 -8
  16. dbt_platform_helper/providers/logs.py +57 -0
  17. dbt_platform_helper/providers/s3.py +21 -0
  18. dbt_platform_helper/providers/terraform_manifest.py +3 -5
  19. dbt_platform_helper/providers/yaml_file.py +13 -5
  20. {dbt_platform_helper-15.9.0.dist-info → dbt_platform_helper-15.11.0.dist-info}/METADATA +5 -3
  21. {dbt_platform_helper-15.9.0.dist-info → dbt_platform_helper-15.11.0.dist-info}/RECORD +25 -22
  22. {dbt_platform_helper-15.9.0.dist-info → dbt_platform_helper-15.11.0.dist-info}/WHEEL +1 -1
  23. platform_helper.py +2 -2
  24. dbt_platform_helper/commands/service.py +0 -53
  25. {dbt_platform_helper-15.9.0.dist-info → dbt_platform_helper-15.11.0.dist-info}/entry_points.txt +0 -0
  26. {dbt_platform_helper-15.9.0.dist-info → dbt_platform_helper-15.11.0.dist-info/licenses}/LICENSE +0 -0
@@ -1,8 +1,15 @@
1
+ import json
2
+ import os
3
+ import time
4
+ from collections import OrderedDict
5
+ from copy import deepcopy
1
6
  from datetime import datetime
2
7
  from importlib.metadata import version
3
8
  from pathlib import Path
9
+ from typing import Any
4
10
 
5
11
  from dbt_platform_helper.constants import IMAGE_TAG_ENV_VAR
12
+ from dbt_platform_helper.constants import PLATFORM_HELPER_PACKAGE_NAME
6
13
  from dbt_platform_helper.constants import PLATFORM_HELPER_VERSION_OVERRIDE_KEY
7
14
  from dbt_platform_helper.constants import SERVICE_CONFIG_FILE
8
15
  from dbt_platform_helper.constants import SERVICE_DIRECTORY
@@ -15,63 +22,68 @@ from dbt_platform_helper.domain.terraform_environment import (
15
22
  )
16
23
  from dbt_platform_helper.entities.service import ServiceConfig
17
24
  from dbt_platform_helper.platform_exception import PlatformException
18
- from dbt_platform_helper.providers.config import ConfigLoader
19
25
  from dbt_platform_helper.providers.config import ConfigProvider
20
26
  from dbt_platform_helper.providers.config_validator import ConfigValidator
27
+ from dbt_platform_helper.providers.ecs import ECS
21
28
  from dbt_platform_helper.providers.environment_variable import (
22
29
  EnvironmentVariableProvider,
23
30
  )
31
+ from dbt_platform_helper.providers.files import FileProvider
24
32
  from dbt_platform_helper.providers.io import ClickIOProvider
33
+ from dbt_platform_helper.providers.logs import LogsProvider
34
+ from dbt_platform_helper.providers.s3 import S3Provider
25
35
  from dbt_platform_helper.providers.terraform_manifest import TerraformManifestProvider
36
+ from dbt_platform_helper.providers.version import InstalledVersionProvider
26
37
  from dbt_platform_helper.providers.yaml_file import YamlFileProvider
27
38
  from dbt_platform_helper.utils.application import load_application
28
39
  from dbt_platform_helper.utils.deep_merge import deep_merge
29
40
 
30
- # TODO add schema version too service config
41
+ SERVICE_TYPES = ["Load Balanced Web Service", "Backend Service"]
42
+ DEPLOYMENT_TIMEOUT_SECONDS = 600
43
+ POLL_INTERVAL_SECONDS = 5
44
+
45
+ # TODO add schema version to service config
31
46
 
32
47
 
33
48
  class ServiceManager:
34
49
  def __init__(
35
50
  self,
36
51
  config_provider=ConfigProvider(ConfigValidator()),
37
- loader: ConfigLoader = ConfigLoader(),
38
52
  io: ClickIOProvider = ClickIOProvider(),
39
53
  file_provider=YamlFileProvider,
40
- environment_variable_provider: EnvironmentVariableProvider = None,
41
54
  manifest_provider: TerraformManifestProvider = None,
42
55
  platform_helper_version_override: str = None,
43
56
  load_application=load_application,
57
+ installed_version_provider: InstalledVersionProvider = InstalledVersionProvider(),
58
+ ecs_provider: ECS = None,
59
+ s3_provider: S3Provider = None,
60
+ logs_provider: LogsProvider = None,
44
61
  ):
45
62
 
46
63
  self.file_provider = file_provider
47
64
  self.config_provider = config_provider
48
- self.loader = loader
49
65
  self.io = io
50
- self.environment_variable_provider = (
51
- environment_variable_provider or EnvironmentVariableProvider()
52
- )
53
66
  self.manifest_provider = manifest_provider or TerraformManifestProvider()
54
67
  self.platform_helper_version_override = (
55
68
  platform_helper_version_override
56
- or self.environment_variable_provider.get(PLATFORM_HELPER_VERSION_OVERRIDE_KEY)
69
+ or EnvironmentVariableProvider.get(PLATFORM_HELPER_VERSION_OVERRIDE_KEY)
57
70
  )
58
71
  self.load_application = load_application
72
+ self.installed_version_provider = installed_version_provider
73
+ self.ecs_provider = ecs_provider
74
+ self.s3_provider = s3_provider
75
+ self.logs_provider = logs_provider
59
76
 
60
- def generate(self, environments: list[str], services: list[str], image_tag_flag: str = None):
77
+ def generate(self, environment: str, services: list[str]):
61
78
 
62
79
  config = self.config_provider.get_enriched_config()
63
80
  application_name = config.get("application", "")
64
81
  application = self.load_application(app=application_name)
65
82
 
66
- if not environments:
67
- for environment in application.environments:
68
- environments.append(environment)
69
- else:
70
- for environment in environments:
71
- if environment not in application.environments:
72
- raise EnvironmentNotFoundException(
73
- f"cannot generate terraform for environment {environment}. It does not exist in your configuration"
74
- )
83
+ if environment not in application.environments:
84
+ raise EnvironmentNotFoundException(
85
+ f"Cannot generate Terraform for environment '{environment}'. It does not exist in your configuration."
86
+ )
75
87
 
76
88
  if not services:
77
89
  try:
@@ -87,67 +99,319 @@ class ServiceManager:
87
99
  )
88
100
  except Exception as e:
89
101
  self.io.abort_with_error(f"Failed extracting services with exception, {e}")
102
+
90
103
  service_models = []
91
104
  for service in services:
92
- service_models.append(
93
- self.loader.load_into_model(
94
- f"{SERVICE_DIRECTORY}/{service}/{SERVICE_CONFIG_FILE}",
95
- ServiceConfig,
96
- )
105
+ file_content = self.file_provider.load(
106
+ f"{SERVICE_DIRECTORY}/{service}/{SERVICE_CONFIG_FILE}"
107
+ )
108
+
109
+ file_content = YamlFileProvider.find_and_replace(
110
+ config=file_content,
111
+ strings=[
112
+ "${PLATFORM_APPLICATION_NAME}",
113
+ "${PLATFORM_ENVIRONMENT_NAME}",
114
+ ],
115
+ replacements=[application.name, environment],
97
116
  )
117
+ service_models.append(ServiceConfig(**file_content))
98
118
 
99
119
  platform_helper_version_for_template: str = (
100
120
  self.platform_helper_version_override
101
121
  or config.get("default_versions", {}).get("platform-helper")
102
122
  )
103
123
 
104
- source_type = self.environment_variable_provider.get(TERRAFORM_MODULE_SOURCE_TYPE_ENV_VAR)
124
+ source_type = EnvironmentVariableProvider.get(TERRAFORM_MODULE_SOURCE_TYPE_ENV_VAR)
105
125
 
106
126
  if source_type == "LOCAL":
107
127
  module_source_override = ServiceConfig.local_terraform_source
108
128
  elif source_type == "OVERRIDE":
109
- module_source_override = self.environment_variable_provider.get(
129
+ module_source_override = EnvironmentVariableProvider.get(
110
130
  TERRAFORM_ECS_SERVICE_MODULE_SOURCE_OVERRIDE_ENV_VAR
111
131
  )
112
132
  else:
113
133
  module_source_override = None
114
134
 
115
- image_tag = image_tag_flag or self.environment_variable_provider.get(IMAGE_TAG_ENV_VAR)
116
- if not image_tag:
117
- raise PlatformException(
118
- f"An image tag must be provided to deploy a service. This can be set by the $IMAGE_TAG environment variable, or the --image-tag flag."
119
- )
120
-
121
135
  timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
122
136
 
123
137
  for service in service_models:
124
138
 
125
- for environment in environments:
139
+ model_dump = service.model_dump(
140
+ exclude_none=True, by_alias=True
141
+ ) # Use by_alias=True so that the Cooldown field 'in_' is written as 'in' in the output
142
+ env_overrides = model_dump.get("environments", {}).get(environment)
143
+ if env_overrides:
144
+ merged_config = deep_merge(model_dump, env_overrides)
145
+ else:
146
+ merged_config = model_dump.copy()
147
+ merged_config.pop("environments", None)
148
+
149
+ output_path = Path(
150
+ f"terraform/{SERVICE_DIRECTORY}/{environment}/{service.name}/{SERVICE_CONFIG_FILE}"
151
+ )
152
+ output_path.parent.mkdir(parents=True, exist_ok=True)
153
+
154
+ self.file_provider.write(
155
+ str(output_path),
156
+ merged_config,
157
+ f"# WARNING: This is an autogenerated file, not for manual editing.\n# Generated by platform-helper {version('dbt-platform-helper')} / {timestamp}.\n",
158
+ )
159
+
160
+ self.manifest_provider.generate_service_config(
161
+ service,
162
+ environment,
163
+ platform_helper_version_for_template,
164
+ config,
165
+ module_source_override,
166
+ )
167
+
168
+ def migrate_copilot_manifests(self) -> None:
169
+ service_directory = Path("services/")
170
+ service_directory.mkdir(parents=True, exist_ok=True)
171
+
172
+ for dirname, _, filenames in os.walk("copilot"):
173
+ if "manifest.yml" in filenames and "environments" not in dirname:
174
+ copilot_manifest = self.file_provider.load(f"{dirname}/manifest.yml")
175
+ service_manifest = OrderedDict(deepcopy(copilot_manifest))
176
+
177
+ if service_manifest["type"] not in SERVICE_TYPES:
178
+ continue
179
+
180
+ if "environments" in service_manifest:
181
+ for env in service_manifest["environments"]:
182
+ env_config = service_manifest["environments"][env]
183
+ if "http" in env_config:
184
+ if "alb" in env_config["http"]:
185
+ del env_config["http"]["alb"]
126
186
 
127
- model_dump = service.model_dump(exclude_none=True)
128
- env_overrides = model_dump.get("environments", {}).get(environment)
129
- if env_overrides:
130
- merged_config = deep_merge(model_dump, env_overrides)
131
- else:
132
- merged_config = model_dump.copy()
133
- merged_config.pop("environments", None)
187
+ if "entrypoint" in service_manifest:
188
+ if isinstance(service_manifest["entrypoint"], str):
189
+ service_manifest["entrypoint"] = [service_manifest["entrypoint"]]
134
190
 
135
- output_path = Path(
136
- f"terraform/{SERVICE_DIRECTORY}/{environment}/{service.name}/{SERVICE_CONFIG_FILE}"
191
+ service_manifest = self.file_provider.find_and_replace(
192
+ config=service_manifest,
193
+ strings=["${COPILOT_APPLICATION_NAME}", "${COPILOT_ENVIRONMENT_NAME}"],
194
+ replacements=["${PLATFORM_APPLICATION_NAME}", "${PLATFORM_ENVIRONMENT_NAME}"],
137
195
  )
138
- output_path.parent.mkdir(parents=True, exist_ok=True)
196
+
197
+ service_manifest = self.file_provider.remove_empty_keys(service_manifest)
198
+
199
+ if "sidecars" in service_manifest:
200
+ new_sidecars = {}
201
+ writable_directories = []
202
+
203
+ for sidecar_name, sidecar in service_manifest["sidecars"].items():
204
+ if "chown" not in sidecar.get("command", "") and "chmod" not in sidecar.get(
205
+ "command", ""
206
+ ):
207
+ new_sidecars[sidecar_name] = sidecar
208
+ if "chown" in sidecar.get("command", "") and "mount_points" in sidecar:
209
+ for mountpoint in sidecar["mount_points"]:
210
+ writable_directories.append(mountpoint["path"])
211
+
212
+ service_manifest["sidecars"] = new_sidecars
213
+ if "storage" in service_manifest:
214
+ service_manifest["storage"]["writable_directories"] = writable_directories
215
+
216
+ service_path = service_directory / service_manifest["name"]
217
+
218
+ self.io.info(
219
+ FileProvider.mkfile(
220
+ service_path,
221
+ "service-config.yml",
222
+ "",
223
+ overwrite=True,
224
+ )
225
+ )
226
+
227
+ current_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
228
+ message = f"# Generated by platform-helper {self.installed_version_provider.get_semantic_version(PLATFORM_HELPER_PACKAGE_NAME)} / {current_date}.\n\n"
139
229
 
140
230
  self.file_provider.write(
141
- str(output_path),
142
- merged_config,
143
- f"# WARNING: This is an autogenerated file, not for manual editing.\n# Generated by platform-helper {version('dbt-platform-helper')} / {timestamp}.\n",
231
+ f"{service_path}/service-config.yml", dict(service_manifest), message
144
232
  )
145
233
 
146
- self.manifest_provider.generate_service_config(
147
- service,
148
- environment,
149
- image_tag,
150
- platform_helper_version_for_template,
151
- config,
152
- module_source_override,
234
+ def deploy(self, service: str, environment: str, application: str, image_tag: str = None):
235
+ """Register a new ECS task definition revision, update the ECS service
236
+ with it, and output Cloudwatch logs until deployment is complete."""
237
+
238
+ application_obj = self.load_application(app=application)
239
+ application_envs = application_obj.environments
240
+ account_id = application_envs.get(environment).account_id
241
+
242
+ s3_response = self.s3_provider.get_object(
243
+ bucket_name=f"ecs-task-definitions-{application}-{environment}",
244
+ object_key=f"{application}/{environment}/{service}.json",
245
+ )
246
+
247
+ task_definition = json.loads(s3_response)
248
+
249
+ image_tag = image_tag or EnvironmentVariableProvider.get(IMAGE_TAG_ENV_VAR)
250
+
251
+ task_def_arn = self.ecs_provider.register_task_definition(
252
+ service=service,
253
+ image_tag=image_tag,
254
+ task_definition=task_definition,
255
+ )
256
+
257
+ self.io.info(f"Task definition successfully registered with ARN '{task_def_arn}'.\n")
258
+
259
+ service_response = self.ecs_provider.update_service(
260
+ service=service,
261
+ task_def_arn=task_def_arn,
262
+ environment=environment,
263
+ application=application,
264
+ )
265
+
266
+ self.io.info(f"Successfully updated ECS service '{service_response['serviceName']}'.\n")
267
+
268
+ primary_deployment_id = self._get_primary_deployment_id(service_response=service_response)
269
+ self.io.info(f"New ECS Deployment with ID '{primary_deployment_id}' has been triggered.\n")
270
+
271
+ expected_count = service_response.get("desiredCount", 1)
272
+ task_ids = self._fetch_ecs_task_ids(
273
+ application=application,
274
+ environment=environment,
275
+ deployment_id=primary_deployment_id,
276
+ expected_count=expected_count,
277
+ )
278
+
279
+ self.io.info(
280
+ f"Detected {len(task_ids)} new ECS task(s) with the following ID(s) {task_ids}.\n"
281
+ )
282
+
283
+ container_names = self.ecs_provider.get_container_names_from_ecs_tasks(
284
+ cluster_name=f"{application}-{environment}-cluster",
285
+ task_ids=task_ids,
286
+ )
287
+
288
+ log_streams = self._build_log_stream_names(
289
+ task_ids=task_ids, container_names=container_names, stream_prefix="platform"
290
+ )
291
+
292
+ log_group = f"/platform/ecs/service/{application}/{environment}/{service}"
293
+ self.logs_provider.check_log_streams_present(
294
+ log_group=log_group, expected_log_streams=log_streams
295
+ )
296
+
297
+ cloudwatch_url = self._build_cloudwatch_live_tail_url(
298
+ account_id=account_id, log_group=log_group, log_streams=log_streams
299
+ )
300
+ self.io.info(f"View real-time deployment logs in the AWS Console: \n{cloudwatch_url}\n")
301
+
302
+ self._monitor_ecs_deployment(
303
+ application=application,
304
+ environment=environment,
305
+ service=service,
306
+ )
307
+
308
+ @staticmethod
309
+ def _build_cloudwatch_live_tail_url(
310
+ account_id: str, log_group: str, log_streams: list[str]
311
+ ) -> str:
312
+ """Build CloudWatch live tail URL with log group and log streams pre-
313
+ populated in Rison format."""
314
+
315
+ log_group_rison = log_group.replace("/", "*2f")
316
+
317
+ delimiter = "~'"
318
+ log_streams_rison = ""
319
+ for stream in log_streams:
320
+ stream_rison = stream.replace("/", "*2f")
321
+ log_streams_rison = log_streams_rison + f"{delimiter}{stream_rison}"
322
+
323
+ base = "https://eu-west-2.console.aws.amazon.com/cloudwatch/home?region=eu-west-2#logsV2:live-tail"
324
+ log_group_fragment = f"$3FlogGroupArns$3D~(~'arn*3aaws*3alogs*3aeu-west-2*3a{account_id}*3alog-group*3a{log_group_rison}*3a*2a)"
325
+ log_streams_fragment = f"$26logStreamNames$3D~({log_streams_rison})"
326
+
327
+ return base + log_group_fragment + log_streams_fragment
328
+
329
+ @staticmethod
330
+ def _build_log_stream_names(
331
+ task_ids: list[str], container_names: list[str], stream_prefix: str
332
+ ) -> list[str]:
333
+ """Manually build names of the log stream that will get created."""
334
+
335
+ log_streams = []
336
+ for id in task_ids:
337
+ for name in container_names:
338
+ if not name.startswith(
339
+ "ecs-service-connect"
340
+ ): # ECS Service Connect container logs are noisy and not relevant in most cases
341
+ log_streams.append(f"{stream_prefix}/{name}/{id}")
342
+
343
+ return log_streams
344
+
345
+ @staticmethod
346
+ def _get_primary_deployment_id(service_response: dict[str, Any]):
347
+ for dep in service_response["deployments"]:
348
+ if dep["status"] == "PRIMARY":
349
+ return dep["id"]
350
+ raise PlatformException(
351
+ f"\nUnable to find primary ECS deployment for service '{service_response['serviceName']}'\n"
352
+ )
353
+
354
+ def _fetch_ecs_task_ids(
355
+ self, application: str, environment: str, deployment_id: str, expected_count: int
356
+ ) -> list[str]:
357
+ """Return ECS task ID(s) of tasks started by the PRIMARY ECS
358
+ deployment."""
359
+
360
+ timeout_seconds = DEPLOYMENT_TIMEOUT_SECONDS
361
+ deadline = time.monotonic() + timeout_seconds # 10 minute deadline before timing out
362
+
363
+ self.io.info(f"Waiting for the new ECS task(s) to spin up.\n")
364
+
365
+ while time.monotonic() < deadline:
366
+ task_arns = self.ecs_provider.get_ecs_task_arns(
367
+ cluster=f"{application}-{environment}-cluster",
368
+ started_by=deployment_id,
369
+ desired_status="RUNNING",
370
+ )
371
+
372
+ if len(task_arns) >= expected_count:
373
+ break
374
+
375
+ time.sleep(POLL_INTERVAL_SECONDS)
376
+
377
+ if len(task_arns) < expected_count:
378
+ raise PlatformException(
379
+ f"Timed out waiting for {expected_count} RUNNING ECS task(s) to spin up after {timeout_seconds}s. Got {len(task_arns)} instead."
380
+ )
381
+
382
+ task_ids = []
383
+ for arn in task_arns:
384
+ task_ids.append(arn.rsplit("/", 1)[-1])
385
+ return task_ids
386
+
387
+ def _monitor_ecs_deployment(self, application: str, environment: str, service: str) -> bool:
388
+ """Loop until ECS rollout state is SUCCESSFUL or a fail status or else
389
+ times out."""
390
+
391
+ cluster_name = f"{application}-{environment}-cluster"
392
+ ecs_service_name = f"{application}-{environment}-{service}"
393
+ start_time = time.time()
394
+ timeout_seconds = DEPLOYMENT_TIMEOUT_SECONDS
395
+ deadline = time.monotonic() + timeout_seconds # 10 minute deadline before timing out
396
+
397
+ while time.monotonic() < deadline:
398
+ try:
399
+ state, reason = self.ecs_provider.get_service_rollout_state(
400
+ cluster_name=cluster_name, service_name=ecs_service_name, start_time=start_time
153
401
  )
402
+ except Exception as e:
403
+ raise PlatformException(f"Failed to fetch ECS rollout state: {e}")
404
+
405
+ if state == "SUCCESSFUL":
406
+ self.io.info("\nECS deployment complete!")
407
+ return True
408
+ if state in ["STOPPED", "ROLLBACK_SUCCESSFUL", "ROLLBACK_FAILED"]:
409
+ raise PlatformException(f"\nECS deployment failed: {reason or 'unknown reason'}")
410
+
411
+ elapsed_time = int(time.time() - start_time)
412
+ self.io.info(f"Deployment in progress {elapsed_time}s")
413
+ time.sleep(POLL_INTERVAL_SECONDS)
414
+
415
+ raise PlatformException(
416
+ f"Timed out after {timeout_seconds}s waiting for '{ecs_service_name}' to stabilise."
417
+ )