magic-pocket-cli 0.2.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.
- magic_pocket_cli-0.2.0.dist-info/METADATA +14 -0
- magic_pocket_cli-0.2.0.dist-info/RECORD +65 -0
- magic_pocket_cli-0.2.0.dist-info/WHEEL +4 -0
- magic_pocket_cli-0.2.0.dist-info/entry_points.txt +2 -0
- pocket_cli/__init__.py +0 -0
- pocket_cli/cli/__init__.py +0 -0
- pocket_cli/cli/aws_auth.py +48 -0
- pocket_cli/cli/awscontainer_cli.py +328 -0
- pocket_cli/cli/cloudfront_cli.py +116 -0
- pocket_cli/cli/cloudfront_keys_cli.py +68 -0
- pocket_cli/cli/cloudfront_waf_cli.py +68 -0
- pocket_cli/cli/deploy_cli.py +274 -0
- pocket_cli/cli/destroy_cli.py +358 -0
- pocket_cli/cli/dsql_cli.py +60 -0
- pocket_cli/cli/main_cli.py +91 -0
- pocket_cli/cli/migrate_cli.py +148 -0
- pocket_cli/cli/neon_cli.py +97 -0
- pocket_cli/cli/permissions_cli.py +46 -0
- pocket_cli/cli/rds_cli.py +63 -0
- pocket_cli/cli/runtime_config_cli.py +185 -0
- pocket_cli/cli/s3_cli.py +69 -0
- pocket_cli/cli/status_cli.py +56 -0
- pocket_cli/cli/tidb_cli.py +73 -0
- pocket_cli/cli/vpc_cli.py +92 -0
- pocket_cli/cli/waf_cli.py +182 -0
- pocket_cli/django_cli.py +412 -0
- pocket_cli/mediator.py +220 -0
- pocket_cli/resources/__init__.py +0 -0
- pocket_cli/resources/aws/__init__.py +0 -0
- pocket_cli/resources/aws/builders/__init__.py +57 -0
- pocket_cli/resources/aws/builders/codebuild.py +363 -0
- pocket_cli/resources/aws/builders/depot.py +84 -0
- pocket_cli/resources/aws/builders/docker.py +34 -0
- pocket_cli/resources/aws/builders/dockerignore.py +44 -0
- pocket_cli/resources/aws/cloudformation.py +790 -0
- pocket_cli/resources/aws/ecr.py +145 -0
- pocket_cli/resources/aws/efs.py +138 -0
- pocket_cli/resources/aws/lambdahandler.py +182 -0
- pocket_cli/resources/aws/s3_utils.py +58 -0
- pocket_cli/resources/aws/state.py +74 -0
- pocket_cli/resources/awscontainer.py +265 -0
- pocket_cli/resources/cloudfront.py +491 -0
- pocket_cli/resources/cloudfront_acm.py +55 -0
- pocket_cli/resources/cloudfront_keys.py +81 -0
- pocket_cli/resources/cloudfront_waf.py +67 -0
- pocket_cli/resources/dsql.py +142 -0
- pocket_cli/resources/neon.py +353 -0
- pocket_cli/resources/rds.py +680 -0
- pocket_cli/resources/s3.py +307 -0
- pocket_cli/resources/tidb.py +298 -0
- pocket_cli/resources/upstash.py +152 -0
- pocket_cli/resources/vpc.py +67 -0
- pocket_cli/templates/cloudformation/awscontainer.yaml +516 -0
- pocket_cli/templates/cloudformation/cf_function_api_host.js +5 -0
- pocket_cli/templates/cloudformation/cf_function_spa_auth.js +28 -0
- pocket_cli/templates/cloudformation/cf_function_spa_fallback.js +8 -0
- pocket_cli/templates/cloudformation/cloudfront.yaml +309 -0
- pocket_cli/templates/cloudformation/cloudfront_acm.yaml +43 -0
- pocket_cli/templates/cloudformation/cloudfront_keys.yaml +32 -0
- pocket_cli/templates/cloudformation/cloudfront_waf.yaml +97 -0
- pocket_cli/templates/cloudformation/vpc.yaml +213 -0
- pocket_cli/templates/init/django-dotenv.env +3 -0
- pocket_cli/templates/init/django-settings.py +140 -0
- pocket_cli/templates/init/pocket.Dockerfile +26 -0
- pocket_cli/templates/init/pocket_simple.toml +31 -0
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from functools import cached_property
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
import boto3
|
|
8
|
+
import yaml
|
|
9
|
+
from botocore.exceptions import ClientError
|
|
10
|
+
from deepdiff import DeepDiff
|
|
11
|
+
from jinja2 import Environment, PackageLoader, select_autoescape
|
|
12
|
+
|
|
13
|
+
from pocket.resources.base import ResourceStatus
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from pocket.context import (
|
|
17
|
+
AwsContainerContext,
|
|
18
|
+
CloudFrontContext,
|
|
19
|
+
DsqlContext,
|
|
20
|
+
RdsContext,
|
|
21
|
+
SchedulerContext,
|
|
22
|
+
)
|
|
23
|
+
from pocket.general_context import VpcContext
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Stack:
|
|
27
|
+
template_filename: str
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def name(self) -> str:
|
|
31
|
+
raise NotImplementedError
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def export(self) -> dict:
|
|
35
|
+
raise NotImplementedError
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def stack_tags(self) -> list[dict]:
|
|
39
|
+
return []
|
|
40
|
+
|
|
41
|
+
def __init__(self, context: AwsContainerContext | VpcContext | CloudFrontContext):
|
|
42
|
+
self.context = context
|
|
43
|
+
self.client = self.get_client()
|
|
44
|
+
|
|
45
|
+
def get_client(self):
|
|
46
|
+
return boto3.client("cloudformation", region_name=self.context.region)
|
|
47
|
+
|
|
48
|
+
def _get_resource(self) -> Any:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def capabilities(self):
|
|
53
|
+
return []
|
|
54
|
+
|
|
55
|
+
@cached_property
|
|
56
|
+
def description(self):
|
|
57
|
+
try:
|
|
58
|
+
return self.client.describe_stacks(StackName=self.name)["Stacks"][0]
|
|
59
|
+
except ClientError:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
@cached_property
|
|
63
|
+
def uploaded_template(self) -> str | None:
|
|
64
|
+
try:
|
|
65
|
+
return self.client.get_template(StackName=self.name)["TemplateBody"]
|
|
66
|
+
except ClientError:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
def clear_status(self):
|
|
70
|
+
if hasattr(self, "description"):
|
|
71
|
+
del self.description
|
|
72
|
+
if hasattr(self, "uploaded_template"):
|
|
73
|
+
del self.uploaded_template
|
|
74
|
+
|
|
75
|
+
def wait_status(
|
|
76
|
+
self,
|
|
77
|
+
status: ResourceStatus,
|
|
78
|
+
timeout=300,
|
|
79
|
+
interval=3,
|
|
80
|
+
error_statuses: tuple[ResourceStatus] = ("FAILED",),
|
|
81
|
+
):
|
|
82
|
+
noexist_count = 0
|
|
83
|
+
for i in range(timeout // interval):
|
|
84
|
+
self.clear_status()
|
|
85
|
+
current = self.cfn_status
|
|
86
|
+
if current == status:
|
|
87
|
+
print("")
|
|
88
|
+
return
|
|
89
|
+
if current in error_statuses:
|
|
90
|
+
print(self.description)
|
|
91
|
+
raise RuntimeError(
|
|
92
|
+
f"Stack status is {current}. Please check the console."
|
|
93
|
+
)
|
|
94
|
+
# COMPLETED を待っているのにスタックが見つからない場合
|
|
95
|
+
if status != "NOEXIST" and current == "NOEXIST":
|
|
96
|
+
noexist_count += 1
|
|
97
|
+
if noexist_count >= 3:
|
|
98
|
+
raise RuntimeError(
|
|
99
|
+
f"Stack '{self.name}' が見つかりません。"
|
|
100
|
+
"作成に失敗した可能性があります。"
|
|
101
|
+
"AWS コンソールで権限とリージョンを確認してください。"
|
|
102
|
+
)
|
|
103
|
+
else:
|
|
104
|
+
noexist_count = 0
|
|
105
|
+
if i == 0:
|
|
106
|
+
msg = "Waiting for %s stack status to be %s" % (
|
|
107
|
+
self.template_filename,
|
|
108
|
+
status,
|
|
109
|
+
)
|
|
110
|
+
print(msg, end="", flush=True)
|
|
111
|
+
print(".", end="", flush=True)
|
|
112
|
+
time.sleep(interval)
|
|
113
|
+
raise RuntimeError("Timeout is %s seconds" % timeout)
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def output(self) -> dict[str, str] | None:
|
|
117
|
+
if self.description and "Outputs" in self.description:
|
|
118
|
+
result = {}
|
|
119
|
+
for output in self.description["Outputs"]:
|
|
120
|
+
result[output["OutputKey"]] = output["OutputValue"]
|
|
121
|
+
if "ExportName" in output:
|
|
122
|
+
result[output["ExportName"]] = output["OutputValue"]
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def deleted_at(self):
|
|
127
|
+
if self.description:
|
|
128
|
+
return self.description["DeletionTime"].astimezone()
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def status_detail(self):
|
|
132
|
+
if not self.description:
|
|
133
|
+
return "NOT_CREATED"
|
|
134
|
+
return self.description["StackStatus"]
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def exists(self):
|
|
138
|
+
return self.status != "NOEXIST"
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def cfn_status(self) -> ResourceStatus:
|
|
142
|
+
"""CloudFormation のステータスのみで判定する(yaml_synced を含まない)。
|
|
143
|
+
|
|
144
|
+
wait_status 等、スタック操作の完了待ちに使用する。
|
|
145
|
+
"""
|
|
146
|
+
if self.status_detail == "NOT_CREATED":
|
|
147
|
+
return "NOEXIST"
|
|
148
|
+
action = self.status_detail.split("_")[0]
|
|
149
|
+
action_status = self.status_detail.split("_")[-1]
|
|
150
|
+
if action_status == "PROGRESS":
|
|
151
|
+
return "PROGRESS"
|
|
152
|
+
if action_status == "FAILED":
|
|
153
|
+
return "FAILED"
|
|
154
|
+
if action_status == "COMPLETE":
|
|
155
|
+
if action == "DELETE":
|
|
156
|
+
return "NOEXIST"
|
|
157
|
+
elif action == "ROLLBACK":
|
|
158
|
+
if self.deleted_at:
|
|
159
|
+
return "FAILED"
|
|
160
|
+
return "COMPLETED"
|
|
161
|
+
elif action in {"IMPORT", "REVIEW", "UPDATE", "CREATE"}:
|
|
162
|
+
return "COMPLETED"
|
|
163
|
+
raise RuntimeError("unknown status: %s" % self.status_detail)
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def status(self) -> ResourceStatus:
|
|
167
|
+
"""deploy_resources 等で使うステータス。yaml_synced も考慮する。"""
|
|
168
|
+
cfn = self.cfn_status
|
|
169
|
+
if cfn in ("NOEXIST", "PROGRESS", "FAILED"):
|
|
170
|
+
return cfn
|
|
171
|
+
# COMPLETED だが yaml が同期していなければ更新が必要
|
|
172
|
+
if cfn == "COMPLETED" and not self.yaml_synced:
|
|
173
|
+
return "REQUIRE_UPDATE"
|
|
174
|
+
return cfn
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def yaml(self) -> str:
|
|
178
|
+
template = Environment(
|
|
179
|
+
loader=PackageLoader("pocket_cli"), autoescape=select_autoescape()
|
|
180
|
+
).get_template(name=f"cloudformation/{self.template_filename}.yaml")
|
|
181
|
+
original_yaml = template.render(
|
|
182
|
+
stack_name=self.name,
|
|
183
|
+
export=self.export,
|
|
184
|
+
resource=self._get_resource(),
|
|
185
|
+
**self.context.model_dump(),
|
|
186
|
+
)
|
|
187
|
+
return "\n".join(
|
|
188
|
+
[
|
|
189
|
+
line
|
|
190
|
+
for line in original_yaml.splitlines()
|
|
191
|
+
if line.strip() not in ["#", "# prettier-ignore"]
|
|
192
|
+
]
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def _template_hash(self) -> str:
|
|
197
|
+
"""現在のテンプレートの SHA256 ハッシュ"""
|
|
198
|
+
import hashlib
|
|
199
|
+
|
|
200
|
+
return hashlib.sha256(self.yaml.encode()).hexdigest()[:16]
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def _deployed_template_hash(self) -> str | None:
|
|
204
|
+
"""デプロイ済みスタックのタグから template hash を取得"""
|
|
205
|
+
if not self.description:
|
|
206
|
+
return None
|
|
207
|
+
for tag in self.description.get("Tags", []):
|
|
208
|
+
if tag["Key"] == "pocket:template_hash":
|
|
209
|
+
return tag["Value"]
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def yaml_synced(self):
|
|
214
|
+
deployed_hash = self._deployed_template_hash
|
|
215
|
+
if deployed_hash is None:
|
|
216
|
+
if self.cfn_status not in ("NOEXIST",):
|
|
217
|
+
print(
|
|
218
|
+
"pocket:template_hash タグがありません: %s\n"
|
|
219
|
+
"pocket migrate --stage=<stage> を実行してください。" % self.name
|
|
220
|
+
)
|
|
221
|
+
return False
|
|
222
|
+
return deployed_hash == self._template_hash
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def yaml_diff(self):
|
|
226
|
+
return DeepDiff(
|
|
227
|
+
yaml.safe_load(self.uploaded_template or ""),
|
|
228
|
+
yaml.safe_load(self.yaml),
|
|
229
|
+
ignore_order=True,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def _build_tags(self) -> list[dict]:
|
|
233
|
+
tags = list(self.stack_tags)
|
|
234
|
+
tags.append({"Key": "pocket:template_hash", "Value": self._template_hash})
|
|
235
|
+
return tags
|
|
236
|
+
|
|
237
|
+
def create(self):
|
|
238
|
+
kwargs: dict[str, Any] = {
|
|
239
|
+
"StackName": self.name,
|
|
240
|
+
"TemplateBody": self.yaml,
|
|
241
|
+
"Capabilities": self.capabilities,
|
|
242
|
+
"Tags": self._build_tags(),
|
|
243
|
+
}
|
|
244
|
+
return self.client.create_stack(**kwargs)
|
|
245
|
+
|
|
246
|
+
def update(self):
|
|
247
|
+
print("Update stack")
|
|
248
|
+
return self.client.update_stack(
|
|
249
|
+
StackName=self.name,
|
|
250
|
+
TemplateBody=self.yaml,
|
|
251
|
+
Capabilities=self.capabilities,
|
|
252
|
+
Tags=self._build_tags(),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
def delete(self):
|
|
256
|
+
return self.client.delete_stack(StackName=self.name)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class AcmStack(Stack):
|
|
260
|
+
"""us-east-1 に ACM 証明書を作成するスタック。
|
|
261
|
+
|
|
262
|
+
CloudFront はカスタムドメイン使用時に us-east-1 の ACM 証明書が必須。
|
|
263
|
+
メインの CloudFront スタックとは別リージョンで管理する。
|
|
264
|
+
"""
|
|
265
|
+
|
|
266
|
+
context: CloudFrontContext
|
|
267
|
+
template_filename = "cloudfront_acm"
|
|
268
|
+
|
|
269
|
+
def get_client(self):
|
|
270
|
+
return boto3.client("cloudformation", region_name="us-east-1")
|
|
271
|
+
|
|
272
|
+
@property
|
|
273
|
+
def name(self):
|
|
274
|
+
return f"{self.context.slug}-acm"
|
|
275
|
+
|
|
276
|
+
@property
|
|
277
|
+
def export(self):
|
|
278
|
+
return {}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class CloudFrontWafStack(Stack):
|
|
282
|
+
"""us-east-1 に WAFv2 IPSet + WebACL を作成するスタック。
|
|
283
|
+
|
|
284
|
+
CloudFront に attach する WebACL は Scope=CLOUDFRONT 必須で、これは
|
|
285
|
+
us-east-1 でしか作成できない。AcmStack と同じく、メインの CloudFront
|
|
286
|
+
スタックとは別リージョンで管理する。
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
context: CloudFrontContext
|
|
290
|
+
template_filename = "cloudfront_waf"
|
|
291
|
+
|
|
292
|
+
def get_client(self):
|
|
293
|
+
return boto3.client("cloudformation", region_name="us-east-1")
|
|
294
|
+
|
|
295
|
+
@property
|
|
296
|
+
def name(self):
|
|
297
|
+
return f"{self.context.slug}-waf"
|
|
298
|
+
|
|
299
|
+
@property
|
|
300
|
+
def export(self):
|
|
301
|
+
return {}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class CloudFrontKeysStack(Stack):
|
|
305
|
+
context: CloudFrontContext
|
|
306
|
+
template_filename = "cloudfront_keys"
|
|
307
|
+
|
|
308
|
+
def __init__(
|
|
309
|
+
self,
|
|
310
|
+
context: CloudFrontContext,
|
|
311
|
+
signing_public_key_pem: str = "",
|
|
312
|
+
):
|
|
313
|
+
self._signing_public_key_pem = signing_public_key_pem
|
|
314
|
+
super().__init__(context)
|
|
315
|
+
|
|
316
|
+
def get_client(self):
|
|
317
|
+
return boto3.client("cloudformation", region_name=self.context.region)
|
|
318
|
+
|
|
319
|
+
@property
|
|
320
|
+
def name(self):
|
|
321
|
+
return f"{self.context.slug}-cloudfront-keys"
|
|
322
|
+
|
|
323
|
+
@property
|
|
324
|
+
def export(self):
|
|
325
|
+
return {
|
|
326
|
+
"public_key_id": f"{self.context.slug}-public-key-id",
|
|
327
|
+
"key_group_id": f"{self.context.slug}-key-group-id",
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
@property
|
|
331
|
+
def yaml(self) -> str:
|
|
332
|
+
from jinja2 import Environment, PackageLoader, select_autoescape
|
|
333
|
+
|
|
334
|
+
template = Environment(
|
|
335
|
+
loader=PackageLoader("pocket_cli"), autoescape=select_autoescape()
|
|
336
|
+
).get_template(name=f"cloudformation/{self.template_filename}.yaml")
|
|
337
|
+
original_yaml = template.render(
|
|
338
|
+
stack_name=self.name,
|
|
339
|
+
export=self.export,
|
|
340
|
+
resource=self._get_resource(),
|
|
341
|
+
signing_public_key_pem=self._signing_public_key_pem,
|
|
342
|
+
**self.context.model_dump(),
|
|
343
|
+
)
|
|
344
|
+
return "\n".join(
|
|
345
|
+
[
|
|
346
|
+
line
|
|
347
|
+
for line in original_yaml.splitlines()
|
|
348
|
+
if line.strip() not in ["#", "# prettier-ignore"]
|
|
349
|
+
]
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
class CloudFrontStack(Stack):
|
|
354
|
+
context: CloudFrontContext
|
|
355
|
+
template_filename = "cloudfront"
|
|
356
|
+
|
|
357
|
+
def __init__(
|
|
358
|
+
self,
|
|
359
|
+
context: CloudFrontContext,
|
|
360
|
+
token_secret_value: str = "",
|
|
361
|
+
):
|
|
362
|
+
self._token_secret_value = token_secret_value
|
|
363
|
+
super().__init__(context)
|
|
364
|
+
|
|
365
|
+
def get_client(self):
|
|
366
|
+
return boto3.client("cloudformation", region_name=self.context.region)
|
|
367
|
+
|
|
368
|
+
@property
|
|
369
|
+
def name(self):
|
|
370
|
+
return f"{self.context.slug}-cloudfront"
|
|
371
|
+
|
|
372
|
+
@property
|
|
373
|
+
def _has_token_kvs(self) -> bool:
|
|
374
|
+
return any(r.require_token for r in self.context.routes)
|
|
375
|
+
|
|
376
|
+
@property
|
|
377
|
+
def export(self):
|
|
378
|
+
exports: dict[str, str] = {}
|
|
379
|
+
if self.context.signing_key:
|
|
380
|
+
exports["key_group_id"] = f"{self.context.slug}-key-group-id"
|
|
381
|
+
if self._has_token_kvs:
|
|
382
|
+
exports["kvs_arn"] = f"{self.context.slug}-token-kvs-arn"
|
|
383
|
+
return exports
|
|
384
|
+
|
|
385
|
+
def _resolve_waf_arn(self) -> str | None:
|
|
386
|
+
"""WAF スタック (us-east-1) から WebACL ARN を取得する。"""
|
|
387
|
+
if not self.context.waf:
|
|
388
|
+
return None
|
|
389
|
+
waf_stack = CloudFrontWafStack(self.context)
|
|
390
|
+
output = waf_stack.output
|
|
391
|
+
if not output:
|
|
392
|
+
raise RuntimeError(
|
|
393
|
+
f"WAF stack '{waf_stack.name}' が見つかりません。"
|
|
394
|
+
"先に WAF スタックをデプロイしてください。"
|
|
395
|
+
)
|
|
396
|
+
return output.get("WebACLArn")
|
|
397
|
+
|
|
398
|
+
def _resolve_acm_arns(self) -> tuple[str | None, dict[str, str]]:
|
|
399
|
+
"""ACM スタック (us-east-1) から証明書 ARN を取得する。
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
(メインドメインの証明書 ARN, redirect_from の yaml_key → ARN マップ)
|
|
403
|
+
"""
|
|
404
|
+
if not self.context.domain:
|
|
405
|
+
return None, {}
|
|
406
|
+
acm_stack = AcmStack(self.context)
|
|
407
|
+
output = acm_stack.output
|
|
408
|
+
if not output:
|
|
409
|
+
raise RuntimeError(
|
|
410
|
+
f"ACM stack '{acm_stack.name}' が見つかりません。"
|
|
411
|
+
"先に ACM スタックをデプロイしてください。"
|
|
412
|
+
)
|
|
413
|
+
cert_arn = output.get("CertificateArn")
|
|
414
|
+
redirect_arns: dict[str, str] = {}
|
|
415
|
+
for rf in self.context.redirect_from:
|
|
416
|
+
key = f"CertificateArn{rf.yaml_key}"
|
|
417
|
+
if key in output:
|
|
418
|
+
redirect_arns[rf.yaml_key] = output[key]
|
|
419
|
+
return cert_arn, redirect_arns
|
|
420
|
+
|
|
421
|
+
def _build_function_codes(self) -> dict[str, str]:
|
|
422
|
+
"""ルートごとに CloudFront Function コードを生成する"""
|
|
423
|
+
codes: dict[str, str] = {}
|
|
424
|
+
for route in self.context.routes:
|
|
425
|
+
if not route.is_spa:
|
|
426
|
+
continue
|
|
427
|
+
if route.require_token:
|
|
428
|
+
codes[route.yaml_key] = self._generate_spa_auth_function(route)
|
|
429
|
+
else:
|
|
430
|
+
codes[route.yaml_key] = self._generate_spa_fallback_function(route)
|
|
431
|
+
return codes
|
|
432
|
+
|
|
433
|
+
def _build_deploy_hash_function_codes(self) -> dict[str, str]:
|
|
434
|
+
"""deploy_hash route 用の hash prefix strip Function コードを生成する"""
|
|
435
|
+
codes: dict[str, str] = {}
|
|
436
|
+
deploy_hash = self.context.deploy_hash
|
|
437
|
+
if not deploy_hash:
|
|
438
|
+
return codes
|
|
439
|
+
for route in self.context.routes:
|
|
440
|
+
if not route.is_deploy_hash:
|
|
441
|
+
continue
|
|
442
|
+
code = (
|
|
443
|
+
"function handler(event) {\n"
|
|
444
|
+
" var request = event.request;\n"
|
|
445
|
+
' request.uri = request.uri.replace("/%s/", "/");\n'
|
|
446
|
+
" return request;\n"
|
|
447
|
+
"}" % deploy_hash
|
|
448
|
+
)
|
|
449
|
+
lines = []
|
|
450
|
+
for i, line in enumerate(code.splitlines()):
|
|
451
|
+
if i == 0:
|
|
452
|
+
lines.append(line)
|
|
453
|
+
else:
|
|
454
|
+
lines.append(" " * 8 + line)
|
|
455
|
+
codes[route.yaml_key] = "\n".join(lines)
|
|
456
|
+
return codes
|
|
457
|
+
|
|
458
|
+
def _generate_spa_fallback_function(self, route) -> str: # type: ignore
|
|
459
|
+
"""SPA URL フォールバック用 CloudFront Function コードを生成する"""
|
|
460
|
+
fallback_uri = route.path_pattern + "/" + route.spa_fallback_html
|
|
461
|
+
if not route.path_pattern:
|
|
462
|
+
fallback_uri = "/" + route.spa_fallback_html
|
|
463
|
+
env = Environment(
|
|
464
|
+
loader=PackageLoader("pocket_cli"),
|
|
465
|
+
autoescape=select_autoescape(),
|
|
466
|
+
)
|
|
467
|
+
template = env.get_template("cloudformation/cf_function_spa_fallback.js")
|
|
468
|
+
code = template.render(fallback_uri=fallback_uri)
|
|
469
|
+
# FunctionCode: | の下は8スペース
|
|
470
|
+
lines = []
|
|
471
|
+
for i, line in enumerate(code.splitlines()):
|
|
472
|
+
if i == 0:
|
|
473
|
+
lines.append(line)
|
|
474
|
+
else:
|
|
475
|
+
lines.append(" " * 8 + line)
|
|
476
|
+
return "\n".join(lines)
|
|
477
|
+
|
|
478
|
+
def _generate_spa_auth_function(self, route) -> str: # type: ignore
|
|
479
|
+
"""KVS + HMAC 検証付き async CloudFront Function コードを生成する"""
|
|
480
|
+
fallback_uri = route.path_pattern + "/" + route.spa_fallback_html
|
|
481
|
+
if not route.path_pattern:
|
|
482
|
+
fallback_uri = "/" + route.spa_fallback_html
|
|
483
|
+
env = Environment(
|
|
484
|
+
loader=PackageLoader("pocket_cli"),
|
|
485
|
+
autoescape=select_autoescape(),
|
|
486
|
+
)
|
|
487
|
+
template = env.get_template("cloudformation/cf_function_spa_auth.js")
|
|
488
|
+
code = template.render(
|
|
489
|
+
fallback_uri=fallback_uri,
|
|
490
|
+
login_path=route.login_path,
|
|
491
|
+
)
|
|
492
|
+
# Fn::Sub の2パラメータ形式で - | の下は12スペース
|
|
493
|
+
lines = []
|
|
494
|
+
for i, line in enumerate(code.splitlines()):
|
|
495
|
+
if i == 0:
|
|
496
|
+
lines.append(line)
|
|
497
|
+
else:
|
|
498
|
+
lines.append(" " * 12 + line)
|
|
499
|
+
return "\n".join(lines)
|
|
500
|
+
|
|
501
|
+
def _generate_api_host_function(self) -> str:
|
|
502
|
+
"""API ルート用 X-Forwarded-Host 付与 Function コードを生成する"""
|
|
503
|
+
from jinja2 import Environment, PackageLoader, select_autoescape
|
|
504
|
+
|
|
505
|
+
env = Environment(
|
|
506
|
+
loader=PackageLoader("pocket_cli"),
|
|
507
|
+
autoescape=select_autoescape(),
|
|
508
|
+
)
|
|
509
|
+
template = env.get_template("cloudformation/cf_function_api_host.js")
|
|
510
|
+
code = template.render()
|
|
511
|
+
lines = []
|
|
512
|
+
for i, line in enumerate(code.splitlines()):
|
|
513
|
+
if i == 0:
|
|
514
|
+
lines.append(line)
|
|
515
|
+
else:
|
|
516
|
+
lines.append(" " * 8 + line)
|
|
517
|
+
return "\n".join(lines)
|
|
518
|
+
|
|
519
|
+
@property
|
|
520
|
+
def yaml(self) -> str:
|
|
521
|
+
acm_certificate_arn, acm_redirect_arns = self._resolve_acm_arns()
|
|
522
|
+
waf_acl_arn = self._resolve_waf_arn()
|
|
523
|
+
function_codes = self._build_function_codes()
|
|
524
|
+
deploy_hash_function_codes = self._build_deploy_hash_function_codes()
|
|
525
|
+
api_host_function_code = ""
|
|
526
|
+
if self.context.has_lambda_route:
|
|
527
|
+
api_host_function_code = self._generate_api_host_function()
|
|
528
|
+
|
|
529
|
+
from jinja2 import Environment, PackageLoader, select_autoescape
|
|
530
|
+
|
|
531
|
+
template = Environment(
|
|
532
|
+
loader=PackageLoader("pocket_cli"), autoescape=select_autoescape()
|
|
533
|
+
).get_template(name=f"cloudformation/{self.template_filename}.yaml")
|
|
534
|
+
context_data = self.context.model_dump(exclude={"signing_key", "token_secret"})
|
|
535
|
+
original_yaml = template.render(
|
|
536
|
+
stack_name=self.name,
|
|
537
|
+
export=self.export,
|
|
538
|
+
resource=self._get_resource(),
|
|
539
|
+
signing_key=bool(self.context.signing_key),
|
|
540
|
+
acm_certificate_arn=acm_certificate_arn,
|
|
541
|
+
acm_redirect_arns=acm_redirect_arns,
|
|
542
|
+
waf_acl_arn=waf_acl_arn,
|
|
543
|
+
function_codes=function_codes,
|
|
544
|
+
deploy_hash_function_codes=deploy_hash_function_codes,
|
|
545
|
+
api_host_function_code=api_host_function_code,
|
|
546
|
+
has_token_kvs=self._has_token_kvs,
|
|
547
|
+
**context_data,
|
|
548
|
+
)
|
|
549
|
+
return "\n".join(
|
|
550
|
+
[
|
|
551
|
+
line
|
|
552
|
+
for line in original_yaml.splitlines()
|
|
553
|
+
if line.strip() not in ["#", "# prettier-ignore"]
|
|
554
|
+
]
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
class ContainerStack(Stack):
|
|
559
|
+
context: AwsContainerContext
|
|
560
|
+
template_filename = "awscontainer"
|
|
561
|
+
|
|
562
|
+
def __init__(
|
|
563
|
+
self,
|
|
564
|
+
context: AwsContainerContext,
|
|
565
|
+
*,
|
|
566
|
+
rds_context: RdsContext | None = None,
|
|
567
|
+
dsql_context: DsqlContext | None = None,
|
|
568
|
+
scheduler_context: SchedulerContext | None = None,
|
|
569
|
+
):
|
|
570
|
+
self._rds_context = rds_context
|
|
571
|
+
self._dsql_context = dsql_context
|
|
572
|
+
self._scheduler_context = scheduler_context
|
|
573
|
+
super().__init__(context)
|
|
574
|
+
|
|
575
|
+
def _resolve_rds(self) -> dict:
|
|
576
|
+
"""RDS の接続情報を動的に取得"""
|
|
577
|
+
if self._rds_context is None:
|
|
578
|
+
return {}
|
|
579
|
+
# managed = false: 接続情報は context に直接入っている
|
|
580
|
+
if not self._rds_context.managed:
|
|
581
|
+
return {
|
|
582
|
+
"rds_security_group_id": self._rds_context.security_group_id,
|
|
583
|
+
"rds_secret_store": "sm",
|
|
584
|
+
"rds_secret_arn": self._rds_context.secret_arn,
|
|
585
|
+
"rds_kms_key_id": None,
|
|
586
|
+
"rds_ssm_param_name": None,
|
|
587
|
+
"rds_ssm_param_arn": None,
|
|
588
|
+
"rds_endpoint": None,
|
|
589
|
+
"rds_port": None,
|
|
590
|
+
"rds_dbname": None,
|
|
591
|
+
}
|
|
592
|
+
from pocket_cli.resources.rds import Rds
|
|
593
|
+
|
|
594
|
+
rds = Rds(self._rds_context)
|
|
595
|
+
base = {
|
|
596
|
+
"rds_security_group_id": rds.security_group_id,
|
|
597
|
+
"rds_endpoint": rds.endpoint,
|
|
598
|
+
"rds_port": str(rds.port) if rds.port else None,
|
|
599
|
+
"rds_dbname": rds.database_name,
|
|
600
|
+
}
|
|
601
|
+
# static + secret_store=ssm: SSM パラメータを参照する (SM secret は作らない)
|
|
602
|
+
ssm_param_name = rds.static_ssm_param_name
|
|
603
|
+
if ssm_param_name is not None:
|
|
604
|
+
return {
|
|
605
|
+
**base,
|
|
606
|
+
"rds_secret_store": "ssm",
|
|
607
|
+
"rds_secret_arn": None,
|
|
608
|
+
"rds_kms_key_id": None,
|
|
609
|
+
"rds_ssm_param_name": ssm_param_name,
|
|
610
|
+
"rds_ssm_param_arn": (
|
|
611
|
+
"arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/"
|
|
612
|
+
+ ssm_param_name
|
|
613
|
+
),
|
|
614
|
+
}
|
|
615
|
+
return {
|
|
616
|
+
**base,
|
|
617
|
+
"rds_secret_store": "sm",
|
|
618
|
+
"rds_secret_arn": rds.master_user_secret_arn,
|
|
619
|
+
"rds_kms_key_id": rds.master_user_secret_kms_key_id,
|
|
620
|
+
"rds_ssm_param_name": None,
|
|
621
|
+
"rds_ssm_param_arn": None,
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
def _resolve_dsql(self) -> tuple[str | None, str | None, str | None]:
|
|
625
|
+
"""DSQL のエンドポイント、リージョン、ARN を動的に取得"""
|
|
626
|
+
if self._dsql_context is None:
|
|
627
|
+
return None, None, None
|
|
628
|
+
from pocket_cli.resources.dsql import Dsql
|
|
629
|
+
|
|
630
|
+
dsql = Dsql(self._dsql_context)
|
|
631
|
+
return dsql.endpoint, self._dsql_context.region, dsql.arn
|
|
632
|
+
|
|
633
|
+
@property
|
|
634
|
+
def name(self):
|
|
635
|
+
return f"{self.context.slug}-container"
|
|
636
|
+
|
|
637
|
+
@property
|
|
638
|
+
def capabilities(self):
|
|
639
|
+
return ["CAPABILITY_NAMED_IAM"]
|
|
640
|
+
|
|
641
|
+
@property
|
|
642
|
+
def export(self):
|
|
643
|
+
if self.context.vpc:
|
|
644
|
+
return VpcStack(self.context.vpc).export
|
|
645
|
+
return {}
|
|
646
|
+
|
|
647
|
+
def _resolve_vpc_zone_count(self) -> int:
|
|
648
|
+
assert self.context.vpc, "VPC context is required"
|
|
649
|
+
vpc_stack = VpcStack(self.context.vpc)
|
|
650
|
+
output = vpc_stack.output
|
|
651
|
+
assert output, f"VPC stack '{vpc_stack.name}' の output が取得できません"
|
|
652
|
+
prefix = vpc_stack.export["private_subnet_"]
|
|
653
|
+
count = 0
|
|
654
|
+
for i in range(1, 20):
|
|
655
|
+
if f"{prefix}{i}" in output:
|
|
656
|
+
count += 1
|
|
657
|
+
else:
|
|
658
|
+
break
|
|
659
|
+
return count
|
|
660
|
+
|
|
661
|
+
@property
|
|
662
|
+
def yaml(self) -> str:
|
|
663
|
+
rds_info = self._resolve_rds()
|
|
664
|
+
dsql_endpoint, dsql_region, dsql_cluster_arn = self._resolve_dsql()
|
|
665
|
+
context_dump = self.context.model_dump()
|
|
666
|
+
|
|
667
|
+
# 外部 VPC: zones を動的取得
|
|
668
|
+
if self.context.vpc and not self.context.vpc.manage:
|
|
669
|
+
zone_count = self._resolve_vpc_zone_count()
|
|
670
|
+
context_dump["vpc"]["zones"] = [
|
|
671
|
+
f"{self.context.vpc.region}{chr(97 + i)}" for i in range(zone_count)
|
|
672
|
+
]
|
|
673
|
+
|
|
674
|
+
template = Environment(
|
|
675
|
+
loader=PackageLoader("pocket_cli"), autoescape=select_autoescape()
|
|
676
|
+
).get_template(name=f"cloudformation/{self.template_filename}.yaml")
|
|
677
|
+
original_yaml = template.render(
|
|
678
|
+
stack_name=self.name,
|
|
679
|
+
export=self.export,
|
|
680
|
+
resource=self._get_resource(),
|
|
681
|
+
rds_security_group_id=rds_info.get("rds_security_group_id"),
|
|
682
|
+
rds_secret_store=rds_info.get("rds_secret_store"),
|
|
683
|
+
rds_secret_arn=rds_info.get("rds_secret_arn"),
|
|
684
|
+
rds_kms_key_id=rds_info.get("rds_kms_key_id"),
|
|
685
|
+
rds_ssm_param_name=rds_info.get("rds_ssm_param_name"),
|
|
686
|
+
rds_ssm_param_arn=rds_info.get("rds_ssm_param_arn"),
|
|
687
|
+
rds_endpoint=rds_info.get("rds_endpoint"),
|
|
688
|
+
rds_port=rds_info.get("rds_port"),
|
|
689
|
+
rds_dbname=rds_info.get("rds_dbname"),
|
|
690
|
+
use_rds=bool(rds_info),
|
|
691
|
+
dsql_endpoint=dsql_endpoint,
|
|
692
|
+
dsql_region=dsql_region,
|
|
693
|
+
dsql_cluster_arn=dsql_cluster_arn,
|
|
694
|
+
use_dsql=dsql_endpoint is not None,
|
|
695
|
+
scheduler=(
|
|
696
|
+
self._scheduler_context.model_dump()
|
|
697
|
+
if self._scheduler_context
|
|
698
|
+
else None
|
|
699
|
+
),
|
|
700
|
+
**context_dump,
|
|
701
|
+
)
|
|
702
|
+
return "\n".join(
|
|
703
|
+
[
|
|
704
|
+
line
|
|
705
|
+
for line in original_yaml.splitlines()
|
|
706
|
+
if line.strip() not in ["#", "# prettier-ignore"]
|
|
707
|
+
]
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
class VpcStack(Stack):
|
|
712
|
+
context: VpcContext
|
|
713
|
+
template_filename = "vpc"
|
|
714
|
+
|
|
715
|
+
def _get_resource(self):
|
|
716
|
+
from pocket_cli.resources.vpc import Vpc
|
|
717
|
+
|
|
718
|
+
return Vpc(self.context)
|
|
719
|
+
|
|
720
|
+
@property
|
|
721
|
+
def name(self):
|
|
722
|
+
return f"{self.context.name}-vpc"
|
|
723
|
+
|
|
724
|
+
@property
|
|
725
|
+
def export(self):
|
|
726
|
+
return {
|
|
727
|
+
"vpc_id": self.context.name + "-vpc-id",
|
|
728
|
+
"private_subnet_": self.context.name + "-private-subnet-",
|
|
729
|
+
"efs_access_point_arn": self.context.name + "-efs-access-point",
|
|
730
|
+
"efs_security_group": self.context.name + "-efs-security-group",
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
@property
|
|
734
|
+
def stack_tags(self) -> list[dict]:
|
|
735
|
+
tags: list[dict[str, str]] = []
|
|
736
|
+
if self.context.sharable:
|
|
737
|
+
tags.append({"Key": "pocket:sharable", "Value": "true"})
|
|
738
|
+
return tags
|
|
739
|
+
|
|
740
|
+
@cached_property
|
|
741
|
+
def tags(self) -> list[dict]:
|
|
742
|
+
if self.description:
|
|
743
|
+
return self.description.get("Tags", [])
|
|
744
|
+
return []
|
|
745
|
+
|
|
746
|
+
@cached_property
|
|
747
|
+
def stack_arn(self) -> str | None:
|
|
748
|
+
if self.description:
|
|
749
|
+
return self.description["StackId"] # type: ignore
|
|
750
|
+
return None
|
|
751
|
+
|
|
752
|
+
def get_tag(self, key: str) -> str | None:
|
|
753
|
+
for tag in self.tags:
|
|
754
|
+
if tag["Key"] == key:
|
|
755
|
+
return tag["Value"] # type: ignore
|
|
756
|
+
return None
|
|
757
|
+
|
|
758
|
+
@property
|
|
759
|
+
def is_sharable(self) -> bool:
|
|
760
|
+
return self.get_tag("pocket:sharable") == "true"
|
|
761
|
+
|
|
762
|
+
@property
|
|
763
|
+
def consumers(self) -> list[str]:
|
|
764
|
+
return [
|
|
765
|
+
t["Key"].removeprefix("pocket:consumer:")
|
|
766
|
+
for t in self.tags
|
|
767
|
+
if t["Key"].startswith("pocket:consumer:")
|
|
768
|
+
]
|
|
769
|
+
|
|
770
|
+
def add_consumer_tag(self, slug: str):
|
|
771
|
+
if not self.stack_arn:
|
|
772
|
+
return
|
|
773
|
+
tagging = boto3.client(
|
|
774
|
+
"resourcegroupstaggingapi", region_name=self.context.region
|
|
775
|
+
)
|
|
776
|
+
tagging.tag_resources(
|
|
777
|
+
ResourceARNList=[self.stack_arn],
|
|
778
|
+
Tags={f"pocket:consumer:{slug}": "deployed"},
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
def remove_consumer_tag(self, slug: str):
|
|
782
|
+
if not self.stack_arn:
|
|
783
|
+
return
|
|
784
|
+
tagging = boto3.client(
|
|
785
|
+
"resourcegroupstaggingapi", region_name=self.context.region
|
|
786
|
+
)
|
|
787
|
+
tagging.untag_resources(
|
|
788
|
+
ResourceARNList=[self.stack_arn],
|
|
789
|
+
TagKeys=[f"pocket:consumer:{slug}"],
|
|
790
|
+
)
|