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,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)
|