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,145 @@
1
+ from __future__ import annotations
2
+
3
+ from functools import cached_property
4
+ from typing import TYPE_CHECKING
5
+
6
+ import boto3
7
+ from pydantic import BaseModel, Field, computed_field
8
+
9
+ if TYPE_CHECKING:
10
+ from pocket_cli.resources.aws.builders import Builder
11
+
12
+
13
+ class RepositoryDetail(BaseModel):
14
+ uri: str | None = Field(alias="repositoryUri", default=None)
15
+ arn: str | None = Field(alias="repositoryArn", default=None)
16
+
17
+
18
+ class ImageDetail(BaseModel):
19
+ image_digest: str | None = Field(alias="imageDigest", default=None)
20
+
21
+ @computed_field
22
+ def hash(self) -> str | None:
23
+ if self.image_digest:
24
+ alg, hash = self.image_digest.split(":")
25
+ if alg == "sha256":
26
+ return hash
27
+ raise ValueError("unsupported algorithm")
28
+
29
+
30
+ class Ecr:
31
+ def __init__(
32
+ self,
33
+ region_name: str,
34
+ name: str,
35
+ tag: str,
36
+ dockerfile_path: str,
37
+ platform: str,
38
+ builder: Builder | None = None,
39
+ ):
40
+ self.client = boto3.client("ecr", region_name=region_name)
41
+ self.name = name
42
+ self.tag = tag
43
+ self.dockerfile_path = dockerfile_path
44
+ self.platform = platform
45
+ self._builder = builder
46
+
47
+ @cached_property
48
+ def info(self) -> RepositoryDetail:
49
+ for repository in self.client.describe_repositories()["repositories"]:
50
+ if repository["repositoryName"] == self.name:
51
+ return RepositoryDetail(**repository)
52
+ return RepositoryDetail()
53
+
54
+ @property
55
+ def uri(self):
56
+ return self.info.uri
57
+
58
+ @property
59
+ def arn(self):
60
+ return self.info.arn
61
+
62
+ @property
63
+ def target(self):
64
+ if self.uri:
65
+ return self.uri + ":" + self.tag
66
+
67
+ @property
68
+ def image_detail(self):
69
+ data = self.client.describe_images(repositoryName=self.name)
70
+ for detail in data["imageDetails"]:
71
+ if image_tags := detail.get("imageTags"):
72
+ if self.tag in image_tags:
73
+ return ImageDetail(**detail)
74
+ return ImageDetail()
75
+
76
+ def create(self):
77
+ print("Creating repository ...")
78
+ print(" %s" % self.name)
79
+ self.client.create_repository(repositoryName=self.name)
80
+ if hasattr(self, "info"):
81
+ del self.info
82
+ print(" %s" % self.uri)
83
+
84
+ def ensure_exists(self):
85
+ if not self.info.uri:
86
+ self.create()
87
+
88
+ def build_and_push(self, tag: str | None = None):
89
+ if self.uri is None:
90
+ raise ValueError("target is not defined")
91
+ # tag 省略時は self.tag (= stage) を使う。build once 用に commit hash 等を
92
+ # 焼きたい場合は tag を明示する。
93
+ target = self.uri + ":" + (tag or self.tag)
94
+ if self._builder is None:
95
+ raise ValueError("builder is not configured")
96
+ self._builder.build_and_push(
97
+ target=target,
98
+ dockerfile_path=self.dockerfile_path,
99
+ platform=self.platform,
100
+ )
101
+
102
+ def retag(self, source_tag: str, dest_tag: str):
103
+ """source_tag の image に dest_tag を付与する (タグの付け替え。build しない)。
104
+
105
+ build once の昇格用: `:<commit hash>` の image へ `:<stage>` タグを移す。
106
+ source_tag の image が存在しない場合は ValueError。
107
+ """
108
+ try:
109
+ images = self.client.batch_get_image(
110
+ repositoryName=self.name,
111
+ imageIds=[{"imageTag": source_tag}],
112
+ )["images"]
113
+ except self.client.exceptions.RepositoryNotFoundException:
114
+ raise ValueError(
115
+ "ECR repository '%s' が存在しません。"
116
+ "先に `pocket django build` を実行してください。" % self.name
117
+ )
118
+ if not images:
119
+ raise ValueError(
120
+ "image :%s が ECR repository '%s' に存在しません。"
121
+ "先に `pocket django build` を実行してください。"
122
+ % (source_tag, self.name)
123
+ )
124
+ try:
125
+ self.client.put_image(
126
+ repositoryName=self.name,
127
+ imageManifest=images[0]["imageManifest"],
128
+ imageTag=dest_tag,
129
+ )
130
+ except self.client.exceptions.ImageAlreadyExistsException:
131
+ pass # dest_tag が既に同じ digest を指している (再昇格の冪等性)
132
+
133
+ def exists(self) -> bool:
134
+ return self.info.uri is not None
135
+
136
+ def delete(self):
137
+ if not self.exists():
138
+ return
139
+ self.client.delete_repository(repositoryName=self.name, force=True)
140
+ if hasattr(self, "info"):
141
+ del self.info
142
+
143
+ def sync(self):
144
+ self.ensure_exists()
145
+ self.build_and_push()
@@ -0,0 +1,138 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ import uuid
5
+ from functools import cached_property
6
+ from typing import TYPE_CHECKING
7
+
8
+ import boto3
9
+
10
+ from pocket.resources.base import ResourceStatus
11
+
12
+ if TYPE_CHECKING:
13
+ from pocket.general_context import EfsContext
14
+
15
+
16
+ class Efs:
17
+ context: EfsContext
18
+
19
+ def __init__(self, context: EfsContext) -> None:
20
+ self.context = context
21
+ self.client = boto3.client("efs", region_name=context.region)
22
+
23
+ @cached_property
24
+ def description(self):
25
+ for fs in self._iter_all_efs():
26
+ for tag in fs["Tags"]:
27
+ if tag["Key"] == "Name" and tag["Value"] == self.context.name:
28
+ return fs
29
+ return None
30
+
31
+ @cached_property
32
+ def lifecycle_policies(self):
33
+ res = self.client.describe_lifecycle_configuration(
34
+ FileSystemId=self.filesystem_id
35
+ )
36
+ return res["LifecyclePolicies"]
37
+
38
+ @property
39
+ def lifecycle_policies_should_be(self):
40
+ return [
41
+ {
42
+ "TransitionToIA": "AFTER_30_DAYS",
43
+ },
44
+ {
45
+ "TransitionToPrimaryStorageClass": "AFTER_1_ACCESS",
46
+ },
47
+ ]
48
+
49
+ @property
50
+ def filesystem_id(self):
51
+ if self.description:
52
+ return self.description["FileSystemId"]
53
+
54
+ def clear_status(self):
55
+ if hasattr(self, "description"):
56
+ del self.description
57
+ if hasattr(self, "lifecycle_policies"):
58
+ del self.lifecycle_policies
59
+
60
+ def wait_status(self, status: ResourceStatus, timeout=60):
61
+ max_iter = 100
62
+ interval = 3
63
+ if (timeout < 0) or ((max_iter * interval) < timeout):
64
+ raise Exception("timeout value is out of range")
65
+ for i in range(max_iter):
66
+ self.clear_status()
67
+ if self.status == status:
68
+ print("")
69
+ return
70
+ if i == 0:
71
+ print("Waiting for efs status to be %s" % status, end="", flush=True)
72
+ print(".", end="", flush=True)
73
+ time.sleep(interval)
74
+
75
+ def create(self):
76
+ res = self.client.create_file_system(
77
+ CreationToken=str(uuid.uuid4()),
78
+ Encrypted=True,
79
+ Backup=True,
80
+ Tags=[
81
+ {
82
+ "Key": "Name",
83
+ "Value": self.context.name,
84
+ },
85
+ ],
86
+ )
87
+ filesystem_id = res["FileSystemId"]
88
+ print(res)
89
+ print(filesystem_id)
90
+ self.wait_status("REQUIRE_UPDATE")
91
+ self.ensure_lifecycle_policies()
92
+
93
+ def update(self):
94
+ self.client.put_lifecycle_configuration(
95
+ FileSystemId=self.filesystem_id,
96
+ LifecyclePolicies=self.lifecycle_policies_should_be,
97
+ )
98
+
99
+ def ensure_lifecycle_policies(self):
100
+ if self.lifecycle_policies != self.lifecycle_policies_should_be:
101
+ self.update()
102
+
103
+ def delete(self):
104
+ self.client.delete_file_system(FileSystemId=self.filesystem_id)
105
+
106
+ def _iter_all_efs(self):
107
+ res = self.client.describe_file_systems()
108
+ if res.get("NextMarker"):
109
+ raise Exception("Your efs number is over 100. Please implement here.")
110
+ return res["FileSystems"]
111
+
112
+ def exists(self):
113
+ if self.description:
114
+ return True
115
+ return False
116
+
117
+ def ensure_exists(self):
118
+ if self.exists():
119
+ return
120
+ self.create()
121
+
122
+ @property
123
+ def status(self) -> ResourceStatus:
124
+ if not self.exists():
125
+ return "NOEXIST"
126
+ assert self.description
127
+ if self.description["LifeCycleState"] != "available":
128
+ return "PROGRESS"
129
+ if self.lifecycle_policies != [
130
+ {
131
+ "TransitionToIA": "AFTER_30_DAYS",
132
+ },
133
+ {
134
+ "TransitionToPrimaryStorageClass": "AFTER_1_ACCESS",
135
+ },
136
+ ]:
137
+ return "REQUIRE_UPDATE"
138
+ return "COMPLETED"
@@ -0,0 +1,182 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+ import time
5
+ from email.utils import parsedate_to_datetime
6
+ from functools import cached_property
7
+ from typing import TYPE_CHECKING
8
+
9
+ import boto3
10
+ from botocore.exceptions import ClientError
11
+ from pydantic import BaseModel, Field
12
+
13
+ from pocket.resources.base import ResourceStatus
14
+
15
+ if TYPE_CHECKING:
16
+ from pocket.context import LambdaHandlerContext
17
+
18
+
19
+ class Configuration(BaseModel):
20
+ hash: str | None = Field(alias="CodeSha256", default=None)
21
+ last_update_status: str | None = Field(alias="LastUpdateStatus", default=None)
22
+
23
+
24
+ class LambdaHandler:
25
+ context: LambdaHandlerContext
26
+
27
+ def __init__(self, context: LambdaHandlerContext) -> None:
28
+ self.context = context
29
+ self.client = boto3.client("lambda", region_name=context.region)
30
+ self.logs_client = boto3.client("logs", region_name=self.context.region)
31
+
32
+ @property
33
+ def name(self):
34
+ return self.context.function_name
35
+
36
+ @cached_property
37
+ def configuration(self):
38
+ try:
39
+ data = self.client.get_function(FunctionName=self.name)["Configuration"]
40
+ return Configuration(**data)
41
+ except ClientError:
42
+ return Configuration()
43
+
44
+ def refresh(self):
45
+ try:
46
+ del self.configuration
47
+ except AttributeError:
48
+ pass
49
+
50
+ @property
51
+ def status(self) -> ResourceStatus:
52
+ match self.configuration.last_update_status:
53
+ case None:
54
+ return "NOEXIST"
55
+ case "InProgress":
56
+ return "PROGRESS"
57
+ case "Failed":
58
+ return "FAILED"
59
+ case "Successful":
60
+ return "COMPLETED"
61
+ case _:
62
+ raise Exception("unexpected status")
63
+
64
+ @property
65
+ def queueurl(self) -> str | None:
66
+ if self.context.sqs:
67
+ res = boto3.client("sqs").get_queue_url(QueueName=self.context.sqs.name)
68
+ return res["QueueUrl"]
69
+
70
+ def update(self, image_uri, wait=False):
71
+ res = self.client.update_function_code(
72
+ FunctionName=self.name, ImageUri=image_uri
73
+ )
74
+ print(f"lambda function {self.name} was updated.")
75
+ show_keys = ["FunctionName", "Timeout", "MemorySize", "Version", "Environment"]
76
+ for key, value in res.items():
77
+ if key in show_keys:
78
+ print(f" - {key}: {value}")
79
+ if wait:
80
+ self.wait_update()
81
+
82
+ def wait_update(self, interval=3, limit=60):
83
+ print(f"waiting lambda fanction {self.name} update.", end="", flush=True)
84
+ for _i in range(limit // interval):
85
+ time.sleep(interval)
86
+ self.refresh()
87
+ if self.status == "PROGRESS":
88
+ print(".", end="", flush=True)
89
+ else:
90
+ print(f"\nlambda function {self.name} was updated.")
91
+ break
92
+ else:
93
+ raise Exception("Lambda couldn't stop updating. Please check.")
94
+
95
+ def invoke(self, payload: str):
96
+ return self.client.invoke(
97
+ FunctionName=self.name, InvocationType="Event", Payload=payload
98
+ )
99
+
100
+ def show_logs(self, invoke_http_response, timeout_seconds=120):
101
+ res = invoke_http_response
102
+ request_id = res["ResponseMetadata"]["RequestId"]
103
+ created_at_rfc1123 = res["ResponseMetadata"]["HTTPHeaders"]["date"]
104
+ created_at = parsedate_to_datetime(created_at_rfc1123)
105
+ print("lambda request_id:", request_id)
106
+ print("lambda created_at:", created_at)
107
+ start_pattern = '"START RequestId: %s"' % request_id
108
+ report_prefix = "REPORT RequestId: %s" % request_id
109
+ events = self._find_events(start_pattern, created_at)
110
+ print("Log stream found: %s" % events[0]["logStreamName"])
111
+ printed = []
112
+ sleep_seconds = 5
113
+ for _i in range(timeout_seconds // sleep_seconds):
114
+ res = self.logs_client.filter_log_events(
115
+ logGroupName=self.context.log_group_name,
116
+ logStreamNames=[events[0]["logStreamName"]],
117
+ startTime=events[0]["timestamp"],
118
+ )
119
+ messages = [event["message"] for event in res["events"]]
120
+ if messages[: len(printed)] != printed:
121
+ raise Exception("log stream changed")
122
+ for message in messages[len(printed) :]:
123
+ print(message.strip())
124
+ printed.append(message)
125
+ time.sleep(0.05)
126
+ if message.startswith(report_prefix):
127
+ return
128
+ time.sleep(sleep_seconds)
129
+ print("Timeout %s seconds. Please check logs in cloudwatch." % timeout_seconds)
130
+
131
+ def _get_recent_log_stream_names(self, limit: int):
132
+ try:
133
+ res = self.logs_client.describe_log_streams(
134
+ logGroupName=self.context.log_group_name,
135
+ orderBy="LastEventTime",
136
+ descending=True,
137
+ limit=limit,
138
+ )
139
+ except self.logs_client.exceptions.ResourceNotFoundException:
140
+ return []
141
+ return [s["logStreamName"] for s in res["logStreams"]]
142
+
143
+ def _find_events(
144
+ self,
145
+ filter_pattern: str,
146
+ created_at: datetime.datetime,
147
+ log_stream_names: list[str] | None = None,
148
+ log_stream_limit: int = 3,
149
+ ):
150
+ for i in range(20):
151
+ if i != 0:
152
+ msg = "Waiting for log stream." if i == 1 else "."
153
+ print(msg, end="", flush=True)
154
+ time.sleep(3)
155
+ target_log_stream_names = (
156
+ log_stream_names or self._get_recent_log_stream_names(log_stream_limit)
157
+ )
158
+ if not target_log_stream_names:
159
+ continue
160
+ kwargs = {
161
+ "logGroupName": self.context.log_group_name,
162
+ "logStreamNames": target_log_stream_names,
163
+ "filterPattern": filter_pattern,
164
+ "startTime": int(created_at.timestamp() * 1000),
165
+ }
166
+ events = []
167
+ for _j in range(10):
168
+ res = self.logs_client.filter_log_events(**kwargs)
169
+ if res["events"]:
170
+ events += res["events"]
171
+ if "nextToken" in res:
172
+ kwargs["nextToken"] = res["nextToken"]
173
+ else:
174
+ break
175
+ if events:
176
+ return events
177
+ print(
178
+ "Searched %s log stream below..." % len(target_log_stream_names) # pyright: ignore
179
+ )
180
+ for s in target_log_stream_names: # pyright: ignore
181
+ print(" - %s" % s)
182
+ raise Exception("log stream not found")
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from botocore.exceptions import ClientError
4
+
5
+
6
+ def bucket_exists(client, bucket_name: str) -> bool:
7
+ """バケットの存在確認。404以外のエラーはClientErrorとして再送出"""
8
+ try:
9
+ client.head_bucket(Bucket=bucket_name)
10
+ return True
11
+ except ClientError as e:
12
+ if e.response.get("Error", {}).get("Code") == "404":
13
+ return False
14
+ raise
15
+
16
+
17
+ def create_bucket(client, bucket_name: str, region: str):
18
+ """リージョンを考慮してバケットを作成"""
19
+ if region == "us-east-1":
20
+ client.create_bucket(Bucket=bucket_name)
21
+ else:
22
+ client.create_bucket(
23
+ Bucket=bucket_name,
24
+ CreateBucketConfiguration={"LocationConstraint": region},
25
+ )
26
+
27
+
28
+ def empty_bucket(client, bucket_name: str):
29
+ """バケット内の全オブジェクト(バージョン含む)を削除"""
30
+ # 通常オブジェクトの削除
31
+ paginator = client.get_paginator("list_objects_v2")
32
+ for page in paginator.paginate(Bucket=bucket_name):
33
+ objects = page.get("Contents", [])
34
+ if not objects:
35
+ continue
36
+ delete_keys = [{"Key": obj["Key"]} for obj in objects]
37
+ client.delete_objects(Bucket=bucket_name, Delete={"Objects": delete_keys})
38
+
39
+ # バージョニングが有効な場合のバージョン・DeleteMarker削除
40
+ paginator = client.get_paginator("list_object_versions")
41
+ for page in paginator.paginate(Bucket=bucket_name):
42
+ delete_keys = []
43
+ for version in page.get("Versions", []):
44
+ delete_keys.append(
45
+ {"Key": version["Key"], "VersionId": version["VersionId"]}
46
+ )
47
+ for marker in page.get("DeleteMarkers", []):
48
+ delete_keys.append({"Key": marker["Key"], "VersionId": marker["VersionId"]})
49
+ if delete_keys:
50
+ client.delete_objects(Bucket=bucket_name, Delete={"Objects": delete_keys})
51
+
52
+
53
+ def delete_bucket_with_contents(client, bucket_name: str):
54
+ """バケットを中身ごと削除(存在しなければ no-op)"""
55
+ if not bucket_exists(client, bucket_name):
56
+ return
57
+ empty_bucket(client, bucket_name)
58
+ client.delete_bucket(Bucket=bucket_name)
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ import boto3
6
+ from botocore.exceptions import ClientError
7
+
8
+ from pocket.settings import _deep_merge
9
+ from pocket_cli.resources.aws.s3_utils import (
10
+ bucket_exists,
11
+ create_bucket,
12
+ delete_bucket_with_contents,
13
+ )
14
+
15
+
16
+ class StateStore:
17
+ STATE_KEY = "resources.json"
18
+
19
+ def __init__(self, bucket_name: str, region: str):
20
+ self.bucket_name = bucket_name
21
+ self.region = region
22
+ self.client = boto3.client("s3", region_name=region)
23
+ self._state: dict | None = None
24
+
25
+ def ensure_bucket(self):
26
+ """state バケットが存在しなければ作成(全公開ブロック)"""
27
+ if bucket_exists(self.client, self.bucket_name):
28
+ return
29
+ create_bucket(self.client, self.bucket_name, self.region)
30
+ self.client.put_public_access_block(
31
+ Bucket=self.bucket_name,
32
+ PublicAccessBlockConfiguration={
33
+ "BlockPublicAcls": True,
34
+ "IgnorePublicAcls": True,
35
+ "BlockPublicPolicy": True,
36
+ "RestrictPublicBuckets": True,
37
+ },
38
+ )
39
+
40
+ def load(self) -> dict:
41
+ """S3から resources.json を読み込み。存在しなければ空stateを返す"""
42
+ if self._state is not None:
43
+ return self._state
44
+ try:
45
+ response = self.client.get_object(
46
+ Bucket=self.bucket_name, Key=self.STATE_KEY
47
+ )
48
+ self._state = json.loads(response["Body"].read().decode("utf-8"))
49
+ except ClientError as e:
50
+ if e.response.get("Error", {}).get("Code") == "NoSuchKey":
51
+ self._state = {"version": 1, "resources": {}}
52
+ else:
53
+ raise
54
+ return self._state # type: ignore[return-value]
55
+
56
+ def save(self):
57
+ """現在のstateをS3に書き込み"""
58
+ state = self.load()
59
+ self.client.put_object(
60
+ Bucket=self.bucket_name,
61
+ Key=self.STATE_KEY,
62
+ Body=json.dumps(state, indent=2).encode("utf-8"),
63
+ ContentType="application/json",
64
+ )
65
+
66
+ def record(self, info: dict):
67
+ """リソース情報を deep merge して保存"""
68
+ state = self.load()
69
+ _deep_merge(state["resources"], info)
70
+ self.save()
71
+
72
+ def delete_bucket(self):
73
+ """ステートバケットを中身ごと削除"""
74
+ delete_bucket_with_contents(self.client, self.bucket_name)