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,680 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import secrets
|
|
5
|
+
import string
|
|
6
|
+
import time
|
|
7
|
+
from functools import cached_property
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
import boto3
|
|
11
|
+
from botocore.exceptions import ClientError
|
|
12
|
+
|
|
13
|
+
from pocket.resources.base import ResourceStatus
|
|
14
|
+
from pocket.utils import echo
|
|
15
|
+
from pocket_cli.resources.aws.cloudformation import VpcStack
|
|
16
|
+
from pocket_cli.resources.vpc import Vpc
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from pocket.context import RdsContext
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RdsResourceIsNotReady(Exception):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _generate_master_password(length: int = 32) -> str:
|
|
27
|
+
"""Aurora PostgreSQL マスターパスワードとして安全なランダム文字列を生成する。
|
|
28
|
+
|
|
29
|
+
マスターパスワードは ``/`` ``"`` ``@`` スペースを使えない。
|
|
30
|
+
さらにクォート事故を避けるため ``'`` ``\\`` `` ` `` も除外し、
|
|
31
|
+
URL 安全な英数字 + 限定記号から ``secrets`` で選ぶ。
|
|
32
|
+
"""
|
|
33
|
+
alphabet = string.ascii_letters + string.digits + "-_.!#%+="
|
|
34
|
+
return "".join(secrets.choice(alphabet) for _ in range(length))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Rds:
|
|
38
|
+
context: RdsContext
|
|
39
|
+
|
|
40
|
+
def __init__(self, context: RdsContext) -> None:
|
|
41
|
+
self.context = context
|
|
42
|
+
self._rds_client = boto3.client("rds", region_name=context.region)
|
|
43
|
+
self._ec2_client = boto3.client("ec2", region_name=context.region)
|
|
44
|
+
self._sm_client = boto3.client("secretsmanager", region_name=context.region)
|
|
45
|
+
self._ssm_client = boto3.client("ssm", region_name=context.region)
|
|
46
|
+
|
|
47
|
+
@cached_property
|
|
48
|
+
def cluster(self) -> dict | None:
|
|
49
|
+
try:
|
|
50
|
+
res = self._rds_client.describe_db_clusters(
|
|
51
|
+
DBClusterIdentifier=self.context.cluster_identifier
|
|
52
|
+
)
|
|
53
|
+
clusters = res["DBClusters"]
|
|
54
|
+
if clusters:
|
|
55
|
+
return clusters[0]
|
|
56
|
+
return None
|
|
57
|
+
except ClientError as e:
|
|
58
|
+
if e.response["Error"]["Code"] == "DBClusterNotFoundFault":
|
|
59
|
+
return None
|
|
60
|
+
raise
|
|
61
|
+
|
|
62
|
+
@cached_property
|
|
63
|
+
def instance(self) -> dict | None:
|
|
64
|
+
try:
|
|
65
|
+
res = self._rds_client.describe_db_instances(
|
|
66
|
+
DBInstanceIdentifier=self.context.instance_identifier
|
|
67
|
+
)
|
|
68
|
+
instances = res["DBInstances"]
|
|
69
|
+
if instances:
|
|
70
|
+
return instances[0]
|
|
71
|
+
return None
|
|
72
|
+
except ClientError as e:
|
|
73
|
+
if e.response["Error"]["Code"] == "DBInstanceNotFound":
|
|
74
|
+
return None
|
|
75
|
+
raise
|
|
76
|
+
|
|
77
|
+
@cached_property
|
|
78
|
+
def _security_group(self) -> dict | None:
|
|
79
|
+
res = self._ec2_client.describe_security_groups(
|
|
80
|
+
Filters=[
|
|
81
|
+
{"Name": "tag:Name", "Values": [self.context.security_group_name]},
|
|
82
|
+
]
|
|
83
|
+
)
|
|
84
|
+
groups = res["SecurityGroups"]
|
|
85
|
+
if groups:
|
|
86
|
+
return groups[0]
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def security_group_id(self) -> str | None:
|
|
91
|
+
if self._security_group:
|
|
92
|
+
return self._security_group["GroupId"]
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
@cached_property
|
|
96
|
+
def _static_secret(self) -> dict | None:
|
|
97
|
+
"""password_strategy = "static" で pocket が作成した認証情報 secret。"""
|
|
98
|
+
try:
|
|
99
|
+
return self._sm_client.describe_secret(
|
|
100
|
+
SecretId=self.context.credentials_secret_name
|
|
101
|
+
)
|
|
102
|
+
except ClientError as e:
|
|
103
|
+
if e.response["Error"]["Code"] == "ResourceNotFoundException":
|
|
104
|
+
return None
|
|
105
|
+
raise
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def master_user_secret_arn(self) -> str | None:
|
|
109
|
+
if self.context.password_strategy == "static":
|
|
110
|
+
# secret_store=ssm の場合は Secrets Manager に secret を作らない。
|
|
111
|
+
if self.context.secret_store != "sm":
|
|
112
|
+
return None
|
|
113
|
+
return self._static_secret["ARN"] if self._static_secret else None
|
|
114
|
+
if self.cluster and "MasterUserSecret" in self.cluster:
|
|
115
|
+
return self.cluster["MasterUserSecret"]["SecretArn"]
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def static_ssm_param_name(self) -> str | None:
|
|
120
|
+
"""static + secret_store=ssm のとき、認証情報を保存する SSM パラメータ名。"""
|
|
121
|
+
if self.context.password_strategy == "static" and (
|
|
122
|
+
self.context.secret_store == "ssm"
|
|
123
|
+
):
|
|
124
|
+
return self.context.credentials_secret_name
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def master_user_secret_kms_key_id(self) -> str | None:
|
|
129
|
+
# static は既定の aws/secretsmanager キーで暗号化するため、
|
|
130
|
+
# secretsmanager:GetSecretValue のみで復号でき KMS の明示付与は不要。
|
|
131
|
+
if self.context.password_strategy == "static":
|
|
132
|
+
return None
|
|
133
|
+
if self.cluster and "MasterUserSecret" in self.cluster:
|
|
134
|
+
return self.cluster["MasterUserSecret"].get("KmsKeyId")
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def endpoint(self) -> str | None:
|
|
139
|
+
if self.cluster:
|
|
140
|
+
return self.cluster.get("Endpoint")
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def port(self) -> int | None:
|
|
145
|
+
if self.cluster:
|
|
146
|
+
return self.cluster.get("Port")
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def database_name(self) -> str:
|
|
151
|
+
return self.context.database_name
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def status(self) -> ResourceStatus:
|
|
155
|
+
if not self.context.managed:
|
|
156
|
+
return "COMPLETED"
|
|
157
|
+
if self.cluster is None:
|
|
158
|
+
return "NOEXIST"
|
|
159
|
+
cluster_status = self.cluster["Status"]
|
|
160
|
+
if cluster_status in ("creating", "modifying", "deleting"):
|
|
161
|
+
return "PROGRESS"
|
|
162
|
+
if cluster_status == "available":
|
|
163
|
+
if not self._scaling_config_matches():
|
|
164
|
+
return "REQUIRE_UPDATE"
|
|
165
|
+
if not self._password_state_matches():
|
|
166
|
+
return "REQUIRE_UPDATE"
|
|
167
|
+
return "COMPLETED"
|
|
168
|
+
return "FAILED"
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def description(self):
|
|
172
|
+
if not self.context.managed:
|
|
173
|
+
return "Reference existing RDS (secret_arn: %s)" % self.context.secret_arn
|
|
174
|
+
return (
|
|
175
|
+
"Create Aurora PostgreSQL Serverless v2 cluster: %s"
|
|
176
|
+
% self.context.cluster_identifier
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def _scaling_config_matches(self) -> bool:
|
|
180
|
+
if not self.cluster:
|
|
181
|
+
return False
|
|
182
|
+
config = self.cluster.get("ServerlessV2ScalingConfiguration", {})
|
|
183
|
+
return (
|
|
184
|
+
config.get("MinCapacity") == self.context.min_capacity
|
|
185
|
+
and config.get("MaxCapacity") == self.context.max_capacity
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def _actual_is_managed(self) -> bool:
|
|
189
|
+
"""クラスタが現状 ManageMasterUserPassword 管理かどうか。"""
|
|
190
|
+
return bool(self.cluster and "MasterUserSecret" in self.cluster)
|
|
191
|
+
|
|
192
|
+
def _store_has_credential(self, store: str) -> bool:
|
|
193
|
+
name = self.context.credentials_secret_name
|
|
194
|
+
if store == "ssm":
|
|
195
|
+
try:
|
|
196
|
+
self._ssm_client.get_parameter(Name=name)
|
|
197
|
+
return True
|
|
198
|
+
except ClientError as e:
|
|
199
|
+
if e.response["Error"]["Code"] == "ParameterNotFound":
|
|
200
|
+
return False
|
|
201
|
+
raise
|
|
202
|
+
return self._static_secret is not None
|
|
203
|
+
|
|
204
|
+
def _password_state_matches(self) -> bool:
|
|
205
|
+
"""クラスタの現状が望む password_strategy / secret_store と一致するか。
|
|
206
|
+
|
|
207
|
+
不一致なら status が REQUIRE_UPDATE を返し、update() が移行する。
|
|
208
|
+
"""
|
|
209
|
+
if self.context.password_strategy == "aws-managed":
|
|
210
|
+
return self._actual_is_managed()
|
|
211
|
+
# 望む = static
|
|
212
|
+
if self._actual_is_managed():
|
|
213
|
+
return False # aws-managed → static の移行が必要
|
|
214
|
+
# クラスタは static。望む store に認証情報が在るか
|
|
215
|
+
return self._store_has_credential(self.context.secret_store)
|
|
216
|
+
|
|
217
|
+
def _get_vpc_stack(self) -> VpcStack:
|
|
218
|
+
return VpcStack(self.context.vpc)
|
|
219
|
+
|
|
220
|
+
def _get_vpc_subnet_ids(self) -> list[str]:
|
|
221
|
+
vpc_stack = self._get_vpc_stack()
|
|
222
|
+
output = vpc_stack.output
|
|
223
|
+
export = vpc_stack.export
|
|
224
|
+
assert output, "VPC stack output is not available"
|
|
225
|
+
prefix = export["private_subnet_"]
|
|
226
|
+
subnet_ids = []
|
|
227
|
+
for i in range(1, 20):
|
|
228
|
+
key = f"{prefix}{i}"
|
|
229
|
+
if key in output:
|
|
230
|
+
subnet_ids.append(output[key])
|
|
231
|
+
else:
|
|
232
|
+
break
|
|
233
|
+
assert subnet_ids, "No private subnets found in VPC stack"
|
|
234
|
+
return subnet_ids
|
|
235
|
+
|
|
236
|
+
def _get_vpc_id(self) -> str:
|
|
237
|
+
vpc_stack = self._get_vpc_stack()
|
|
238
|
+
output = vpc_stack.output
|
|
239
|
+
export = vpc_stack.export
|
|
240
|
+
assert output, "VPC stack output is not available"
|
|
241
|
+
return output[export["vpc_id"]]
|
|
242
|
+
|
|
243
|
+
def state_info(self):
|
|
244
|
+
if not self.context.managed:
|
|
245
|
+
return {
|
|
246
|
+
"rds": {
|
|
247
|
+
"managed": False,
|
|
248
|
+
"secret_arn": self.context.secret_arn,
|
|
249
|
+
"security_group_id": self.context.security_group_id,
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
"rds": {
|
|
254
|
+
"managed": True,
|
|
255
|
+
"cluster_identifier": self.context.cluster_identifier,
|
|
256
|
+
"security_group_id": self.security_group_id,
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
def deploy_init(self):
|
|
261
|
+
if not self.context.managed:
|
|
262
|
+
return
|
|
263
|
+
assert self.context.vpc
|
|
264
|
+
vpc_stack = Vpc(self.context.vpc).stack
|
|
265
|
+
if not self.context.vpc.manage:
|
|
266
|
+
if vpc_stack.status == "NOEXIST":
|
|
267
|
+
raise ValueError(
|
|
268
|
+
f"外部 VPC スタック '{vpc_stack.name}' が見つかりません。"
|
|
269
|
+
)
|
|
270
|
+
# managed VPC の COMPLETED 待ちは deploy_resources で行う
|
|
271
|
+
# (deploy_init 時点ではまだ VPC が作成されていない場合がある)
|
|
272
|
+
|
|
273
|
+
def create(self):
|
|
274
|
+
# VPC スタックの完了を待つ
|
|
275
|
+
if self.context.vpc.manage:
|
|
276
|
+
Vpc(self.context.vpc).stack.wait_status("COMPLETED")
|
|
277
|
+
subnet_ids = self._get_vpc_subnet_ids()
|
|
278
|
+
vpc_id = self._get_vpc_id()
|
|
279
|
+
|
|
280
|
+
# 1. DB Subnet Group
|
|
281
|
+
echo.log("Creating DB Subnet Group: %s" % self.context.subnet_group_name)
|
|
282
|
+
self._rds_client.create_db_subnet_group(
|
|
283
|
+
DBSubnetGroupName=self.context.subnet_group_name,
|
|
284
|
+
DBSubnetGroupDescription="Aurora subnet group for %s"
|
|
285
|
+
% self.context.cluster_identifier,
|
|
286
|
+
SubnetIds=subnet_ids,
|
|
287
|
+
Tags=[{"Key": "Name", "Value": self.context.subnet_group_name}],
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
# 2. Security Group
|
|
291
|
+
echo.log("Creating Security Group: %s" % self.context.security_group_name)
|
|
292
|
+
sg_res = self._ec2_client.create_security_group(
|
|
293
|
+
GroupName=self.context.security_group_name,
|
|
294
|
+
Description="RDS Aurora security group for %s"
|
|
295
|
+
% self.context.cluster_identifier,
|
|
296
|
+
VpcId=vpc_id,
|
|
297
|
+
TagSpecifications=[
|
|
298
|
+
{
|
|
299
|
+
"ResourceType": "security-group",
|
|
300
|
+
"Tags": [
|
|
301
|
+
{"Key": "Name", "Value": self.context.security_group_name}
|
|
302
|
+
],
|
|
303
|
+
}
|
|
304
|
+
],
|
|
305
|
+
)
|
|
306
|
+
sg_id = sg_res["GroupId"]
|
|
307
|
+
|
|
308
|
+
static = self.context.password_strategy == "static"
|
|
309
|
+
# static の場合、ここで設定した平文パスワードを後段で secret に保存する。
|
|
310
|
+
password: str | None = None
|
|
311
|
+
|
|
312
|
+
# 3. Aurora クラスター作成 (snapshot_identifier があれば復元)
|
|
313
|
+
if self.context.snapshot_identifier:
|
|
314
|
+
echo.log(
|
|
315
|
+
"Restoring Aurora cluster %s from snapshot %s"
|
|
316
|
+
% (
|
|
317
|
+
self.context.cluster_identifier,
|
|
318
|
+
self.context.snapshot_identifier,
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
self._rds_client.restore_db_cluster_from_snapshot(
|
|
322
|
+
DBClusterIdentifier=self.context.cluster_identifier,
|
|
323
|
+
SnapshotIdentifier=self.context.snapshot_identifier,
|
|
324
|
+
Engine="aurora-postgresql",
|
|
325
|
+
EngineMode="provisioned",
|
|
326
|
+
DatabaseName=self.context.database_name,
|
|
327
|
+
DBSubnetGroupName=self.context.subnet_group_name,
|
|
328
|
+
VpcSecurityGroupIds=[sg_id],
|
|
329
|
+
ServerlessV2ScalingConfiguration={
|
|
330
|
+
"MinCapacity": self.context.min_capacity,
|
|
331
|
+
"MaxCapacity": self.context.max_capacity,
|
|
332
|
+
},
|
|
333
|
+
Tags=[{"Key": "Name", "Value": self.context.cluster_identifier}],
|
|
334
|
+
)
|
|
335
|
+
else:
|
|
336
|
+
echo.log("Creating Aurora cluster: %s" % self.context.cluster_identifier)
|
|
337
|
+
password_kwargs: dict = {}
|
|
338
|
+
if static:
|
|
339
|
+
password = _generate_master_password()
|
|
340
|
+
password_kwargs["MasterUserPassword"] = password
|
|
341
|
+
else:
|
|
342
|
+
password_kwargs["ManageMasterUserPassword"] = True
|
|
343
|
+
self._rds_client.create_db_cluster(
|
|
344
|
+
DBClusterIdentifier=self.context.cluster_identifier,
|
|
345
|
+
Engine="aurora-postgresql",
|
|
346
|
+
EngineMode="provisioned",
|
|
347
|
+
DatabaseName=self.context.database_name,
|
|
348
|
+
MasterUsername=self.context.master_username,
|
|
349
|
+
DBSubnetGroupName=self.context.subnet_group_name,
|
|
350
|
+
VpcSecurityGroupIds=[sg_id],
|
|
351
|
+
ServerlessV2ScalingConfiguration={
|
|
352
|
+
"MinCapacity": self.context.min_capacity,
|
|
353
|
+
"MaxCapacity": self.context.max_capacity,
|
|
354
|
+
},
|
|
355
|
+
Tags=[{"Key": "Name", "Value": self.context.cluster_identifier}],
|
|
356
|
+
**password_kwargs,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
# 4. Aurora インスタンス作成
|
|
360
|
+
echo.log("Creating Aurora instance: %s" % self.context.instance_identifier)
|
|
361
|
+
self._rds_client.create_db_instance(
|
|
362
|
+
DBInstanceIdentifier=self.context.instance_identifier,
|
|
363
|
+
DBClusterIdentifier=self.context.cluster_identifier,
|
|
364
|
+
DBInstanceClass="db.serverless",
|
|
365
|
+
Engine="aurora-postgresql",
|
|
366
|
+
Tags=[{"Key": "Name", "Value": self.context.instance_identifier}],
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
# 5. クラスター available を待機(最大30分)
|
|
370
|
+
echo.log("Waiting for Aurora cluster to become available...")
|
|
371
|
+
self._wait_cluster_available(timeout=1800)
|
|
372
|
+
|
|
373
|
+
# 6. snapshot から復元した場合、マスターパスワードを設定し直す。
|
|
374
|
+
# (RestoreDBClusterFromSnapshot は snapshot の元パスワードを引き継ぐため、
|
|
375
|
+
# pocket が参照できる認証情報を改めて確立する必要がある)
|
|
376
|
+
if self.context.snapshot_identifier:
|
|
377
|
+
if static:
|
|
378
|
+
echo.log("Setting a pocket-managed static master password...")
|
|
379
|
+
password = _generate_master_password()
|
|
380
|
+
self._rds_client.modify_db_cluster(
|
|
381
|
+
DBClusterIdentifier=self.context.cluster_identifier,
|
|
382
|
+
MasterUserPassword=password,
|
|
383
|
+
ApplyImmediately=True,
|
|
384
|
+
)
|
|
385
|
+
else:
|
|
386
|
+
echo.log(
|
|
387
|
+
"Switching master password to AWS-managed secret "
|
|
388
|
+
"(ManageMasterUserPassword=True)..."
|
|
389
|
+
)
|
|
390
|
+
self._rds_client.modify_db_cluster(
|
|
391
|
+
DBClusterIdentifier=self.context.cluster_identifier,
|
|
392
|
+
ManageMasterUserPassword=True,
|
|
393
|
+
ApplyImmediately=True,
|
|
394
|
+
)
|
|
395
|
+
self._wait_cluster_available(timeout=600)
|
|
396
|
+
|
|
397
|
+
# 7. static: 生成したパスワードを pocket 所有の secret に保存する。
|
|
398
|
+
# この secret を MasterUserSecret 相当として Lambda へ渡すため、ローテーション
|
|
399
|
+
# 用 Lambda は付けない (= 自動ローテーションしない)。
|
|
400
|
+
if static:
|
|
401
|
+
assert password is not None
|
|
402
|
+
self._store_static_credentials(password)
|
|
403
|
+
|
|
404
|
+
echo.success("Aurora cluster is now available.")
|
|
405
|
+
|
|
406
|
+
def _store_static_credentials(self, password: str) -> None:
|
|
407
|
+
"""static パスワードと接続情報を pocket 所有の store に保存する。
|
|
408
|
+
|
|
409
|
+
保存先は awscontainer.secrets.store のトグル (sm / ssm) に従う。
|
|
410
|
+
MasterUserSecret (ManageMasterUserPassword) と同じ username/password 形に
|
|
411
|
+
host/port/dbname も加えるため、Lambda 側は環境変数フォールバック無しでも
|
|
412
|
+
DATABASE_URL を組み立てられる。ローテーション用 Lambda は付けない。
|
|
413
|
+
"""
|
|
414
|
+
self.clear_cache() # endpoint/port を最新のクラスタ情報から取得する
|
|
415
|
+
secret_string = json.dumps(
|
|
416
|
+
{
|
|
417
|
+
"username": self.context.master_username,
|
|
418
|
+
"password": password,
|
|
419
|
+
"host": self.endpoint,
|
|
420
|
+
"port": self.port,
|
|
421
|
+
"dbname": self.database_name,
|
|
422
|
+
"engine": "postgres",
|
|
423
|
+
"dbClusterIdentifier": self.context.cluster_identifier,
|
|
424
|
+
}
|
|
425
|
+
)
|
|
426
|
+
self._write_credential_to_store(self.context.secret_store, secret_string)
|
|
427
|
+
self.clear_cache()
|
|
428
|
+
|
|
429
|
+
def _write_credential_to_store(self, store: str, secret_string: str) -> None:
|
|
430
|
+
name = self.context.credentials_secret_name
|
|
431
|
+
if store == "ssm":
|
|
432
|
+
self._ssm_client.put_parameter(
|
|
433
|
+
Name=name, Value=secret_string, Type="SecureString", Overwrite=True
|
|
434
|
+
)
|
|
435
|
+
echo.success("Stored static DB credentials in SSM parameter: %s" % name)
|
|
436
|
+
return
|
|
437
|
+
try:
|
|
438
|
+
self._sm_client.create_secret(
|
|
439
|
+
Name=name,
|
|
440
|
+
SecretString=secret_string,
|
|
441
|
+
Tags=[{"Key": "Name", "Value": name}],
|
|
442
|
+
)
|
|
443
|
+
echo.success("Stored static DB credentials secret: %s" % name)
|
|
444
|
+
except ClientError as e:
|
|
445
|
+
if e.response["Error"]["Code"] == "ResourceExistsException":
|
|
446
|
+
self._sm_client.put_secret_value(
|
|
447
|
+
SecretId=name, SecretString=secret_string
|
|
448
|
+
)
|
|
449
|
+
echo.success("Updated static DB credentials secret: %s" % name)
|
|
450
|
+
else:
|
|
451
|
+
raise
|
|
452
|
+
|
|
453
|
+
def _read_credential_from_store(self, store: str) -> str | None:
|
|
454
|
+
name = self.context.credentials_secret_name
|
|
455
|
+
try:
|
|
456
|
+
if store == "ssm":
|
|
457
|
+
res = self._ssm_client.get_parameter(Name=name, WithDecryption=True)
|
|
458
|
+
return res["Parameter"]["Value"]
|
|
459
|
+
return self._sm_client.get_secret_value(SecretId=name)["SecretString"]
|
|
460
|
+
except ClientError as e:
|
|
461
|
+
not_found = ("ParameterNotFound", "ResourceNotFoundException")
|
|
462
|
+
if e.response["Error"]["Code"] in not_found:
|
|
463
|
+
return None
|
|
464
|
+
raise
|
|
465
|
+
|
|
466
|
+
def _delete_credential_from_store(self, store: str) -> None:
|
|
467
|
+
name = self.context.credentials_secret_name
|
|
468
|
+
try:
|
|
469
|
+
if store == "ssm":
|
|
470
|
+
self._ssm_client.delete_parameter(Name=name)
|
|
471
|
+
else:
|
|
472
|
+
self._sm_client.delete_secret(
|
|
473
|
+
SecretId=name, ForceDeleteWithoutRecovery=True
|
|
474
|
+
)
|
|
475
|
+
except ClientError as e:
|
|
476
|
+
not_found = ("ParameterNotFound", "ResourceNotFoundException")
|
|
477
|
+
if e.response["Error"]["Code"] not in not_found:
|
|
478
|
+
raise
|
|
479
|
+
|
|
480
|
+
def update(self):
|
|
481
|
+
self.clear_cache()
|
|
482
|
+
scaling_changed = not self._scaling_config_matches()
|
|
483
|
+
if scaling_changed:
|
|
484
|
+
echo.log("Updating RDS scaling configuration...")
|
|
485
|
+
self._rds_client.modify_db_cluster(
|
|
486
|
+
DBClusterIdentifier=self.context.cluster_identifier,
|
|
487
|
+
ServerlessV2ScalingConfiguration={
|
|
488
|
+
"MinCapacity": self.context.min_capacity,
|
|
489
|
+
"MaxCapacity": self.context.max_capacity,
|
|
490
|
+
},
|
|
491
|
+
)
|
|
492
|
+
echo.success("RDS scaling configuration updated.")
|
|
493
|
+
if not self._password_state_matches():
|
|
494
|
+
if scaling_changed:
|
|
495
|
+
self._wait_cluster_available(timeout=600)
|
|
496
|
+
self._migrate_password()
|
|
497
|
+
|
|
498
|
+
def _migrate_password(self) -> None:
|
|
499
|
+
"""クラスタを望む password_strategy / secret_store に合わせて移行する。"""
|
|
500
|
+
if self.context.password_strategy == "aws-managed":
|
|
501
|
+
self._migrate_to_managed()
|
|
502
|
+
else:
|
|
503
|
+
self._migrate_to_static()
|
|
504
|
+
|
|
505
|
+
def _migrate_to_managed(self) -> None:
|
|
506
|
+
echo.log("Migrating master password to AWS-managed...")
|
|
507
|
+
self._rds_client.modify_db_cluster(
|
|
508
|
+
DBClusterIdentifier=self.context.cluster_identifier,
|
|
509
|
+
ManageMasterUserPassword=True,
|
|
510
|
+
ApplyImmediately=True,
|
|
511
|
+
)
|
|
512
|
+
self._wait_cluster_available(timeout=600)
|
|
513
|
+
# pocket 所有の認証情報は不要になるので両 store から除去
|
|
514
|
+
for store in ("sm", "ssm"):
|
|
515
|
+
self._delete_credential_from_store(store)
|
|
516
|
+
self.clear_cache()
|
|
517
|
+
echo.success("Master password is now AWS-managed.")
|
|
518
|
+
|
|
519
|
+
def _migrate_to_static(self) -> None:
|
|
520
|
+
if self._actual_is_managed():
|
|
521
|
+
# aws-managed → static: managed を切り、既知パスワードを設定。
|
|
522
|
+
# (Manage=False + MasterUserPassword 指定で RDS が managed secret を削除)
|
|
523
|
+
echo.log("Migrating master password to pocket-managed static...")
|
|
524
|
+
password = _generate_master_password()
|
|
525
|
+
self._rds_client.modify_db_cluster(
|
|
526
|
+
DBClusterIdentifier=self.context.cluster_identifier,
|
|
527
|
+
ManageMasterUserPassword=False,
|
|
528
|
+
MasterUserPassword=password,
|
|
529
|
+
ApplyImmediately=True,
|
|
530
|
+
)
|
|
531
|
+
self._wait_cluster_available(timeout=600)
|
|
532
|
+
self._store_static_credentials(password)
|
|
533
|
+
echo.success("Master password is now pocket-managed (static).")
|
|
534
|
+
return
|
|
535
|
+
# クラスタは既に static。store 変更 (sm⇄ssm) を試みる (パスワード変更なし)。
|
|
536
|
+
other = "sm" if self.context.secret_store == "ssm" else "ssm"
|
|
537
|
+
existing = self._read_credential_from_store(other)
|
|
538
|
+
if existing is not None:
|
|
539
|
+
echo.log(
|
|
540
|
+
"Moving static DB credentials to %s store..."
|
|
541
|
+
% (self.context.secret_store)
|
|
542
|
+
)
|
|
543
|
+
self._write_credential_to_store(self.context.secret_store, existing)
|
|
544
|
+
self._delete_credential_from_store(other)
|
|
545
|
+
self.clear_cache()
|
|
546
|
+
return
|
|
547
|
+
# どの store にも認証情報が無い: パスワードを再設定して保存し直す。
|
|
548
|
+
echo.warning(
|
|
549
|
+
"Static credential not found in any store; resetting master password."
|
|
550
|
+
)
|
|
551
|
+
password = _generate_master_password()
|
|
552
|
+
self._rds_client.modify_db_cluster(
|
|
553
|
+
DBClusterIdentifier=self.context.cluster_identifier,
|
|
554
|
+
MasterUserPassword=password,
|
|
555
|
+
ApplyImmediately=True,
|
|
556
|
+
)
|
|
557
|
+
self._wait_cluster_available(timeout=600)
|
|
558
|
+
self._store_static_credentials(password)
|
|
559
|
+
|
|
560
|
+
def delete(self):
|
|
561
|
+
# 1. インスタンス削除
|
|
562
|
+
if self.instance:
|
|
563
|
+
echo.log("Deleting Aurora instance: %s" % self.context.instance_identifier)
|
|
564
|
+
self._rds_client.delete_db_instance(
|
|
565
|
+
DBInstanceIdentifier=self.context.instance_identifier,
|
|
566
|
+
SkipFinalSnapshot=True,
|
|
567
|
+
)
|
|
568
|
+
self._wait_instance_deleted(timeout=600)
|
|
569
|
+
echo.success("Aurora instance deleted.")
|
|
570
|
+
|
|
571
|
+
# 2. クラスター削除(FinalSnapshot 付き)
|
|
572
|
+
if self.cluster:
|
|
573
|
+
snapshot_id = "%s-final-%s" % (
|
|
574
|
+
self.context.cluster_identifier,
|
|
575
|
+
int(time.time()),
|
|
576
|
+
)
|
|
577
|
+
echo.log("Deleting Aurora cluster: %s" % self.context.cluster_identifier)
|
|
578
|
+
self._rds_client.delete_db_cluster(
|
|
579
|
+
DBClusterIdentifier=self.context.cluster_identifier,
|
|
580
|
+
SkipFinalSnapshot=False,
|
|
581
|
+
FinalDBSnapshotIdentifier=snapshot_id,
|
|
582
|
+
)
|
|
583
|
+
self._wait_cluster_deleted(timeout=600)
|
|
584
|
+
echo.success("Aurora cluster deleted. Final snapshot: %s" % snapshot_id)
|
|
585
|
+
|
|
586
|
+
# 3. Security Group 削除
|
|
587
|
+
if self.security_group_id:
|
|
588
|
+
echo.log("Deleting Security Group: %s" % self.context.security_group_name)
|
|
589
|
+
self._ec2_client.delete_security_group(GroupId=self.security_group_id)
|
|
590
|
+
echo.success("Security Group deleted.")
|
|
591
|
+
|
|
592
|
+
# 4. Subnet Group 削除
|
|
593
|
+
try:
|
|
594
|
+
echo.log("Deleting DB Subnet Group: %s" % self.context.subnet_group_name)
|
|
595
|
+
self._rds_client.delete_db_subnet_group(
|
|
596
|
+
DBSubnetGroupName=self.context.subnet_group_name
|
|
597
|
+
)
|
|
598
|
+
echo.success("DB Subnet Group deleted.")
|
|
599
|
+
except ClientError as e:
|
|
600
|
+
if e.response["Error"]["Code"] != "DBSubnetGroupNotFoundFault":
|
|
601
|
+
raise
|
|
602
|
+
|
|
603
|
+
# 5. static: pocket 所有の認証情報を store から削除
|
|
604
|
+
if self.context.password_strategy == "static":
|
|
605
|
+
self._delete_static_credentials()
|
|
606
|
+
|
|
607
|
+
def _delete_static_credentials(self) -> None:
|
|
608
|
+
"""static の認証情報を保存先 store (sm / ssm) から削除する。"""
|
|
609
|
+
name = self.context.credentials_secret_name
|
|
610
|
+
echo.log(
|
|
611
|
+
"Deleting static DB credentials (%s): %s"
|
|
612
|
+
% (
|
|
613
|
+
self.context.secret_store,
|
|
614
|
+
name,
|
|
615
|
+
)
|
|
616
|
+
)
|
|
617
|
+
self._delete_credential_from_store(self.context.secret_store)
|
|
618
|
+
echo.success("Static DB credentials deleted.")
|
|
619
|
+
|
|
620
|
+
def _wait_cluster_available(self, timeout: int = 1800, interval: int = 10):
|
|
621
|
+
for i in range(timeout // interval):
|
|
622
|
+
try:
|
|
623
|
+
res = self._rds_client.describe_db_clusters(
|
|
624
|
+
DBClusterIdentifier=self.context.cluster_identifier
|
|
625
|
+
)
|
|
626
|
+
status = res["DBClusters"][0]["Status"]
|
|
627
|
+
if status == "available":
|
|
628
|
+
print("")
|
|
629
|
+
return
|
|
630
|
+
except ClientError:
|
|
631
|
+
pass
|
|
632
|
+
if i == 0:
|
|
633
|
+
print("Waiting for cluster to be available", end="", flush=True)
|
|
634
|
+
print(".", end="", flush=True)
|
|
635
|
+
time.sleep(interval)
|
|
636
|
+
raise TimeoutError(
|
|
637
|
+
"Cluster did not become available within %s seconds" % timeout
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
def _wait_instance_deleted(self, timeout: int = 600, interval: int = 10):
|
|
641
|
+
for i in range(timeout // interval):
|
|
642
|
+
try:
|
|
643
|
+
self._rds_client.describe_db_instances(
|
|
644
|
+
DBInstanceIdentifier=self.context.instance_identifier
|
|
645
|
+
)
|
|
646
|
+
except ClientError as e:
|
|
647
|
+
if e.response["Error"]["Code"] == "DBInstanceNotFound":
|
|
648
|
+
print("")
|
|
649
|
+
return
|
|
650
|
+
raise
|
|
651
|
+
if i == 0:
|
|
652
|
+
print("Waiting for instance deletion", end="", flush=True)
|
|
653
|
+
print(".", end="", flush=True)
|
|
654
|
+
time.sleep(interval)
|
|
655
|
+
raise TimeoutError("Instance not deleted within %s seconds" % timeout)
|
|
656
|
+
|
|
657
|
+
def _wait_cluster_deleted(self, timeout: int = 600, interval: int = 10):
|
|
658
|
+
for i in range(timeout // interval):
|
|
659
|
+
try:
|
|
660
|
+
res = self._rds_client.describe_db_clusters(
|
|
661
|
+
DBClusterIdentifier=self.context.cluster_identifier
|
|
662
|
+
)
|
|
663
|
+
status = res["DBClusters"][0]["Status"]
|
|
664
|
+
if status == "deleting":
|
|
665
|
+
pass
|
|
666
|
+
except ClientError as e:
|
|
667
|
+
if e.response["Error"]["Code"] == "DBClusterNotFoundFault":
|
|
668
|
+
print("")
|
|
669
|
+
return
|
|
670
|
+
raise
|
|
671
|
+
if i == 0:
|
|
672
|
+
print("Waiting for cluster deletion", end="", flush=True)
|
|
673
|
+
print(".", end="", flush=True)
|
|
674
|
+
time.sleep(interval)
|
|
675
|
+
raise TimeoutError("Cluster not deleted within %s seconds" % timeout)
|
|
676
|
+
|
|
677
|
+
def clear_cache(self):
|
|
678
|
+
for attr in ("cluster", "instance", "_security_group", "_static_secret"):
|
|
679
|
+
if attr in self.__dict__:
|
|
680
|
+
del self.__dict__[attr]
|