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 +0 -0
- pulse_aws/__init__.py +104 -0
- pulse_aws/baseline.py +380 -0
- pulse_aws/cdk/.DS_Store +0 -0
- pulse_aws/cdk/__init__.py +0 -0
- pulse_aws/cdk/app.py +48 -0
- pulse_aws/cdk/baseline.py +387 -0
- pulse_aws/cdk/cdk.context.json +17 -0
- pulse_aws/cdk/cdk.json +5 -0
- pulse_aws/cdk/cdk.out/cdk.out +1 -0
- pulse_aws/cdk/cdk.out/manifest.json +799 -0
- pulse_aws/cdk/cdk.out/test-baseline.assets.json +21 -0
- pulse_aws/cdk/cdk.out/test-baseline.template.json +1106 -0
- pulse_aws/cdk/cdk.out/tree.json +1 -0
- pulse_aws/cdk/helpers.py +25 -0
- pulse_aws/certificate.py +516 -0
- pulse_aws/config.py +96 -0
- pulse_aws/deployment.py +1550 -0
- pulse_aws/plugin.py +262 -0
- pulse_aws/py.typed +0 -0
- pulse_aws/reaper_lambda.py +484 -0
- pulse_aws/reporting.py +141 -0
- pulse_aws/teardown.py +213 -0
- pulse_aws-0.1.0.dist-info/METADATA +219 -0
- pulse_aws-0.1.0.dist-info/RECORD +26 -0
- pulse_aws-0.1.0.dist-info/WHEEL +4 -0
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
|
+
]
|
pulse_aws/cdk/.DS_Store
ADDED
|
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()
|