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.
Files changed (65) hide show
  1. magic_pocket_cli-0.2.0.dist-info/METADATA +14 -0
  2. magic_pocket_cli-0.2.0.dist-info/RECORD +65 -0
  3. magic_pocket_cli-0.2.0.dist-info/WHEEL +4 -0
  4. magic_pocket_cli-0.2.0.dist-info/entry_points.txt +2 -0
  5. pocket_cli/__init__.py +0 -0
  6. pocket_cli/cli/__init__.py +0 -0
  7. pocket_cli/cli/aws_auth.py +48 -0
  8. pocket_cli/cli/awscontainer_cli.py +328 -0
  9. pocket_cli/cli/cloudfront_cli.py +116 -0
  10. pocket_cli/cli/cloudfront_keys_cli.py +68 -0
  11. pocket_cli/cli/cloudfront_waf_cli.py +68 -0
  12. pocket_cli/cli/deploy_cli.py +274 -0
  13. pocket_cli/cli/destroy_cli.py +358 -0
  14. pocket_cli/cli/dsql_cli.py +60 -0
  15. pocket_cli/cli/main_cli.py +91 -0
  16. pocket_cli/cli/migrate_cli.py +148 -0
  17. pocket_cli/cli/neon_cli.py +97 -0
  18. pocket_cli/cli/permissions_cli.py +46 -0
  19. pocket_cli/cli/rds_cli.py +63 -0
  20. pocket_cli/cli/runtime_config_cli.py +185 -0
  21. pocket_cli/cli/s3_cli.py +69 -0
  22. pocket_cli/cli/status_cli.py +56 -0
  23. pocket_cli/cli/tidb_cli.py +73 -0
  24. pocket_cli/cli/vpc_cli.py +92 -0
  25. pocket_cli/cli/waf_cli.py +182 -0
  26. pocket_cli/django_cli.py +412 -0
  27. pocket_cli/mediator.py +220 -0
  28. pocket_cli/resources/__init__.py +0 -0
  29. pocket_cli/resources/aws/__init__.py +0 -0
  30. pocket_cli/resources/aws/builders/__init__.py +57 -0
  31. pocket_cli/resources/aws/builders/codebuild.py +363 -0
  32. pocket_cli/resources/aws/builders/depot.py +84 -0
  33. pocket_cli/resources/aws/builders/docker.py +34 -0
  34. pocket_cli/resources/aws/builders/dockerignore.py +44 -0
  35. pocket_cli/resources/aws/cloudformation.py +790 -0
  36. pocket_cli/resources/aws/ecr.py +145 -0
  37. pocket_cli/resources/aws/efs.py +138 -0
  38. pocket_cli/resources/aws/lambdahandler.py +182 -0
  39. pocket_cli/resources/aws/s3_utils.py +58 -0
  40. pocket_cli/resources/aws/state.py +74 -0
  41. pocket_cli/resources/awscontainer.py +265 -0
  42. pocket_cli/resources/cloudfront.py +491 -0
  43. pocket_cli/resources/cloudfront_acm.py +55 -0
  44. pocket_cli/resources/cloudfront_keys.py +81 -0
  45. pocket_cli/resources/cloudfront_waf.py +67 -0
  46. pocket_cli/resources/dsql.py +142 -0
  47. pocket_cli/resources/neon.py +353 -0
  48. pocket_cli/resources/rds.py +680 -0
  49. pocket_cli/resources/s3.py +307 -0
  50. pocket_cli/resources/tidb.py +298 -0
  51. pocket_cli/resources/upstash.py +152 -0
  52. pocket_cli/resources/vpc.py +67 -0
  53. pocket_cli/templates/cloudformation/awscontainer.yaml +516 -0
  54. pocket_cli/templates/cloudformation/cf_function_api_host.js +5 -0
  55. pocket_cli/templates/cloudformation/cf_function_spa_auth.js +28 -0
  56. pocket_cli/templates/cloudformation/cf_function_spa_fallback.js +8 -0
  57. pocket_cli/templates/cloudformation/cloudfront.yaml +309 -0
  58. pocket_cli/templates/cloudformation/cloudfront_acm.yaml +43 -0
  59. pocket_cli/templates/cloudformation/cloudfront_keys.yaml +32 -0
  60. pocket_cli/templates/cloudformation/cloudfront_waf.yaml +97 -0
  61. pocket_cli/templates/cloudformation/vpc.yaml +213 -0
  62. pocket_cli/templates/init/django-dotenv.env +3 -0
  63. pocket_cli/templates/init/django-settings.py +140 -0
  64. pocket_cli/templates/init/pocket.Dockerfile +26 -0
  65. 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
+ )