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,491 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import mimetypes
5
+ import subprocess
6
+ import time
7
+ from functools import cached_property
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, Literal
10
+
11
+ import boto3
12
+ from botocore.exceptions import ClientError
13
+ from pydantic import BaseModel
14
+
15
+ from pocket.resources.base import ResourceStatus
16
+ from pocket.utils import echo
17
+ from pocket_cli.resources.aws.cloudformation import CloudFrontStack
18
+ from pocket_cli.resources.aws.s3_utils import bucket_exists, create_bucket
19
+
20
+ if TYPE_CHECKING:
21
+ from pocket.context import CloudFrontContext, RouteContext
22
+ from pocket_cli.mediator import Mediator
23
+
24
+
25
+ class OriginAccessControl(BaseModel):
26
+ Id: str
27
+ Description: str | None = None
28
+ Name: str
29
+ SigningProtocol: Literal["sigv4"]
30
+ SigningBehavior: Literal["never", "always", "no-override"]
31
+ OriginAccessControlOriginType: Literal[
32
+ "s3", "mediastore", "mediapackagev2", "lambda"
33
+ ]
34
+
35
+
36
+ class NoOacException(Exception):
37
+ pass
38
+
39
+
40
+ class BucketOwnershipException(Exception):
41
+ pass
42
+
43
+
44
+ class CloudFront:
45
+ context: CloudFrontContext
46
+
47
+ def __init__(self, context: CloudFrontContext) -> None:
48
+ self.context = context
49
+ self.s3_client = boto3.client("s3", region_name=context.s3_region)
50
+ self.cf_client = boto3.client("cloudfront")
51
+ self._token_secret_value: str = ""
52
+
53
+ @property
54
+ def description(self):
55
+ return (
56
+ "Create cloudformation(for cloudfront) using s3 bucket: %s"
57
+ % self.context.bucket_name
58
+ )
59
+
60
+ def state_info(self):
61
+ key = "cloudfront-%s" % self.context.name
62
+ return {key: {"bucket_name": self.context.bucket_name}}
63
+
64
+ def deploy_init(self):
65
+ self.warn_contents()
66
+
67
+ @property
68
+ def status(self) -> ResourceStatus:
69
+ return self.stack.status
70
+
71
+ @property
72
+ def stack(self):
73
+ return CloudFrontStack(
74
+ self.context, token_secret_value=self._token_secret_value
75
+ )
76
+
77
+ def create(self, mediator: Mediator | None = None):
78
+ self.update(mediator=mediator)
79
+
80
+ def update(self, mediator: Mediator | None = None):
81
+ self._prepare_token_secret(mediator)
82
+ self._ensure_redirect_from()
83
+ if not self.stack.exists:
84
+ self.stack.create()
85
+ elif not self.stack.yaml_synced:
86
+ self.stack.update()
87
+ info = echo.info
88
+ log = echo.log
89
+ log("Waiting for cloudformation stack to be completed ...")
90
+ log("This may take a few minutes.")
91
+ log("Because cloudfront distribution id is required to set s3 bucket policy.")
92
+ info("If you want to exit, you can safely kill this process.")
93
+ info("In that case, run `pocket resource cloudfront update` later.")
94
+ self.stack.wait_status("COMPLETED", timeout=1800, interval=10)
95
+ self._ensure_bucket_policy()
96
+ self._write_token_secret_to_kvs()
97
+ log("Bucket for cloudfront is ready.")
98
+ self.warn_contents()
99
+
100
+ def ensure_post_deploy_state(self, mediator: Mediator | None = None):
101
+ """stack 完了後に必要な後付け状態 (bucket policy / KVS) を冪等に確保する。
102
+
103
+ update() の wait_status が timeout した場合、stack 自体はその後 COMPLETED
104
+ になっても次回 deploy で status==COMPLETED と判定され update() が呼ばれず
105
+ KVS 書き込みなどが永遠にスキップされる、という事故が起きる。これを防ぐため
106
+ deploy フローの末尾で stack 状態によらず冪等に再実行する。
107
+ """
108
+ if self.stack.status != "COMPLETED":
109
+ return
110
+ self._prepare_token_secret(mediator)
111
+ self._ensure_bucket_policy()
112
+ self._write_token_secret_to_kvs()
113
+
114
+ def _prepare_token_secret(self, mediator: Mediator | None):
115
+ if not self.context.token_secret:
116
+ return
117
+ if not mediator:
118
+ return
119
+ ac = mediator.context.awscontainer
120
+ if not ac or not ac.secrets:
121
+ return
122
+ pocket_store = ac.secrets.pocket_store
123
+ secrets = pocket_store.secrets
124
+ if self.context.token_secret in secrets:
125
+ value = secrets[self.context.token_secret]
126
+ if isinstance(value, str):
127
+ self._token_secret_value = value
128
+ else:
129
+ echo.warning(
130
+ "token_secret の値が文字列ではありません: %s"
131
+ % self.context.token_secret
132
+ )
133
+ else:
134
+ echo.warning(
135
+ "token_secret '%s' が managed secrets に見つかりません"
136
+ % self.context.token_secret
137
+ )
138
+
139
+ def _write_token_secret_to_kvs(self):
140
+ if not self._token_secret_value:
141
+ return
142
+ if not self.stack.output:
143
+ echo.warning("スタック出力が取得できません。KVS 書き込みをスキップします。")
144
+ return
145
+ kvs_arn = self.stack.output.get("TokenKvsArn")
146
+ if not kvs_arn:
147
+ echo.warning("TokenKvsArn が出力に見つかりません。")
148
+ return
149
+ kvs_client = boto3.client(
150
+ "cloudfront-keyvaluestore", region_name=self.context.region
151
+ )
152
+ desc = kvs_client.describe_key_value_store(KvsARN=kvs_arn)
153
+ etag = desc["ETag"]
154
+ kvs_client.put_key(
155
+ KvsARN=kvs_arn,
156
+ Key="token_secret",
157
+ Value=self._token_secret_value,
158
+ IfMatch=etag,
159
+ )
160
+ echo.info("KVS にトークンシークレットを書き込みました")
161
+
162
+ def upload_managed_assets(self):
163
+ """managed_assets のファイルを S3 に同期する (全件 upload + 不要削除)。"""
164
+ if not self.context.managed_assets:
165
+ return
166
+ base = Path(self.context.managed_assets)
167
+ stage_dir = base / self.context.stage
168
+ if stage_dir.is_dir():
169
+ asset_dir = stage_dir
170
+ else:
171
+ asset_dir = base / "default"
172
+ if not asset_dir.is_dir():
173
+ echo.warning("managed_assets ディレクトリが見つかりません: %s" % asset_dir)
174
+ return
175
+ bucket = self.context.bucket_name
176
+ uploaded_keys: set[str] = set()
177
+ for file in asset_dir.iterdir():
178
+ if not file.is_file():
179
+ continue
180
+ s3_key = "pocket_managed/%s" % file.name
181
+ uploaded_keys.add(s3_key)
182
+ content_type = (
183
+ mimetypes.guess_type(str(file))[0] or "application/octet-stream"
184
+ )
185
+ self.s3_client.upload_file(
186
+ str(file),
187
+ bucket,
188
+ s3_key,
189
+ ExtraArgs={"ContentType": content_type},
190
+ )
191
+ echo.log("managed_assets: s3://%s/%s" % (bucket, s3_key))
192
+ paginator = self.s3_client.get_paginator("list_objects_v2")
193
+ for page in paginator.paginate(Bucket=bucket, Prefix="pocket_managed/"):
194
+ for obj in page.get("Contents", []):
195
+ if obj["Key"] not in uploaded_keys:
196
+ self.s3_client.delete_object(Bucket=bucket, Key=obj["Key"])
197
+ echo.log("managed_assets 削除: s3://%s/%s" % (bucket, obj["Key"]))
198
+ echo.info(
199
+ "managed_assets: %d ファイルをアップロードしました" % len(uploaded_keys)
200
+ )
201
+
202
+ def upload(self, *, skip_build: bool = False):
203
+ for route in self.context.uploadable_routes:
204
+ if route.build and not skip_build:
205
+ echo.info("ビルド実行: %s" % route.build)
206
+ try:
207
+ subprocess.run(route.build, shell=True, check=True)
208
+ except subprocess.CalledProcessError as e:
209
+ echo.danger(
210
+ "build コマンドが失敗しました (exit %d): %s"
211
+ % (e.returncode, route.build)
212
+ )
213
+ echo.warning(
214
+ "deploy ホスト側で依存を入れ直してから再実行してください。"
215
+ " 例えば npm/bun の optional dependency (rolldown 等) "
216
+ "が materialize されていないと、ロックファイルにあっても"
217
+ " import 時に Cannot find module で失敗します"
218
+ " (`rm -rf node_modules && npm ci` 等で復旧)。"
219
+ )
220
+ raise
221
+ self._upload_route(route)
222
+ if self.context.uploadable_routes:
223
+ self._invalidate()
224
+
225
+ def _upload_route(self, route: RouteContext):
226
+ s3_prefix = (route.origin_path + route.path_pattern.rstrip("/*")).lstrip("/")
227
+ assert route.build_dir
228
+ local_dir = Path(route.build_dir)
229
+ uploaded_keys: set[str] = set()
230
+ for file in local_dir.rglob("*"):
231
+ if file.is_dir():
232
+ continue
233
+ relative = file.relative_to(local_dir)
234
+ s3_key = s3_prefix + "/" + str(relative)
235
+ uploaded_keys.add(s3_key)
236
+ extra_args: dict[str, str] = {
237
+ "ContentType": mimetypes.guess_type(str(file))[0]
238
+ or "application/octet-stream"
239
+ }
240
+ if route.is_spa:
241
+ if file.suffix in (".html", ".htm"):
242
+ extra_args["CacheControl"] = "no-cache, no-store"
243
+ else:
244
+ extra_args["CacheControl"] = "max-age=31536000"
245
+ self.s3_client.upload_file(
246
+ str(file),
247
+ self.context.bucket_name,
248
+ s3_key,
249
+ ExtraArgs=extra_args,
250
+ )
251
+ echo.log("アップロード: s3://%s/%s" % (self.context.bucket_name, s3_key))
252
+ self._delete_stale_objects(s3_prefix, uploaded_keys)
253
+ echo.info(
254
+ "%d ファイルをアップロードしました (prefix: %s)"
255
+ % (len(uploaded_keys), s3_prefix)
256
+ )
257
+
258
+ def _delete_stale_objects(self, prefix: str, uploaded_keys: set[str]):
259
+ paginator = self.s3_client.get_paginator("list_objects_v2")
260
+ for page in paginator.paginate(
261
+ Bucket=self.context.bucket_name, Prefix=prefix + "/"
262
+ ):
263
+ for obj in page.get("Contents", []):
264
+ if obj["Key"] not in uploaded_keys:
265
+ self.s3_client.delete_object(
266
+ Bucket=self.context.bucket_name, Key=obj["Key"]
267
+ )
268
+ echo.log(
269
+ "削除: s3://%s/%s" % (self.context.bucket_name, obj["Key"])
270
+ )
271
+
272
+ def _invalidate(self):
273
+ self.cf_client.create_invalidation(
274
+ DistributionId=self.distribution_id,
275
+ InvalidationBatch={
276
+ "Paths": {"Quantity": 1, "Items": ["/*"]},
277
+ "CallerReference": str(int(time.time())),
278
+ },
279
+ )
280
+ echo.info("CloudFront キャッシュ無効化をリクエストしました")
281
+
282
+ def warn_contents(self):
283
+ bucket = self.context.bucket_name
284
+ for route in self.context.routes:
285
+ if route.build_dir:
286
+ continue
287
+ origin = route.origin_path + route.path_pattern
288
+ echo.warning("Upload files manually to s3://%s%s" % (bucket, origin))
289
+ if route.is_spa:
290
+ echo.info("%s is a spa route." % (route.path_pattern or "default"))
291
+ echo.info("Set proper cahce headers.")
292
+ eg_cmd = "npx s3-spa-upload build %s --delete --prefix %s" % (
293
+ bucket,
294
+ origin[1:],
295
+ )
296
+ echo.info("e.g) " + eg_cmd)
297
+ elif route.versioning:
298
+ echo.info("This is a versioned route.")
299
+ echo.info(
300
+ "Just upload your files. CloudFront will set cache-control headers."
301
+ )
302
+ eg_cmd = "aws s3 sync data s3://%s%s" % (bucket, origin)
303
+ echo.info("e.g) " + eg_cmd)
304
+
305
+ def delete(self):
306
+ self._delete_redirect_from()
307
+ self._delete_bucket_policy()
308
+ echo.info("Deleting cloudformation stack for cloudfront ...")
309
+ self.stack.delete()
310
+ self.stack.wait_status("NOEXIST", timeout=900, interval=15)
311
+ echo.warning(
312
+ "S3 bucket is managed by the S3 resource: " + self.context.bucket_name
313
+ )
314
+
315
+ def _bucket_exists(self, bucket_name):
316
+ try:
317
+ return bucket_exists(self.s3_client, bucket_name)
318
+ except ClientError as e:
319
+ raise BucketOwnershipException(
320
+ "Bucket might be already used by other account. "
321
+ "You may need to change the domain."
322
+ ) from e
323
+
324
+ def _bucket_assert_empty(self, bucket_name):
325
+ res = self.s3_client.list_objects_v2(Bucket=bucket_name)
326
+ if "Contents" in res:
327
+ echo.danger("Redirect from bucket should be empty.")
328
+ raise Exception("Redirect from bucket is not empty.")
329
+
330
+ def _create_bucket(self, bucket_name, region):
331
+ create_bucket(self.s3_client, bucket_name, region)
332
+
333
+ def _ensure_redirect_from(self):
334
+ self._ensure_redirect_from_exists()
335
+ self._ensure_redirect_from_empty()
336
+ self._ensure_redirect_from_website()
337
+
338
+ def _ensure_redirect_from_website(self):
339
+ if not self.context.redirect_from:
340
+ return
341
+ assert self.context.domain, "domain is required when redirect_from is set"
342
+ for redirect_from in self.context.redirect_from:
343
+ self.s3_client.put_bucket_website(
344
+ Bucket=redirect_from.domain,
345
+ WebsiteConfiguration={
346
+ "RedirectAllRequestsTo": {
347
+ "HostName": self.context.domain,
348
+ }
349
+ },
350
+ )
351
+
352
+ def _ensure_redirect_from_exists(self):
353
+ for redirect_from in self.context.redirect_from:
354
+ if not self._bucket_exists(redirect_from.domain):
355
+ self._create_bucket(redirect_from.domain, redirect_from.region)
356
+
357
+ def _ensure_redirect_from_empty(self):
358
+ for redirect_from in self.context.redirect_from:
359
+ self._bucket_assert_empty(redirect_from.domain)
360
+
361
+ def _delete_redirect_from(self):
362
+ for redirect_from in self.context.redirect_from:
363
+ bucket = redirect_from.domain
364
+ try:
365
+ if self._bucket_exists(bucket):
366
+ self._bucket_assert_empty(bucket)
367
+ self.s3_client.delete_bucket_website(Bucket=bucket)
368
+ echo.info("Bucket website hosting for %s was deleted." % bucket)
369
+ echo.warning("Delete the bucket manually: %s" % bucket)
370
+ else:
371
+ echo.warning("Redirect from bucket does not exists.")
372
+ except BucketOwnershipException:
373
+ echo.danger("Redirect bucket might be already used by other account.")
374
+
375
+ def _delete_redirect_from_policies(self, bucket_name):
376
+ echo.danger("Delete redirect from bucket policies is implementing ...")
377
+ echo.warning("Please delete the bucket policy manually.")
378
+ echo.info("The bucket name: " + bucket_name)
379
+
380
+ def _update_origin_bucket_policy(self, policy: dict | None):
381
+ if policy is None:
382
+ echo.info("Deleting bucket policy for %s." % self.context.bucket_name)
383
+ else:
384
+ echo.info("Updating bucket policy for %s." % self.context.bucket_name)
385
+ echo.log("Current policy: %s" % self.bucket_policy)
386
+ if policy is None:
387
+ self.s3_client.delete_bucket_policy(Bucket=self.context.bucket_name)
388
+ else:
389
+ self.s3_client.put_bucket_policy(
390
+ Bucket=self.context.bucket_name,
391
+ Policy=json.dumps(policy),
392
+ )
393
+ echo.log("Updated policy: %s" % policy)
394
+ del self.bucket_policy
395
+
396
+ def _ensure_bucket_policy(self):
397
+ if self.bucket_policy is None:
398
+ self._update_origin_bucket_policy(
399
+ {
400
+ "Version": self.bucket_policy_version_should_be,
401
+ "Statement": [self.bucket_policy_statement_should_contain],
402
+ }
403
+ )
404
+ elif self.bucket_policy["Version"] != self.bucket_policy_version_should_be:
405
+ echo.warning(
406
+ "Bucket policy version %s will be upgraded to %s."
407
+ % (self.bucket_policy["Version"], self.bucket_policy_version_should_be)
408
+ )
409
+ bucket_policy_should_be = self.bucket_policy.copy()
410
+ bucket_policy_should_be["Version"] = self.bucket_policy_version_should_be
411
+ if (
412
+ self.bucket_policy_statement_should_contain
413
+ not in bucket_policy_should_be["Statement"]
414
+ ):
415
+ bucket_policy_should_be["Statement"].append(
416
+ self.bucket_policy_statement_should_contain
417
+ )
418
+ self._update_origin_bucket_policy(bucket_policy_should_be)
419
+ elif self.bucket_policy_require_update:
420
+ bucket_policy_should_be = self.bucket_policy.copy()
421
+ bucket_policy_should_be["Statement"].append(
422
+ self.bucket_policy_statement_should_contain
423
+ )
424
+ self._update_origin_bucket_policy(bucket_policy_should_be)
425
+ else:
426
+ echo.info("Bucket policy is already configured properly.")
427
+
428
+ def _delete_bucket_policy(self):
429
+ delete_target = self.bucket_policy_statement_should_contain
430
+ if self.bucket_policy is None:
431
+ echo.info("Bucket policy is already None.")
432
+ elif self.bucket_policy["Version"] != self.bucket_policy_version_should_be:
433
+ echo.warning("Bucket policy version missmatch. Check the policy manually.")
434
+ elif delete_target not in self.bucket_policy["Statement"]:
435
+ echo.warning("No policy found. Check the policy manually.")
436
+ else:
437
+ bucket_policy_should_be = self.bucket_policy.copy()
438
+ bucket_policy_should_be["Statement"].remove(delete_target)
439
+ if not bucket_policy_should_be["Statement"]:
440
+ self._update_origin_bucket_policy(None)
441
+ else:
442
+ self._update_origin_bucket_policy(bucket_policy_should_be)
443
+
444
+ @property
445
+ def bucket_policy_require_update(self):
446
+ return (self.bucket_policy is None) or (
447
+ self.bucket_policy_statement_should_contain
448
+ not in self.bucket_policy["Statement"]
449
+ )
450
+
451
+ @cached_property
452
+ def bucket_policy(self):
453
+ try:
454
+ return json.loads(
455
+ self.s3_client.get_bucket_policy(Bucket=self.context.bucket_name)[
456
+ "Policy"
457
+ ]
458
+ )
459
+ except ClientError:
460
+ return None
461
+
462
+ @cached_property
463
+ def account_id(self):
464
+ return boto3.client("sts").get_caller_identity().get("Account")
465
+
466
+ @cached_property
467
+ def distribution_id(self):
468
+ if not self.stack.output:
469
+ raise Exception("Cloudfront distribution is not created yet.")
470
+ return self.stack.output["DistributionId"]
471
+
472
+ @property
473
+ def bucket_policy_version_should_be(self):
474
+ return "2012-10-17"
475
+
476
+ @property
477
+ def bucket_policy_statement_should_contain(self):
478
+ return {
479
+ "Sid": "AllowCloudFrontReadOnly%s" % self.context.yaml_key,
480
+ "Effect": "Allow",
481
+ "Principal": {"Service": "cloudfront.amazonaws.com"},
482
+ "Action": "s3:GetObject",
483
+ "Resource": "arn:aws:s3:::%s%s/*"
484
+ % (self.context.bucket_name, self.context.bucket_policy_prefix),
485
+ "Condition": {
486
+ "StringEquals": {
487
+ "AWS:SourceArn": "arn:aws:cloudfront::%s:distribution/%s"
488
+ % (self.account_id, self.distribution_id)
489
+ }
490
+ },
491
+ }
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from pocket.resources.base import ResourceStatus
6
+ from pocket.utils import echo
7
+ from pocket_cli.resources.aws.cloudformation import AcmStack
8
+
9
+ if TYPE_CHECKING:
10
+ from pocket.context import CloudFrontContext
11
+
12
+
13
+ class CloudFrontAcm:
14
+ """us-east-1 に ACM 証明書を管理するリソース。
15
+
16
+ domain が設定されている CloudFront でのみ使用。
17
+ """
18
+
19
+ context: CloudFrontContext
20
+
21
+ def __init__(self, context: CloudFrontContext) -> None:
22
+ self.context = context
23
+
24
+ @property
25
+ def description(self):
26
+ return "Create ACM certificates in us-east-1 for: %s" % self.context.domain
27
+
28
+ def state_info(self):
29
+ key = "cloudfront-acm-%s" % self.context.name
30
+ return {key: {"domain": self.context.domain}}
31
+
32
+ def deploy_init(self):
33
+ pass
34
+
35
+ @property
36
+ def status(self) -> ResourceStatus:
37
+ return self.stack.status
38
+
39
+ @property
40
+ def stack(self):
41
+ return AcmStack(self.context)
42
+
43
+ def create(self):
44
+ echo.log("ACM 証明書を作成中(DNS 検証の完了まで数分かかります)...")
45
+ self.stack.create()
46
+ self.stack.wait_status("COMPLETED", timeout=600, interval=10)
47
+
48
+ def update(self):
49
+ self.stack.update()
50
+ self.stack.wait_status("COMPLETED", timeout=600, interval=10)
51
+
52
+ def delete(self):
53
+ echo.log("ACM スタックを削除中...")
54
+ self.stack.delete()
55
+ self.stack.wait_status("NOEXIST", timeout=300, interval=10)
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ from typing import TYPE_CHECKING
5
+
6
+ from pocket.resources.base import ResourceStatus
7
+ from pocket.utils import echo
8
+ from pocket_cli.mediator import Mediator
9
+ from pocket_cli.resources.aws.cloudformation import CloudFrontKeysStack
10
+
11
+ if TYPE_CHECKING:
12
+ from pocket.context import CloudFrontContext
13
+
14
+
15
+ class CloudFrontKeys:
16
+ context: CloudFrontContext
17
+
18
+ def __init__(self, context: CloudFrontContext) -> None:
19
+ self.context = context
20
+ self._signing_public_key_pem: str = ""
21
+
22
+ @property
23
+ def description(self):
24
+ return (
25
+ "Create CloudFront signing key resources for: %s" % self.context.signing_key
26
+ )
27
+
28
+ def state_info(self):
29
+ key = "cloudfront-keys-%s" % self.context.name
30
+ return {key: {"signing_key": self.context.signing_key}}
31
+
32
+ def deploy_init(self):
33
+ pass
34
+
35
+ @property
36
+ def status(self) -> ResourceStatus:
37
+ return self.stack.status
38
+
39
+ @property
40
+ def stack(self):
41
+ return CloudFrontKeysStack(
42
+ self.context, signing_public_key_pem=self._signing_public_key_pem
43
+ )
44
+
45
+ def create(self, mediator: Mediator):
46
+ mediator.ensure_pocket_managed_secrets()
47
+ self._prepare_signing_key(mediator)
48
+ self.stack.create()
49
+ self.stack.wait_status("COMPLETED", timeout=120, interval=5)
50
+
51
+ def update(self, mediator: Mediator):
52
+ mediator.ensure_pocket_managed_secrets()
53
+ self._prepare_signing_key(mediator)
54
+ if not self.stack.yaml_synced:
55
+ self.stack.update()
56
+ self.stack.wait_status("COMPLETED", timeout=120, interval=5)
57
+
58
+ def delete(self):
59
+ echo.info("Deleting CloudFront keys stack ...")
60
+ self.stack.delete()
61
+ self.stack.wait_status("NOEXIST", timeout=300, interval=10)
62
+
63
+ def _prepare_signing_key(self, mediator: Mediator):
64
+ if not self.context.signing_key:
65
+ return
66
+ ac = mediator.context.awscontainer
67
+ if ac is None or ac.secrets is None:
68
+ echo.warning("awscontainer.secrets is not configured.")
69
+ return
70
+ secrets = ac.secrets.pocket_store.secrets
71
+ signing_key_name = self.context.signing_key
72
+ if signing_key_name not in secrets:
73
+ echo.warning(
74
+ "signing_key '%s' not found in managed secrets. "
75
+ "Deploy the container first to generate the key." % signing_key_name
76
+ )
77
+ return
78
+ secret_data = secrets[signing_key_name]
79
+ if isinstance(secret_data, dict) and "pub" in secret_data:
80
+ pub_b64 = secret_data["pub"]
81
+ self._signing_public_key_pem = base64.b64decode(pub_b64).decode("utf-8")
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from pocket.resources.base import ResourceStatus
6
+ from pocket.utils import echo
7
+ from pocket_cli.resources.aws.cloudformation import CloudFrontWafStack
8
+
9
+ if TYPE_CHECKING:
10
+ from pocket.context import CloudFrontContext
11
+
12
+
13
+ class CloudFrontWaf:
14
+ """us-east-1 に WAFv2 IPSet + WebACL を管理するリソース。
15
+
16
+ `[cloudfront.<name>.waf]` block がある CloudFront でのみ使用。
17
+ IPSet の中身は `pocket waf ip ...` CLI で管理する (side-channel)。
18
+ """
19
+
20
+ context: CloudFrontContext
21
+
22
+ def __init__(self, context: CloudFrontContext) -> None:
23
+ self.context = context
24
+
25
+ @property
26
+ def description(self):
27
+ return "Create WAFv2 IPSet + WebACL in us-east-1 for: %s" % self.context.name
28
+
29
+ def state_info(self):
30
+ key = "cloudfront-waf-%s" % self.context.name
31
+ return {key: {"name": self.context.name}}
32
+
33
+ def deploy_init(self):
34
+ pass
35
+
36
+ @property
37
+ def status(self) -> ResourceStatus:
38
+ return self.stack.status
39
+
40
+ @property
41
+ def stack(self):
42
+ return CloudFrontWafStack(self.context)
43
+
44
+ def create(self):
45
+ echo.log("WAF (IPSet + WebACL) を作成中 (us-east-1)...")
46
+ self.stack.create()
47
+ self.stack.wait_status("COMPLETED", timeout=300, interval=10)
48
+ assert self.context.waf is not None
49
+ if self.context.waf.enable_ip_set:
50
+ echo.info(
51
+ "WAF を作成しました。`pocket waf ip add self --name %s --stage %s` "
52
+ "で自分の IP を allowlist に追加してください "
53
+ "(空の状態では deny-all になります)。"
54
+ % (self.context.name, self.context.stage)
55
+ )
56
+ else:
57
+ echo.info("WAF を作成しました (IP allowlist 無効、managed rules のみ)。")
58
+
59
+ def update(self):
60
+ if not self.stack.yaml_synced:
61
+ self.stack.update()
62
+ self.stack.wait_status("COMPLETED", timeout=300, interval=10)
63
+
64
+ def delete(self):
65
+ echo.log("WAF スタックを削除中...")
66
+ self.stack.delete()
67
+ self.stack.wait_status("NOEXIST", timeout=300, interval=10)