dbt-platform-helper 13.1.0__py3-none-any.whl → 15.16.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 (95) hide show
  1. dbt_platform_helper/COMMANDS.md +107 -27
  2. dbt_platform_helper/commands/application.py +5 -6
  3. dbt_platform_helper/commands/codebase.py +31 -10
  4. dbt_platform_helper/commands/conduit.py +3 -5
  5. dbt_platform_helper/commands/config.py +20 -311
  6. dbt_platform_helper/commands/copilot.py +18 -391
  7. dbt_platform_helper/commands/database.py +17 -9
  8. dbt_platform_helper/commands/environment.py +20 -14
  9. dbt_platform_helper/commands/generate.py +0 -3
  10. dbt_platform_helper/commands/internal.py +140 -0
  11. dbt_platform_helper/commands/notify.py +58 -78
  12. dbt_platform_helper/commands/pipeline.py +23 -19
  13. dbt_platform_helper/commands/secrets.py +39 -93
  14. dbt_platform_helper/commands/version.py +7 -12
  15. dbt_platform_helper/constants.py +52 -7
  16. dbt_platform_helper/domain/codebase.py +89 -39
  17. dbt_platform_helper/domain/conduit.py +335 -76
  18. dbt_platform_helper/domain/config.py +381 -0
  19. dbt_platform_helper/domain/copilot.py +398 -0
  20. dbt_platform_helper/domain/copilot_environment.py +8 -8
  21. dbt_platform_helper/domain/database_copy.py +2 -2
  22. dbt_platform_helper/domain/maintenance_page.py +254 -430
  23. dbt_platform_helper/domain/notify.py +64 -0
  24. dbt_platform_helper/domain/pipelines.py +43 -35
  25. dbt_platform_helper/domain/plans.py +41 -0
  26. dbt_platform_helper/domain/secrets.py +279 -0
  27. dbt_platform_helper/domain/service.py +570 -0
  28. dbt_platform_helper/domain/terraform_environment.py +14 -13
  29. dbt_platform_helper/domain/update_alb_rules.py +412 -0
  30. dbt_platform_helper/domain/versioning.py +249 -0
  31. dbt_platform_helper/{providers → entities}/platform_config_schema.py +75 -82
  32. dbt_platform_helper/entities/semantic_version.py +83 -0
  33. dbt_platform_helper/entities/service.py +339 -0
  34. dbt_platform_helper/platform_exception.py +4 -0
  35. dbt_platform_helper/providers/autoscaling.py +24 -0
  36. dbt_platform_helper/providers/aws/__init__.py +0 -0
  37. dbt_platform_helper/providers/aws/exceptions.py +70 -0
  38. dbt_platform_helper/providers/aws/interfaces.py +13 -0
  39. dbt_platform_helper/providers/aws/opensearch.py +23 -0
  40. dbt_platform_helper/providers/aws/redis.py +21 -0
  41. dbt_platform_helper/providers/aws/sso_auth.py +75 -0
  42. dbt_platform_helper/providers/cache.py +40 -4
  43. dbt_platform_helper/providers/cloudformation.py +1 -1
  44. dbt_platform_helper/providers/config.py +137 -19
  45. dbt_platform_helper/providers/config_validator.py +112 -51
  46. dbt_platform_helper/providers/copilot.py +24 -16
  47. dbt_platform_helper/providers/ecr.py +89 -7
  48. dbt_platform_helper/providers/ecs.py +228 -36
  49. dbt_platform_helper/providers/environment_variable.py +24 -0
  50. dbt_platform_helper/providers/files.py +1 -1
  51. dbt_platform_helper/providers/io.py +36 -4
  52. dbt_platform_helper/providers/kms.py +22 -0
  53. dbt_platform_helper/providers/load_balancers.py +402 -42
  54. dbt_platform_helper/providers/logs.py +72 -0
  55. dbt_platform_helper/providers/parameter_store.py +134 -0
  56. dbt_platform_helper/providers/s3.py +21 -0
  57. dbt_platform_helper/providers/schema_migrations/__init__.py +0 -0
  58. dbt_platform_helper/providers/schema_migrations/schema_v0_to_v1_migration.py +43 -0
  59. dbt_platform_helper/providers/schema_migrator.py +77 -0
  60. dbt_platform_helper/providers/secrets.py +5 -5
  61. dbt_platform_helper/providers/slack_channel_notifier.py +62 -0
  62. dbt_platform_helper/providers/terraform_manifest.py +121 -19
  63. dbt_platform_helper/providers/version.py +106 -23
  64. dbt_platform_helper/providers/version_status.py +27 -0
  65. dbt_platform_helper/providers/vpc.py +36 -5
  66. dbt_platform_helper/providers/yaml_file.py +58 -2
  67. dbt_platform_helper/templates/environment-pipelines/main.tf +4 -3
  68. dbt_platform_helper/templates/svc/overrides/cfn.patches.yml +5 -0
  69. dbt_platform_helper/utilities/decorators.py +103 -0
  70. dbt_platform_helper/utils/application.py +119 -22
  71. dbt_platform_helper/utils/aws.py +39 -150
  72. dbt_platform_helper/utils/deep_merge.py +10 -0
  73. dbt_platform_helper/utils/git.py +1 -14
  74. dbt_platform_helper/utils/validation.py +1 -1
  75. {dbt_platform_helper-13.1.0.dist-info → dbt_platform_helper-15.16.0.dist-info}/METADATA +11 -20
  76. dbt_platform_helper-15.16.0.dist-info/RECORD +118 -0
  77. {dbt_platform_helper-13.1.0.dist-info → dbt_platform_helper-15.16.0.dist-info}/WHEEL +1 -1
  78. platform_helper.py +3 -1
  79. terraform/elasticache-redis/plans.yml +85 -0
  80. terraform/opensearch/plans.yml +71 -0
  81. terraform/postgres/plans.yml +128 -0
  82. dbt_platform_helper/addon-plans.yml +0 -224
  83. dbt_platform_helper/providers/aws.py +0 -37
  84. dbt_platform_helper/providers/opensearch.py +0 -36
  85. dbt_platform_helper/providers/redis.py +0 -34
  86. dbt_platform_helper/providers/semantic_version.py +0 -126
  87. dbt_platform_helper/templates/svc/manifest-backend.yml +0 -69
  88. dbt_platform_helper/templates/svc/manifest-public.yml +0 -109
  89. dbt_platform_helper/utils/cloudfoundry.py +0 -14
  90. dbt_platform_helper/utils/files.py +0 -53
  91. dbt_platform_helper/utils/manifests.py +0 -18
  92. dbt_platform_helper/utils/versioning.py +0 -238
  93. dbt_platform_helper-13.1.0.dist-info/RECORD +0 -96
  94. {dbt_platform_helper-13.1.0.dist-info → dbt_platform_helper-15.16.0.dist-info}/entry_points.txt +0 -0
  95. {dbt_platform_helper-13.1.0.dist-info → dbt_platform_helper-15.16.0.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,339 @@
1
+ import re
2
+ from enum import Enum
3
+ from typing import ClassVar
4
+ from typing import Dict
5
+ from typing import Optional
6
+ from typing import Union
7
+
8
+ from pydantic import BaseModel
9
+ from pydantic import Field
10
+ from pydantic import field_validator
11
+ from pydantic import model_validator
12
+
13
+ from dbt_platform_helper.platform_exception import PlatformException
14
+
15
+
16
+ class HealthCheck(BaseModel):
17
+ path: Optional[str] = Field(
18
+ description="The destination that the health check requests are sent to.", default="/"
19
+ )
20
+ port: Optional[int] = Field(
21
+ description="The port that the health check requests are sent to.", default=8080
22
+ )
23
+ success_codes: Optional[str] = Field(
24
+ description="A comma-separated list of HTTP status codes that healthy targets must use when responding to a HTTP health check.",
25
+ default="200",
26
+ )
27
+ healthy_threshold: Optional[int] = Field(
28
+ description="The number of consecutive health check successes required before considering an unhealthy target healthy.",
29
+ default=3,
30
+ )
31
+ unhealthy_threshold: Optional[int] = Field(
32
+ description="The number of consecutive health check failures required before considering a target unhealthy.",
33
+ default=3,
34
+ )
35
+ interval: Optional[int] = Field(
36
+ description="The approximate amount of time, in seconds, between health checks of an individual target.",
37
+ default=35,
38
+ )
39
+ timeout: Optional[int] = Field(
40
+ description="The amount of time, in seconds, during which no response from a target means a failed health check.",
41
+ default=30,
42
+ )
43
+ grace_period: Optional[int] = Field(
44
+ description="The amount of time to ignore failing target group healthchecks on container start.",
45
+ default=30,
46
+ )
47
+
48
+
49
+ class AdditionalRules(BaseModel):
50
+ path: str = Field(description="""Requests to this path will be forwarded to your service.""")
51
+ alias: list[str] = Field(description="""The HTTP domain alias of the service.""")
52
+
53
+
54
+ class Http(BaseModel):
55
+ alias: list[str] = Field(
56
+ description="List of HTTPS domain alias(es) of your service.", default=None
57
+ )
58
+ stickiness: Optional[bool] = Field(description="Enable sticky sessions.", default=False)
59
+ path: str = Field(description="Requests to this path will be forwarded to your service.")
60
+ target_container: str = Field(description="Target container for the requests.")
61
+ healthcheck: HealthCheck = Field(default_factory=HealthCheck)
62
+ additional_rules: Optional[list[AdditionalRules]] = Field(default=None)
63
+ deregistration_delay: Optional[int] = Field(
64
+ default=60,
65
+ description="The amount of time to wait for targets to drain connections during deregistration.",
66
+ )
67
+
68
+
69
+ class HttpOverride(BaseModel):
70
+ alias: Optional[list[str]] = Field(
71
+ description="List of HTTPS domain alias(es) of your service.", default=None
72
+ )
73
+ stickiness: Optional[bool] = Field(description="Enable sticky sessions.", default=None)
74
+ path: Optional[str] = Field(
75
+ description="Requests to this path will be forwarded to your service.", default=None
76
+ )
77
+ target_container: Optional[str] = Field(
78
+ description="Target container for the requests", default=None
79
+ )
80
+ healthcheck: Optional[HealthCheck] = Field(default=None)
81
+ additional_rules: Optional[list[AdditionalRules]] = Field(default=None)
82
+ deregistration_delay: Optional[int] = Field(
83
+ default=None,
84
+ description="The amount of time to wait for targets to drain connections during deregistration.",
85
+ )
86
+
87
+
88
+ class ContainerHealthCheck(BaseModel):
89
+ command: list[str] = Field(
90
+ description="The command to run to determine if the container is healthy."
91
+ )
92
+ interval: Optional[int] = Field(
93
+ default=10, description="Time period between health checks, in seconds."
94
+ )
95
+ retries: Optional[int] = Field(
96
+ default=2, description="Number of times to retry before container is deemed unhealthy."
97
+ )
98
+ timeout: Optional[int] = Field(
99
+ default=5,
100
+ description="How long to wait before considering the health check failed, in seconds.",
101
+ )
102
+ start_period: Optional[int] = Field(
103
+ default=0,
104
+ description="Length of grace period for containers to bootstrap before failed health checks count towards the maximum number of retries.",
105
+ )
106
+
107
+
108
+ class Sidecar(BaseModel):
109
+ port: int = Field(description="Container port exposed by the sidecar to receive traffic.")
110
+ image: str = Field(description="Container image URI for the sidecar (e.g. 'repo/image:tag').")
111
+ essential: Optional[bool] = Field(
112
+ description="Whether the ECS task should stop if this sidecar container exits.",
113
+ default=True,
114
+ )
115
+ variables: Optional[Dict[str, Union[str, int, bool]]] = Field(
116
+ description="Environment variables to inject into the sidecar container.", default=None
117
+ )
118
+ secrets: Optional[Dict[str, str]] = Field(
119
+ description="Parameter Store secrets to inject into the sidecar.", default=None
120
+ )
121
+ healthcheck: Optional[ContainerHealthCheck] = Field(default=None)
122
+
123
+
124
+ class SidecarOverride(BaseModel):
125
+ port: Optional[int] = Field(default=None)
126
+ image: Optional[str] = Field(default=None)
127
+ essential: Optional[bool] = Field(default=None)
128
+ variables: Optional[Dict[str, Union[str, int, bool]]] = Field(default=None)
129
+ secrets: Optional[Dict[str, str]] = Field(default=None)
130
+ healthcheck: Optional[ContainerHealthCheck] = Field(default=None)
131
+
132
+
133
+ class Image(BaseModel):
134
+ location: str = Field(description="Main container image location.")
135
+ port: Optional[int] = Field(
136
+ description="Port exposed by the main ECS task container (used by the load balancer/Service Connect).",
137
+ default=None,
138
+ )
139
+ depends_on: Optional[dict[str, str]] = Field(
140
+ description="Container dependency conditions.", default=None
141
+ )
142
+ healthcheck: Optional[ContainerHealthCheck] = Field(default=None)
143
+
144
+ @field_validator("location", mode="after")
145
+ @classmethod
146
+ def is_image_untagged(cls, value: str) -> str:
147
+ image_name = value.split("/")[-1]
148
+ if ":" in image_name:
149
+ raise PlatformException(
150
+ f"Image location cannot contain a tag '{value}'\nPlease remove the tag from your image location. The image tag is automatically added during deployment."
151
+ )
152
+ return value
153
+
154
+
155
+ class Storage(BaseModel):
156
+ readonly_fs: Optional[bool] = Field(
157
+ description="Specify true to give your container read-only access to its root file system.",
158
+ default=False,
159
+ )
160
+ writable_directories: Optional[list[str]] = Field(
161
+ description="List of directories with read/write access.", default=None
162
+ )
163
+
164
+ @field_validator("writable_directories", mode="after")
165
+ @classmethod
166
+ def has_leading_forward_slash(cls, value: Union[list, None]) -> Union[list, None]:
167
+ if value is not None:
168
+ for path in value:
169
+ if not path.startswith("/"):
170
+ raise PlatformException(
171
+ "All writable directory paths must be absolute (starts with a /)"
172
+ )
173
+ return value
174
+
175
+
176
+ class Cooldown(BaseModel):
177
+ in_: Optional[int] = Field(
178
+ alias="in",
179
+ description="Number of seconds to wait before scaling in (down) after a drop in load.",
180
+ default=60,
181
+ ) # Can't use 'in' because it's a reserved keyword
182
+ out: Optional[int] = Field(
183
+ description="Number of seconds to wait before scaling out (up) after a spike in load.",
184
+ default=60,
185
+ )
186
+
187
+ @field_validator("in_", "out", mode="before")
188
+ @classmethod
189
+ def parse_seconds(cls, value):
190
+ if isinstance(value, str) and value.endswith("s"):
191
+ value = value.removesuffix("s") # remove the trailing 's'
192
+ try:
193
+ return int(value)
194
+ except (ValueError, TypeError):
195
+ raise PlatformException("Cooldown values must be integers or strings like '30s'")
196
+
197
+
198
+ class CpuPercentage(BaseModel):
199
+ value: int = Field(description="Target CPU utilisation percentage that triggers autoscaling.")
200
+ cooldown: Optional[Cooldown] = Field(
201
+ default=None, description="Optional CPU cooldown that overrides the global cooldown policy."
202
+ )
203
+
204
+
205
+ class MemoryPercentage(BaseModel):
206
+ value: int = Field(description="Target CPU utilisation percentage that triggers autoscaling.")
207
+ cooldown: Optional[Cooldown] = Field(
208
+ default=None,
209
+ description="Optional memory cooldown that overrides the global cooldown policy.",
210
+ )
211
+
212
+
213
+ class RequestsPerMinute(BaseModel):
214
+ value: int = Field(
215
+ description="Number of incoming requests per minute that triggers autoscaling."
216
+ )
217
+ cooldown: Optional[Cooldown] = Field(
218
+ default=None,
219
+ description="Optional requests cooldown that overrides the global cooldown policy.",
220
+ )
221
+
222
+
223
+ class Count(BaseModel):
224
+ range: str = Field(
225
+ description="Minimum and maximum number of ECS tasks to maintain e.g. '1-2'."
226
+ )
227
+ cooldown: Optional[Cooldown] = Field(
228
+ default=None,
229
+ description="Global cooldown applied to all autoscaling metrics unless overridden per metric.",
230
+ )
231
+ cpu_percentage: Optional[Union[int, CpuPercentage]] = Field(
232
+ default=None,
233
+ description="CPU utilisation threshold (0–100). Either a plain integer or a map with 'value' and 'cooldown'.",
234
+ )
235
+ memory_percentage: Optional[Union[int, MemoryPercentage]] = Field(
236
+ default=None,
237
+ description="Memory utilisation threshold (0–100). Either a plain integer or a map with 'value' and 'cooldown'.",
238
+ )
239
+ requests_per_minute: Optional[Union[int, RequestsPerMinute]] = Field(
240
+ default=None,
241
+ description="Request-rate threshold. Either a plain integer or a map with 'value' and 'cooldown'.",
242
+ )
243
+
244
+ @model_validator(mode="after")
245
+ def at_least_one_autoscaling_metric(self):
246
+
247
+ if not any([self.cpu_percentage, self.memory_percentage, self.requests_per_minute]):
248
+ raise PlatformException(
249
+ "If autoscaling is enabled, you must define at least one metric: "
250
+ "cpu_percentage, memory_percentage, or requests_per_minute"
251
+ )
252
+
253
+ if not re.match(r"^(\d+)-(\d+)$", self.range):
254
+ raise PlatformException("Range must be in the format 'int-int' e.g. '1-2'")
255
+
256
+ range_split = self.range.split("-")
257
+ if range_split[0] >= range_split[1]:
258
+ raise PlatformException("Range minimum value must be less than the maximum value.")
259
+
260
+ return self
261
+
262
+
263
+ class ServiceConfigEnvironmentOverride(BaseModel):
264
+ http: Optional[HttpOverride] = Field(default=None)
265
+ sidecars: Optional[Dict[str, SidecarOverride]] = Field(default=None)
266
+ image: Optional[Image] = Field(default=None)
267
+
268
+ cpu: Optional[int] = Field(default=None)
269
+ memory: Optional[int] = Field(default=None)
270
+ count: Optional[Union[int, Count]] = Field(default=None)
271
+ exec: Optional[bool] = Field(default=None)
272
+ entrypoint: Optional[list[str]] = Field(default=None)
273
+ essential: Optional[bool] = Field(default=None)
274
+
275
+ storage: Optional[Storage] = Field(default=None)
276
+
277
+ variables: Optional[Dict[str, Union[str, int, bool]]] = Field(default=None)
278
+ secrets: Optional[Dict[str, str]] = Field(default=None)
279
+
280
+
281
+ class ServiceType(str, Enum):
282
+ BACKEND_SERVICE = "Backend Service"
283
+ LOAD_BALANCED_WEB_SERVICE = "Load Balanced Web Service"
284
+
285
+
286
+ class ServiceConfig(BaseModel):
287
+ name: str = Field(description="Service name.")
288
+ type: ServiceType = Field(
289
+ description=f"Type of service. Must one one of: '{ServiceType.LOAD_BALANCED_WEB_SERVICE.value}', '{ServiceType.BACKEND_SERVICE.value}'"
290
+ )
291
+ http: Optional[Http] = Field(default=None)
292
+
293
+ @model_validator(mode="after")
294
+ def check_http_for_web_service(self):
295
+ if self.type == ServiceType.LOAD_BALANCED_WEB_SERVICE and self.http is None:
296
+ raise PlatformException(
297
+ f"A 'http' block must be provided when service type == {self.type.value}"
298
+ )
299
+ return self
300
+
301
+ sidecars: Optional[Dict[str, Sidecar]] = Field(default=None)
302
+ image: Image = Field()
303
+ cpu: int = Field(
304
+ description="vCPU units reserved for the ECS task (e.g. 256=0.25 vCPU, 512=0.5 vCPU, 1024=1 vCPU)."
305
+ )
306
+ memory: int = Field(
307
+ description="Memory in MiB reserved for the ECS task (e.g. 256, 512, 1024)."
308
+ )
309
+ count: Union[int, Count] = Field(
310
+ description="Desired task count — either a fixed integer or an autoscaling policy map with 'range', 'cooldown', and at least one of 'cpu_percentage', 'memory_percentage', or 'requests_per_minute' metrics."
311
+ )
312
+ exec: Optional[bool] = Field(
313
+ description="Enable ECS Exec (remote command execution) for running ECS tasks.",
314
+ default=False,
315
+ )
316
+ entrypoint: Optional[list[str]] = Field(
317
+ description="Overrides the default entrypoint in the image.", default=None
318
+ )
319
+ essential: Optional[bool] = Field(
320
+ description="Whether the main container is marked essential; The entire ECS task stops if it exits.",
321
+ default=True,
322
+ )
323
+ storage: Storage = Field(default_factory=Storage)
324
+ variables: Optional[Dict[str, Union[str, int, bool]]] = Field(
325
+ description="Environment variables to inject into the main application container.",
326
+ default=None,
327
+ )
328
+ secrets: Optional[Dict[str, str]] = Field(
329
+ description="Parameter Store secrets to inject into the main application container.",
330
+ default=None,
331
+ )
332
+ # Environment overrides can override almost the full config
333
+ environments: Optional[Dict[str, ServiceConfigEnvironmentOverride]] = Field(
334
+ description="Allows you to override most service config properties for specific environments.",
335
+ default=None,
336
+ )
337
+
338
+ # Class based variable used when handling the object
339
+ local_terraform_source: ClassVar[str] = "../../../../../platform-tools/terraform/ecs-service"
@@ -3,3 +3,7 @@
3
3
  # error and abort.
4
4
  class PlatformException(Exception):
5
5
  pass
6
+
7
+
8
+ class ValidationException(PlatformException):
9
+ pass
@@ -0,0 +1,24 @@
1
+ from typing import Any
2
+
3
+ import boto3
4
+ from botocore.exceptions import ClientError
5
+
6
+ from dbt_platform_helper.platform_exception import PlatformException
7
+
8
+
9
+ class AutoscalingProvider:
10
+ def __init__(self, client: boto3.client):
11
+ self.autoscaling_client = client
12
+
13
+ def describe_autoscaling_target(
14
+ self, cluster_name: str, ecs_service_name: str
15
+ ) -> dict[str, Any]:
16
+ """Return autoscaling target information for an ECS service."""
17
+
18
+ try:
19
+ response = self.autoscaling_client.describe_scalable_targets(
20
+ ServiceNamespace="ecs", ResourceIds=[f"service/{cluster_name}/{ecs_service_name}"]
21
+ )
22
+ return response["ScalableTargets"][0]
23
+ except ClientError as err:
24
+ raise PlatformException(f"Error retrieving scalable targets: {err}")
File without changes
@@ -0,0 +1,70 @@
1
+ from dbt_platform_helper.platform_exception import PlatformException
2
+
3
+
4
+ class AWSException(PlatformException):
5
+ pass
6
+
7
+
8
+ class CreateTaskTimeoutException(AWSException):
9
+ def __init__(self, addon_name: str, application_name: str, environment: str):
10
+ super().__init__(
11
+ f"""Client ({addon_name}) ECS task has failed to start for "{application_name}" in "{environment}" environment."""
12
+ )
13
+
14
+
15
+ IMAGE_NOT_FOUND_TEMPLATE = """An image labelled "{image_ref}" could not be found in your image repository. Try the `platform-helper codebase build` command first."""
16
+
17
+
18
+ class ImageNotFoundException(AWSException):
19
+ def __init__(self, image_ref: str):
20
+ super().__init__(IMAGE_NOT_FOUND_TEMPLATE.format(image_ref=image_ref))
21
+
22
+
23
+ MULTIPLE_IMAGES_FOUND_TEMPLATE = (
24
+ 'Image reference "{image_ref}" is matched by the following images: {matching_images}'
25
+ )
26
+
27
+
28
+ class MultipleImagesFoundException(AWSException):
29
+ def __init__(self, image_ref: str, matching_images: list[str]):
30
+ super().__init__(
31
+ MULTIPLE_IMAGES_FOUND_TEMPLATE.format(
32
+ image_ref=image_ref, matching_images=", ".join(sorted(matching_images))
33
+ )
34
+ )
35
+
36
+
37
+ REPOSITORY_NOT_FOUND_TEMPLATE = """The ECR repository "{repository}" could not be found."""
38
+
39
+
40
+ class RepositoryNotFoundException(AWSException):
41
+ def __init__(self, repository: str):
42
+ super().__init__(REPOSITORY_NOT_FOUND_TEMPLATE.format(repository=repository))
43
+
44
+
45
+ class LogGroupNotFoundException(AWSException):
46
+ def __init__(self, log_group_name: str):
47
+ super().__init__(f"""No log group called "{log_group_name}".""")
48
+
49
+
50
+ # TODO: DBTP-1976: This should probably be in the AWS Copilot provider, but was causing circular import when we tried it pre refactoring the utils/aws.py
51
+ class CopilotCodebaseNotFoundException(PlatformException):
52
+ def __init__(self, codebase: str):
53
+ super().__init__(
54
+ f"""The codebase "{codebase}" either does not exist or has not been deployed."""
55
+ )
56
+
57
+
58
+ class CreateAccessTokenException(AWSException):
59
+ def __init__(self, client_id: str):
60
+ super().__init__(f"""Failed to create access token for Client "{client_id}".""")
61
+
62
+
63
+ class UnableToRetrieveSSOAccountList(AWSException):
64
+ def __init__(self):
65
+ super().__init__("Unable to retrieve AWS SSO account list")
66
+
67
+
68
+ class UnableToRetrieveSSOAccountRolesList(AWSException):
69
+ def __init__(self, account_id: str):
70
+ super().__init__(f"Unable to retrieve AWS SSO roles list for AWS account {account_id}")
@@ -0,0 +1,13 @@
1
+ from typing import Protocol
2
+
3
+
4
+ class GetVersionsProtocol(Protocol):
5
+ def get_supported_versions(self) -> list[str]: ...
6
+
7
+
8
+ class GetReferenceProtocol(Protocol):
9
+ def get_reference(self) -> str: ...
10
+
11
+
12
+ class AwsGetVersionProtocol(GetReferenceProtocol, GetVersionsProtocol):
13
+ pass
@@ -0,0 +1,23 @@
1
+ import boto3
2
+
3
+
4
+ class Opensearch:
5
+
6
+ def __init__(self, client: boto3.client):
7
+ self.client = client
8
+ self.engine = "OpenSearch"
9
+
10
+ def get_reference(self) -> str:
11
+ return self.engine.lower()
12
+
13
+ def get_supported_versions(self) -> list[str]:
14
+ response = self.client.list_versions()
15
+ all_versions = response["Versions"]
16
+
17
+ supported_versions = [
18
+ version.removeprefix(f"{self.engine}_")
19
+ for version in all_versions
20
+ if version.startswith(f"{self.engine}_")
21
+ ]
22
+
23
+ return supported_versions
@@ -0,0 +1,21 @@
1
+ import boto3
2
+
3
+
4
+ class Redis:
5
+
6
+ def __init__(self, client: boto3.client):
7
+ self.client = client
8
+ self.engine = "redis"
9
+
10
+ def get_reference(self) -> str:
11
+ return self.engine.lower()
12
+
13
+ def get_supported_versions(self) -> list[str]:
14
+ supported_versions_response = self.client.describe_cache_engine_versions(Engine=self.engine)
15
+
16
+ supported_versions = [
17
+ version["EngineVersion"]
18
+ for version in supported_versions_response["CacheEngineVersions"]
19
+ ]
20
+
21
+ return supported_versions
@@ -0,0 +1,75 @@
1
+ import botocore
2
+ from boto3 import Session
3
+
4
+ from dbt_platform_helper.providers.aws.exceptions import CreateAccessTokenException
5
+ from dbt_platform_helper.providers.aws.exceptions import UnableToRetrieveSSOAccountList
6
+ from dbt_platform_helper.providers.aws.exceptions import (
7
+ UnableToRetrieveSSOAccountRolesList,
8
+ )
9
+ from dbt_platform_helper.utils.aws import get_aws_session_or_abort
10
+
11
+
12
+ class SSOAuthProvider:
13
+ def __init__(self, session: Session = None):
14
+ self.session = session
15
+ self.sso_oidc = self._get_client("sso-oidc")
16
+ self.sso = self._get_client("sso")
17
+
18
+ def register(self, client_name, client_type):
19
+ client = self.sso_oidc.register_client(clientName=client_name, clientType=client_type)
20
+ client_id = client.get("clientId")
21
+ client_secret = client.get("clientSecret")
22
+
23
+ return client_id, client_secret
24
+
25
+ def start_device_authorization(self, client_id, client_secret, start_url):
26
+ authz = self.sso_oidc.start_device_authorization(
27
+ clientId=client_id,
28
+ clientSecret=client_secret,
29
+ startUrl=start_url,
30
+ )
31
+ url = authz.get("verificationUriComplete")
32
+ deviceCode = authz.get("deviceCode")
33
+
34
+ return url, deviceCode
35
+
36
+ def create_access_token(self, client_id, client_secret, device_code):
37
+ try:
38
+ response = self.sso_oidc.create_token(
39
+ clientId=client_id,
40
+ clientSecret=client_secret,
41
+ grantType="urn:ietf:params:oauth:grant-type:device_code",
42
+ deviceCode=device_code,
43
+ )
44
+
45
+ return response.get("accessToken")
46
+
47
+ except botocore.exceptions.ClientError as e:
48
+ if e.response["Error"]["Code"] != "AuthorizationPendingException":
49
+ raise CreateAccessTokenException(client_id)
50
+
51
+ def list_accounts(self, access_token, max_results=100):
52
+ aws_accounts_response = self.sso.list_accounts(
53
+ accessToken=access_token,
54
+ maxResults=max_results,
55
+ )
56
+
57
+ if len(aws_accounts_response.get("accountList", [])) == 0:
58
+ raise UnableToRetrieveSSOAccountList()
59
+ return aws_accounts_response.get("accountList")
60
+
61
+ def list_account_roles(self, access_token, account_id, max_results=100):
62
+ aws_account_roles_response = self.sso.list_account_roles(
63
+ accessToken=access_token,
64
+ accountId=account_id,
65
+ maxResults=max_results,
66
+ )
67
+
68
+ if len(aws_account_roles_response.get("roleList", [])) == 0:
69
+ raise UnableToRetrieveSSOAccountRolesList(account_id=account_id)
70
+ return aws_account_roles_response.get("roleList")
71
+
72
+ def _get_client(self, client: str):
73
+ if not self.session:
74
+ self.session = get_aws_session_or_abort()
75
+ return self.session.client(client)
@@ -1,10 +1,34 @@
1
1
  import os
2
+ from abc import ABC
3
+ from abc import abstractmethod
2
4
  from datetime import datetime
3
5
 
6
+ from dbt_platform_helper.providers.aws.interfaces import AwsGetVersionProtocol
4
7
  from dbt_platform_helper.providers.yaml_file import YamlFileProvider
5
8
 
6
9
 
7
- class CacheProvider:
10
+ class GetDataStrategy(ABC):
11
+ @abstractmethod
12
+ def retrieve_fresh_data(self):
13
+ pass
14
+
15
+ @abstractmethod
16
+ def get_data_identifier(self):
17
+ pass
18
+
19
+
20
+ class GetAWSVersionStrategy(GetDataStrategy):
21
+ def __init__(self, client_provider: AwsGetVersionProtocol):
22
+ self.client_provider = client_provider
23
+
24
+ def retrieve_fresh_data(self):
25
+ return self.client_provider.get_supported_versions()
26
+
27
+ def get_data_identifier(self):
28
+ return self.client_provider.get_reference()
29
+
30
+
31
+ class Cache:
8
32
  def __init__(
9
33
  self,
10
34
  file_provider: YamlFileProvider = None,
@@ -12,13 +36,25 @@ class CacheProvider:
12
36
  self._cache_file = ".platform-helper-config-cache.yml"
13
37
  self.file_provider = file_provider or YamlFileProvider
14
38
 
15
- def read_supported_versions_from_cache(self, resource_name):
39
+ def get_data(self, strategy: GetDataStrategy):
40
+ """Main method to retrieve caching data using the client-specific
41
+ strategy."""
42
+ cache_key = strategy.get_data_identifier()
43
+ if self._cache_refresh_required(cache_key):
44
+ data = strategy.retrieve_fresh_data()
45
+ self._update_cache(cache_key, data)
46
+ else:
47
+ data = self._read_from_cache(cache_key)
48
+
49
+ return data
50
+
51
+ def _read_from_cache(self, resource_name):
16
52
 
17
53
  platform_helper_config = self.file_provider.load(self._cache_file)
18
54
 
19
55
  return platform_helper_config.get(resource_name).get("versions")
20
56
 
21
- def update_cache(self, resource_name, supported_versions):
57
+ def _update_cache(self, resource_name, supported_versions):
22
58
 
23
59
  platform_helper_config = {}
24
60
 
@@ -40,7 +76,7 @@ class CacheProvider:
40
76
  "# [!] This file is autogenerated via the platform-helper. Do not edit.\n",
41
77
  )
42
78
 
43
- def cache_refresh_required(self, resource_name) -> bool:
79
+ def _cache_refresh_required(self, resource_name) -> bool:
44
80
  """
45
81
  Checks if the platform-helper should reach out to AWS to 'refresh' its
46
82
  cached values.
@@ -8,7 +8,7 @@ from dbt_platform_helper.platform_exception import PlatformException
8
8
 
9
9
 
10
10
  class CloudFormation:
11
- # TODO add handling for optional client parameters to handle case of calling boto API with None
11
+ # TODO: DBTP-1966: add handling for optional client parameters to handle case of calling boto API with None
12
12
  def __init__(self, cloudformation_client, iam_client=None, ssm_client=None):
13
13
  self.cloudformation_client = cloudformation_client
14
14
  self.iam_client = iam_client