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