pulse-aws 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.
pulse_aws/.DS_Store ADDED
Binary file
pulse_aws/__init__.py ADDED
@@ -0,0 +1,104 @@
1
+ # ########################
2
+ # ##### NOTES ON IMPORT FORMAT
3
+ # ########################
4
+ #
5
+ # This file defines Pulse's public API. Imports need to be structured/formatted so as to to ensure
6
+ # that the broadest possible set of static analyzers understand Pulse's public API as intended.
7
+ # The below guidelines ensure this is the case.
8
+ #
9
+ # (1) All imports in this module intended to define exported symbols should be of the form `from
10
+ # pulse.foo import X as X`. This is because imported symbols are not by default considered public
11
+ # by static analyzers. The redundant alias form `import X as X` overwrites the private imported `X`
12
+ # with a public `X` bound to the same value. It is also possible to expose `X` as public by listing
13
+ # it inside `__all__`, but the redundant alias form is preferred here due to easier maintainability.
14
+
15
+ # (2) All imports should target the module in which a symbol is actually defined, rather than a
16
+ # container module where it is imported.
17
+
18
+ from .baseline import (
19
+ BaselineStackError as BaselineStackError,
20
+ )
21
+ from .baseline import (
22
+ BaselineStackOutputs as BaselineStackOutputs,
23
+ )
24
+ from .baseline import (
25
+ check_domain_dns as check_domain_dns,
26
+ )
27
+ from .baseline import (
28
+ ensure_baseline_stack as ensure_baseline_stack,
29
+ )
30
+ from .certificate import (
31
+ AcmCertificate as AcmCertificate,
32
+ )
33
+ from .certificate import (
34
+ CertificateError as CertificateError,
35
+ )
36
+ from .certificate import (
37
+ DnsConfiguration as DnsConfiguration,
38
+ )
39
+ from .certificate import (
40
+ DnsRecord as DnsRecord,
41
+ )
42
+ from .certificate import (
43
+ domain_uses_cloudflare_proxy as domain_uses_cloudflare_proxy,
44
+ )
45
+ from .certificate import (
46
+ ensure_acm_certificate as ensure_acm_certificate,
47
+ )
48
+ from .config import (
49
+ DockerBuild as DockerBuild,
50
+ )
51
+ from .config import (
52
+ HealthCheckConfig as HealthCheckConfig,
53
+ )
54
+ from .config import (
55
+ ReaperConfig as ReaperConfig,
56
+ )
57
+ from .config import (
58
+ TaskConfig as TaskConfig,
59
+ )
60
+ from .deployment import (
61
+ DeploymentError as DeploymentError,
62
+ )
63
+ from .deployment import (
64
+ build_and_push_image as build_and_push_image,
65
+ )
66
+ from .deployment import (
67
+ create_service_and_target_group as create_service_and_target_group,
68
+ )
69
+ from .deployment import (
70
+ deploy as deploy,
71
+ )
72
+ from .deployment import (
73
+ generate_deployment_id as generate_deployment_id,
74
+ )
75
+ from .deployment import (
76
+ install_listener_rules_and_switch_traffic as install_listener_rules_and_switch_traffic,
77
+ )
78
+ from .deployment import (
79
+ register_task_definition as register_task_definition,
80
+ )
81
+ from .deployment import (
82
+ wait_for_healthy_targets as wait_for_healthy_targets,
83
+ )
84
+ from .plugin import (
85
+ AWSECSPlugin as AWSECSPlugin,
86
+ )
87
+ from .reporting import (
88
+ CiReporter as CiReporter,
89
+ )
90
+ from .reporting import (
91
+ CliReporter as CliReporter,
92
+ )
93
+ from .reporting import (
94
+ DeploymentContext as DeploymentContext,
95
+ )
96
+ from .reporting import (
97
+ Reporter as Reporter,
98
+ )
99
+ from .reporting import (
100
+ create_context as create_context,
101
+ )
102
+ from .teardown import (
103
+ teardown_baseline_stack as teardown_baseline_stack,
104
+ )
pulse_aws/baseline.py ADDED
@@ -0,0 +1,380 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import subprocess
5
+ from collections.abc import Mapping, Sequence
6
+ from dataclasses import asdict, dataclass
7
+ from pathlib import Path
8
+ from typing import Any, cast
9
+
10
+ import boto3
11
+ from botocore.exceptions import ClientError
12
+
13
+ from pulse_aws.certificate import (
14
+ AcmCertificate,
15
+ DnsConfiguration,
16
+ DnsRecord,
17
+ check_domain_dns,
18
+ ensure_acm_certificate,
19
+ )
20
+ from pulse_aws.config import ReaperConfig
21
+
22
+ STACK_NAME_TEMPLATE = "{env}-baseline"
23
+ TOOLKIT_STACK_NAME = "CDKToolkit"
24
+ BASELINE_STACK_VERSION = "1.3.1" # Bump when baseline stack changes
25
+ PACKAGE_ROOT = Path(__file__).resolve().parents[2]
26
+ DEFAULT_CDK_APP_DIR = PACKAGE_ROOT / "src" / "pulse_aws" / "cdk"
27
+ STACK_SUCCEEDED = {
28
+ "CREATE_COMPLETE",
29
+ "UPDATE_COMPLETE",
30
+ "UPDATE_ROLLBACK_COMPLETE",
31
+ }
32
+ STACK_FAILED = {
33
+ "CREATE_FAILED",
34
+ "ROLLBACK_FAILED",
35
+ "ROLLBACK_COMPLETE",
36
+ "DELETE_FAILED",
37
+ "UPDATE_ROLLBACK_FAILED",
38
+ }
39
+ STACK_DELETING = {
40
+ "DELETE_IN_PROGRESS",
41
+ }
42
+ STACK_DELETE_COMPLETE = "DELETE_COMPLETE"
43
+
44
+
45
+ class BaselineStackError(RuntimeError):
46
+ """Raised when provisioning or describing the baseline stack fails."""
47
+
48
+
49
+ @dataclass(slots=True)
50
+ class BaselineStackOutputs:
51
+ deployment_name: str
52
+ region: str
53
+ account: str
54
+ stack_name: str
55
+ listener_arn: str
56
+ alb_dns_name: str
57
+ alb_hosted_zone_id: str
58
+ private_subnet_ids: list[str]
59
+ public_subnet_ids: list[str]
60
+ alb_security_group_id: str
61
+ service_security_group_id: str
62
+ cluster_name: str
63
+ log_group_name: str
64
+ ecr_repository_uri: str
65
+ vpc_id: str
66
+ execution_role_arn: str
67
+ task_role_arn: str
68
+
69
+ def to_dict(self) -> dict[str, Any]:
70
+ return asdict(self)
71
+
72
+
73
+ async def ensure_baseline_stack(
74
+ deployment_name: str,
75
+ *,
76
+ certificate_arn: str,
77
+ allowed_ingress_cidrs: Sequence[str] | None = None,
78
+ reaper_config: ReaperConfig | None = None,
79
+ cdk_bin: str = "cdk",
80
+ workdir: Path | str | None = None,
81
+ poll_interval: float = 5.0,
82
+ force_bootstrap: bool = False,
83
+ ) -> BaselineStackOutputs:
84
+ """Ensure the shared AWS resources exist and return their identifiers.
85
+
86
+ IMPORTANT: This function requires an ISSUED ACM certificate.
87
+ The certificate must be created and validated BEFORE running this function.
88
+ AWS will reject CloudFormation deployments that try to attach a PENDING_VALIDATION
89
+ certificate to an ALB listener. Use ensure_acm_certificate() first and wait for
90
+ validation to complete.
91
+
92
+ Requires a certificate ARN. Use ensure_acm_certificate() to mint one:
93
+
94
+ Args:
95
+ deployment_name: Name for this deployment (e.g., "prod", "staging")
96
+ certificate_arn: ARN of ACM certificate for HTTPS
97
+ allowed_ingress_cidrs: Optional list of CIDR blocks for ALB access
98
+ reaper_config: Optional reaper configuration (ReaperConfig instance)
99
+ cdk_bin: Path to CDK binary (default: "cdk")
100
+ workdir: Working directory for CDK commands
101
+ poll_interval: How often to check stack status (seconds)
102
+ force_bootstrap: Force re-running CDK bootstrap even if already bootstrapped
103
+ skip_bootstrap_check: Skip bootstrap check entirely (faster but risky if not bootstrapped)
104
+
105
+ Example::
106
+
107
+ cert = await ensure_acm_certificate(["api.example.com"])
108
+ if cert.dns_configuration:
109
+ print(cert.dns_configuration.format_for_display())
110
+
111
+ outputs = await ensure_baseline_stack(
112
+ "prod",
113
+ certificate_arn=cert.arn,
114
+ )
115
+ """
116
+
117
+ if not deployment_name:
118
+ msg = "deployment_name is required"
119
+ raise ValueError(msg)
120
+
121
+ if not certificate_arn:
122
+ msg = "certificate_arn is required"
123
+ raise ValueError(msg)
124
+
125
+ stack_name = STACK_NAME_TEMPLATE.format(env=deployment_name)
126
+
127
+ sts = boto3.client("sts")
128
+ region = cast(str, sts.meta.region_name)
129
+ account = cast(str, sts.get_caller_identity()["Account"])
130
+
131
+ cfn = boto3.client("cloudformation", region_name=region)
132
+ stack = describe_stack(cfn, stack_name)
133
+
134
+ # Check if stack exists and is up-to-date
135
+ if stack and is_stack_healthy(stack):
136
+ current_version = get_stack_version(stack)
137
+ if current_version == BASELINE_STACK_VERSION:
138
+ # Stack exists and is current version, return outputs
139
+ return extract_stack_outputs(
140
+ deployment_name,
141
+ region,
142
+ account,
143
+ stack_name,
144
+ stack,
145
+ )
146
+ else:
147
+ # Stack exists but is outdated, will update below
148
+ print(
149
+ f"📦 Updating stack ({current_version} -> {BASELINE_STACK_VERSION})",
150
+ )
151
+
152
+ _ensure_bootstrap(cfn, cdk_bin, account, region, workdir, force=force_bootstrap)
153
+
154
+ context = {
155
+ "deployment_name": deployment_name,
156
+ "certificate_arn": certificate_arn,
157
+ }
158
+ if allowed_ingress_cidrs:
159
+ context["allowed_ingress_cidrs"] = ",".join(allowed_ingress_cidrs)
160
+
161
+ # Add reaper config if provided
162
+ if reaper_config:
163
+ context["reaper_schedule_minutes"] = str(reaper_config.schedule_minutes)
164
+ context["reaper_consecutive_periods"] = str(reaper_config.consecutive_periods)
165
+ context["reaper_period_seconds"] = str(reaper_config.period_seconds)
166
+ context["reaper_min_age_seconds"] = str(reaper_config.min_age_seconds)
167
+ context["reaper_max_age_hours"] = str(reaper_config.max_age_hours)
168
+
169
+ # Add version tag to track baseline stack version
170
+ tags = {"pulse-cf-version": BASELINE_STACK_VERSION}
171
+
172
+ cdk_run(cdk_bin, "synth", context, workdir)
173
+ cdk_run(cdk_bin, "deploy", context, workdir, stack_name=stack_name, tags=tags)
174
+
175
+ return await wait_for_stack_outputs(
176
+ cfn,
177
+ stack_name,
178
+ deployment_name,
179
+ region,
180
+ account,
181
+ poll_interval=poll_interval,
182
+ )
183
+
184
+
185
+ def _ensure_bootstrap(
186
+ cfn: Any,
187
+ cdk_bin: str,
188
+ account: str,
189
+ region: str,
190
+ workdir: Path | str | None,
191
+ *,
192
+ force: bool = False,
193
+ ) -> None:
194
+ """Ensure CDK is bootstrapped in the target account/region.
195
+
196
+ Args:
197
+ cfn: CloudFormation client
198
+ cdk_bin: Path to CDK binary
199
+ account: AWS account ID
200
+ region: AWS region
201
+ workdir: Working directory for CDK commands
202
+ force: Force re-running bootstrap even if already bootstrapped
203
+ """
204
+ if not force:
205
+ stack = describe_stack(cfn, TOOLKIT_STACK_NAME)
206
+ if stack and is_stack_healthy(stack):
207
+ return
208
+
209
+ target = f"aws://{account}/{region}"
210
+ cdk_run(cdk_bin, "bootstrap", {}, workdir, stack_name=target)
211
+
212
+
213
+ def cdk_run(
214
+ cdk_bin: str,
215
+ command: str,
216
+ context: Mapping[str, str],
217
+ workdir: Path | str | None,
218
+ stack_name: str | None = None,
219
+ tags: Mapping[str, str] | None = None,
220
+ ) -> None:
221
+ # Bootstrap doesn't need the CDK app, so run it from anywhere
222
+ if command == "bootstrap":
223
+ args = [cdk_bin, command]
224
+ if stack_name:
225
+ args.append(stack_name)
226
+ try:
227
+ subprocess.run(args, check=True)
228
+ except FileNotFoundError as exc:
229
+ msg = f"Unable to execute '{cdk_bin}'. Install AWS CDK CLI and try again."
230
+ raise BaselineStackError(msg) from exc
231
+ except subprocess.CalledProcessError as exc:
232
+ msg = f"'{' '.join(args)}' exited with code {exc.returncode}"
233
+ raise BaselineStackError(msg) from exc
234
+ return
235
+
236
+ # Other commands need to run from the CDK app directory
237
+ cwd = Path(workdir) if workdir is not None else DEFAULT_CDK_APP_DIR
238
+ if not cwd.exists():
239
+ msg = f"CDK app directory '{cwd}' does not exist"
240
+ raise BaselineStackError(msg)
241
+ args = [cdk_bin, command]
242
+ if stack_name:
243
+ args.append(stack_name)
244
+ for key, value in context.items():
245
+ args.extend(["-c", f"{key}={value}"])
246
+ if command == "deploy":
247
+ args.extend(["--require-approval", "never"])
248
+ # Add tags for version tracking
249
+ if tags:
250
+ for key, value in tags.items():
251
+ args.extend(["--tags", f"{key}={value}"])
252
+ try:
253
+ subprocess.run(
254
+ args,
255
+ check=True,
256
+ cwd=str(cwd),
257
+ )
258
+ except FileNotFoundError as exc:
259
+ msg = f"Unable to execute '{cdk_bin}'. Install AWS CDK CLI and try again."
260
+ raise BaselineStackError(msg) from exc
261
+ except subprocess.CalledProcessError as exc:
262
+ msg = f"'{' '.join(args)}' exited with code {exc.returncode}"
263
+ raise BaselineStackError(msg) from exc
264
+
265
+
266
+ async def wait_for_stack_outputs(
267
+ cfn: Any,
268
+ stack_name: str,
269
+ deployment_name: str,
270
+ region: str,
271
+ account: str,
272
+ *,
273
+ poll_interval: float,
274
+ ) -> BaselineStackOutputs:
275
+ while True:
276
+ stack = describe_stack(cfn, stack_name)
277
+ if not stack:
278
+ msg = f"Stack {stack_name} not found after deployment"
279
+ raise BaselineStackError(msg)
280
+
281
+ status = stack["StackStatus"]
282
+ if status in STACK_SUCCEEDED:
283
+ return extract_stack_outputs(
284
+ deployment_name,
285
+ region,
286
+ account,
287
+ stack_name,
288
+ stack,
289
+ )
290
+ if status in STACK_FAILED:
291
+ msg = f"Stack {stack_name} failed with status {status}"
292
+ raise BaselineStackError(msg)
293
+ await asyncio.sleep(max(poll_interval, 1.0))
294
+
295
+
296
+ def extract_stack_outputs(
297
+ deployment_name: str,
298
+ region: str,
299
+ account: str,
300
+ stack_name: str,
301
+ stack: Mapping[str, Any],
302
+ ) -> BaselineStackOutputs:
303
+ outputs = {
304
+ item["OutputKey"]: item["OutputValue"] for item in stack.get("Outputs", [])
305
+ }
306
+
307
+ def require(key: str) -> str:
308
+ if key not in outputs or not outputs[key]:
309
+ msg = f"Missing CloudFormation output '{key}' on stack {stack_name}"
310
+ raise BaselineStackError(msg)
311
+ return outputs[key]
312
+
313
+ return BaselineStackOutputs(
314
+ deployment_name=deployment_name,
315
+ region=region,
316
+ account=account,
317
+ stack_name=stack_name,
318
+ listener_arn=require("ListenerArn"),
319
+ alb_dns_name=require("AlbDnsName"),
320
+ alb_hosted_zone_id=require("AlbHostedZoneId"),
321
+ private_subnet_ids=split_commas(require("PrivateSubnets")),
322
+ public_subnet_ids=split_commas(require("PublicSubnets")),
323
+ alb_security_group_id=require("AlbSecurityGroupId"),
324
+ service_security_group_id=require("ServiceSecurityGroupId"),
325
+ cluster_name=require("ClusterName"),
326
+ log_group_name=require("LogGroupName"),
327
+ ecr_repository_uri=require("EcrRepositoryUri"),
328
+ vpc_id=require("VpcId"),
329
+ execution_role_arn=require("ExecutionRoleArn"),
330
+ task_role_arn=require("TaskRoleArn"),
331
+ )
332
+
333
+
334
+ def split_commas(value: str) -> list[str]:
335
+ return [item.strip() for item in value.split(",") if item.strip()]
336
+
337
+
338
+ def describe_stack(cfn: Any, stack_name: str) -> dict[str, Any] | None:
339
+ try:
340
+ response = cfn.describe_stacks(StackName=stack_name)
341
+ except ClientError as exc:
342
+ if (
343
+ exc.response["Error"]["Code"] == "ValidationError"
344
+ and "does not exist" in exc.response["Error"]["Message"]
345
+ ):
346
+ return None
347
+ raise
348
+ return response["Stacks"][0]
349
+
350
+
351
+ def is_stack_healthy(stack: Mapping[str, Any]) -> bool:
352
+ return stack.get("StackStatus") in STACK_SUCCEEDED
353
+
354
+
355
+ def get_stack_version(stack: Mapping[str, Any]) -> str | None:
356
+ """Extract the baseline version from stack tags."""
357
+ tags = stack.get("Tags", [])
358
+ for tag in tags:
359
+ if tag.get("Key") == "pulse-cf-version":
360
+ return tag.get("Value")
361
+ return None
362
+
363
+
364
+ __all__ = [
365
+ "AcmCertificate",
366
+ "BASELINE_STACK_VERSION",
367
+ "BaselineStackError",
368
+ "BaselineStackOutputs",
369
+ "DnsConfiguration",
370
+ "DnsRecord",
371
+ "STACK_DELETE_COMPLETE",
372
+ "STACK_DELETING",
373
+ "STACK_FAILED",
374
+ "STACK_NAME_TEMPLATE",
375
+ "STACK_SUCCEEDED",
376
+ "check_domain_dns",
377
+ "describe_stack",
378
+ "ensure_acm_certificate",
379
+ "ensure_baseline_stack",
380
+ ]
Binary file
File without changes
pulse_aws/cdk/app.py ADDED
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import os
5
+
6
+ import aws_cdk as cdk
7
+
8
+ from pulse_aws.cdk.baseline import BaselineStack
9
+ from pulse_aws.cdk.helpers import cvalue, lst
10
+
11
+ app = cdk.App()
12
+ deployment_name = cvalue(app, "deployment_name")
13
+ certificate_arn = cvalue(app, "certificate_arn")
14
+ allowed_cidrs = lst(cvalue(app, "allowed_ingress_cidrs", optional=True))
15
+
16
+ # Reaper configuration (with defaults)
17
+ reaper_schedule_minutes = int(
18
+ cvalue(app, "reaper_schedule_minutes", optional=True) or "1"
19
+ )
20
+ reaper_consecutive_periods = int(
21
+ cvalue(app, "reaper_consecutive_periods", optional=True) or "2"
22
+ )
23
+ reaper_period_seconds = int(cvalue(app, "reaper_period_seconds", optional=True) or "60")
24
+ reaper_min_age_seconds = int(
25
+ cvalue(app, "reaper_min_age_seconds", optional=True) or "60"
26
+ )
27
+ reaper_max_age_hours = float(
28
+ cvalue(app, "reaper_max_age_hours", optional=True) or "1.0"
29
+ )
30
+
31
+ BaselineStack(
32
+ app,
33
+ f"{deployment_name}-baseline",
34
+ env=cdk.Environment(
35
+ account=os.getenv("CDK_DEFAULT_ACCOUNT"),
36
+ region=os.getenv("CDK_DEFAULT_REGION"),
37
+ ),
38
+ deployment_name=deployment_name,
39
+ certificate_arn=certificate_arn,
40
+ allowed_ingress_cidrs=allowed_cidrs,
41
+ reaper_schedule_minutes=reaper_schedule_minutes,
42
+ reaper_consecutive_periods=reaper_consecutive_periods,
43
+ reaper_period_seconds=reaper_period_seconds,
44
+ reaper_min_age_seconds=reaper_min_age_seconds,
45
+ reaper_max_age_hours=reaper_max_age_hours,
46
+ )
47
+
48
+ app.synth()