magic-pocket-cli 0.26.0__tar.gz → 0.28.0__tar.gz

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 (79) hide show
  1. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/PKG-INFO +2 -2
  2. magic_pocket_cli-0.28.0/pocket_cli/cli/backup_cli.py +68 -0
  3. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/cli/deploy_cli.py +5 -0
  4. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/cli/destroy_cli.py +50 -2
  5. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/cli/dsql_cli.py +7 -4
  6. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/cli/main_cli.py +2 -0
  7. magic_pocket_cli-0.28.0/pocket_cli/resources/aws/backup_common.py +84 -0
  8. magic_pocket_cli-0.28.0/pocket_cli/resources/backup.py +320 -0
  9. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/resources/dsql.py +48 -214
  10. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/resources/rds.py +13 -0
  11. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pyproject.toml +2 -2
  12. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/.gitignore +0 -0
  13. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/__init__.py +0 -0
  14. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/cli/__init__.py +0 -0
  15. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/cli/aws_auth.py +0 -0
  16. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/cli/awscontainer_cli.py +0 -0
  17. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/cli/cloudfront_cli.py +0 -0
  18. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/cli/cloudfront_keys_cli.py +0 -0
  19. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/cli/cloudfront_waf_cli.py +0 -0
  20. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/cli/image_cli.py +0 -0
  21. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/cli/interaction.py +0 -0
  22. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/cli/migrate_cli.py +0 -0
  23. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/cli/neon_cli.py +0 -0
  24. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/cli/permissions_cli.py +0 -0
  25. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/cli/rds_cli.py +0 -0
  26. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/cli/removed_flags.py +0 -0
  27. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/cli/resource_helper.py +0 -0
  28. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/cli/runtime_config_cli.py +0 -0
  29. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/cli/s3_cli.py +0 -0
  30. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/cli/status_cli.py +0 -0
  31. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/cli/store_url_helper.py +0 -0
  32. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/cli/tidb_cli.py +0 -0
  33. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/cli/upstash_cli.py +0 -0
  34. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/cli/url_helper.py +0 -0
  35. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/cli/vpc_cli.py +0 -0
  36. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/cli/waf_cli.py +0 -0
  37. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/django_cli.py +0 -0
  38. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/mediator.py +0 -0
  39. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/resources/__init__.py +0 -0
  40. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/resources/aws/__init__.py +0 -0
  41. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/resources/aws/builders/__init__.py +0 -0
  42. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/resources/aws/builders/codebuild.py +0 -0
  43. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/resources/aws/builders/context_check.py +0 -0
  44. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/resources/aws/builders/depot.py +0 -0
  45. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/resources/aws/builders/docker.py +0 -0
  46. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/resources/aws/builders/dockerignore.py +0 -0
  47. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/resources/aws/cloudformation.py +0 -0
  48. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/resources/aws/ecr.py +0 -0
  49. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/resources/aws/efs.py +0 -0
  50. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/resources/aws/lambdahandler.py +0 -0
  51. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/resources/aws/poll.py +0 -0
  52. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/resources/aws/s3_utils.py +0 -0
  53. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/resources/aws/stack_backed.py +0 -0
  54. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/resources/aws/state.py +0 -0
  55. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/resources/awscontainer.py +0 -0
  56. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/resources/cloudfront.py +0 -0
  57. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/resources/cloudfront_acm.py +0 -0
  58. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/resources/cloudfront_keys.py +0 -0
  59. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/resources/cloudfront_waf.py +0 -0
  60. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/resources/neon.py +0 -0
  61. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/resources/s3.py +0 -0
  62. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/resources/tidb.py +0 -0
  63. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/resources/upstash.py +0 -0
  64. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/resources/vpc.py +0 -0
  65. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/templates/cloudformation/awscontainer.yaml +0 -0
  66. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/templates/cloudformation/cf_function_api_host.js +0 -0
  67. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/templates/cloudformation/cf_function_basic_auth_prelude.js +0 -0
  68. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/templates/cloudformation/cf_function_host_redirect_prelude.js +0 -0
  69. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/templates/cloudformation/cf_function_spa_auth.js +0 -0
  70. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/templates/cloudformation/cf_function_spa_fallback.js +0 -0
  71. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/templates/cloudformation/cloudfront.yaml +0 -0
  72. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/templates/cloudformation/cloudfront_acm.yaml +0 -0
  73. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/templates/cloudformation/cloudfront_keys.yaml +0 -0
  74. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/templates/cloudformation/cloudfront_waf.yaml +0 -0
  75. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/templates/cloudformation/vpc.yaml +0 -0
  76. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/templates/init/django-dotenv.env +0 -0
  77. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/templates/init/django-settings.py +0 -0
  78. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/templates/init/pocket.Dockerfile +0 -0
  79. {magic_pocket_cli-0.26.0 → magic_pocket_cli-0.28.0}/pocket_cli/templates/init/pocket_simple.toml +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: magic-pocket-cli
3
- Version: 0.26.0
3
+ Version: 0.28.0
4
4
  Summary: CLI and deploy tools for magic-pocket.
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: awscrt>=0.19.0
7
7
  Requires-Dist: click>=8.1.7
8
8
  Requires-Dist: deepdiff>=6.7.1
9
9
  Requires-Dist: jinja2>=3.1.3
10
- Requires-Dist: magic-pocket<0.27,>=0.26.0
10
+ Requires-Dist: magic-pocket<0.29,>=0.28.0
11
11
  Requires-Dist: pathspec>=1.0.4
12
12
  Requires-Dist: python-on-whales>=0.68.0
13
13
  Requires-Dist: pyyaml>=6.0.1
@@ -0,0 +1,68 @@
1
+ import click
2
+
3
+ from pocket.context import Context
4
+ from pocket.utils import echo
5
+ from pocket_cli.resources.backup import Backup
6
+
7
+
8
+ def _get_backup_resource(stage: str) -> Backup:
9
+ context = Context.from_toml(stage=stage)
10
+ if context.backup is None:
11
+ raise click.ClickException(
12
+ "この stage には AWS Backup の対象 DB (dsql / managed rds) がありません。"
13
+ )
14
+ if not context.backup.declared:
15
+ raise click.ClickException(
16
+ "[backup] が宣言されていません。バックアップデータの削除には"
17
+ " [backup] の deletable = true 宣言が必要です。"
18
+ )
19
+ if not context.backup.deletable:
20
+ raise click.ClickException(
21
+ "バックアップデータ (recovery point) の削除には [backup] に"
22
+ " deletable = true の宣言が必要です。誤操作でデータを失わないための"
23
+ " ガードなので、削除する時だけ宣言することを推奨します。"
24
+ )
25
+ return Backup(context.backup, dsql_context=context.dsql, rds_context=context.rds)
26
+
27
+
28
+ @click.group()
29
+ def backup():
30
+ pass
31
+
32
+
33
+ @backup.command()
34
+ @click.option("--stage", envvar="POCKET_DEPLOY_STAGE", prompt=True)
35
+ @click.option(
36
+ "--yes", "-y", is_flag=True, default=False, help="確認プロンプトをスキップ"
37
+ )
38
+ def cleanup(stage: str, yes: bool):
39
+ """この stage のバックアップデータ (recovery point) を削除する。
40
+
41
+ [backup] deletable = true の宣言が必要。pocket 管理 vault (pocket-backup)
42
+ にある、現存する対象 DB (dsql / managed rds) の recovery point だけを消す。
43
+ 削除済み cluster の分は ARN で引けないため対象外 (AWS Backup コンソールから
44
+ 削除する)。plan (スケジュール) には触らない。
45
+ """
46
+ r = _get_backup_resource(stage)
47
+ points = r.list_recovery_points()
48
+ if points is None:
49
+ raise click.ClickException(
50
+ "recovery point を列挙する権限がありません (backup:*)。"
51
+ )
52
+ if not points:
53
+ echo.info("削除対象の recovery point はありません。")
54
+ return
55
+ echo.warning("以下の recovery point を削除します:")
56
+ for point in points:
57
+ created = point.get("CreationDate")
58
+ echo.info(
59
+ " - %s (%s)"
60
+ % (point["RecoveryPointArn"], created.isoformat() if created else "-")
61
+ )
62
+ echo.danger("この操作は取り消せません!")
63
+ if not yes:
64
+ click.confirm(
65
+ "%d 件の recovery point を削除しますか?" % len(points), abort=True
66
+ )
67
+ deleted = r.delete_recovery_points(points)
68
+ echo.success("%d 件の recovery point を削除しました。" % deleted)
@@ -10,6 +10,7 @@ from pocket_cli.cli.removed_flags import removed_skip_check_existing
10
10
  from pocket_cli.mediator import Mediator
11
11
  from pocket_cli.resources.aws.state import StateStore, create_state_store
12
12
  from pocket_cli.resources.awscontainer import AwsContainer
13
+ from pocket_cli.resources.backup import Backup
13
14
  from pocket_cli.resources.cloudfront import CloudFront
14
15
  from pocket_cli.resources.cloudfront_acm import CloudFrontAcm
15
16
  from pocket_cli.resources.cloudfront_keys import CloudFrontKeys
@@ -32,6 +33,10 @@ def _append_infra_resources(resources, context: Context, state_bucket: str):
32
33
  resources.append(Dsql(context.dsql))
33
34
  if context.rds:
34
35
  resources.append(Rds(context.rds))
36
+ if context.backup and context.backup.declared:
37
+ resources.append(
38
+ Backup(context.backup, dsql_context=context.dsql, rds_context=context.rds)
39
+ )
35
40
  for _name, cf_ctx in context.cloudfront.items():
36
41
  if cf_ctx.signing_key:
37
42
  resources.append(CloudFrontKeys(cf_ctx))
@@ -9,6 +9,7 @@ from pocket_cli.resources.aws.state import (
9
9
  create_state_store,
10
10
  )
11
11
  from pocket_cli.resources.awscontainer import AwsContainer
12
+ from pocket_cli.resources.backup import Backup
12
13
  from pocket_cli.resources.cloudfront import CloudFront
13
14
  from pocket_cli.resources.cloudfront_acm import CloudFrontAcm
14
15
  from pocket_cli.resources.cloudfront_keys import CloudFrontKeys
@@ -88,6 +89,11 @@ def _warn_command_provisioned(label: str, hint: str):
88
89
 
89
90
  def _collect_aws_database_targets(context: Context) -> list[str]:
90
91
  targets: list[str] = []
92
+ if context.backup:
93
+ for plan_name in Backup(context.backup).existing_plan_names():
94
+ targets.append(
95
+ "AWS Backup plan: %s (recovery point と vault は残ります)" % plan_name
96
+ )
91
97
  if context.dsql:
92
98
  dsql = Dsql(context.dsql)
93
99
  if dsql.status != "NOEXIST":
@@ -223,6 +229,40 @@ def _destroy_awscontainer(context: Context, with_secrets: bool):
223
229
  echo.success("Pocket managed secrets were deleted.")
224
230
 
225
231
 
232
+ def _destroy_backup(context: Context, yes: bool):
233
+ """backup plan / selection を削除し、recovery point の扱いを案内する。
234
+
235
+ plan は [backup] 宣言の有無に関わらず削除する (宣言を外した後の destroy でも
236
+ 残さない)。selection が削除済み cluster の ARN を指したまま残ると以後の
237
+ backup job が失敗し続けるため、cluster 削除より先に消す。
238
+
239
+ recovery point (バックアップデータ) は既定では消さない。deletable = true の
240
+ 宣言下で、対話の [y/N] に明示的に yes と答えた場合のみ削除する (--yes での
241
+ 一括承認では削除しない。データ削除だけは暗黙に通さない)。cluster 削除後は
242
+ ARN で引けなくなるため、この確認も cluster 削除より先に行う。
243
+ """
244
+ if not context.backup:
245
+ return
246
+ backup = Backup(context.backup, dsql_context=context.dsql, rds_context=context.rds)
247
+ backup.delete()
248
+ points = backup.list_recovery_points()
249
+ if not points: # None (権限なし) or 空
250
+ return
251
+ if context.backup.declared and context.backup.deletable and not yes:
252
+ echo.warning(
253
+ "バックアップデータ (recovery point) が %d 件あります。" % len(points)
254
+ )
255
+ if click.confirm("recovery point も削除しますか?", default=False):
256
+ backup.delete_recovery_points(points)
257
+ return
258
+ echo.warning(
259
+ "バックアップデータ (recovery point %d 件) は削除されず残ります"
260
+ " (保持期限まで課金対象)。削除するには [backup] deletable = true を"
261
+ " 宣言して `pocket backup cleanup` を実行するか、AWS Backup の"
262
+ " コンソールから削除してください。" % len(points)
263
+ )
264
+
265
+
226
266
  def _destroy_dsql(context: Context):
227
267
  """DSQL クラスターを削除"""
228
268
  if not context.dsql:
@@ -352,7 +392,12 @@ def _destroy_cloudfront_and_acm(context: Context):
352
392
  echo.success("CloudFront ACM '%s' was destroyed." % name)
353
393
 
354
394
 
355
- def _destroy_resources(context: Context, with_secrets: bool, with_state_bucket: bool):
395
+ def _destroy_resources(
396
+ context: Context,
397
+ with_secrets: bool,
398
+ with_state_bucket: bool,
399
+ yes: bool = False,
400
+ ):
356
401
  """リソースをデプロイの逆順で削除"""
357
402
  # 1. CloudFront + ACM
358
403
  _destroy_cloudfront_and_acm(context)
@@ -360,6 +405,9 @@ def _destroy_resources(context: Context, with_secrets: bool, with_state_bucket:
360
405
  # 2. AwsContainer (CFNスタック + ECR + secrets)
361
406
  _destroy_awscontainer(context, with_secrets)
362
407
 
408
+ # 2.4. backup plan(DSQL / RDS の cluster 削除より先)
409
+ _destroy_backup(context, yes)
410
+
363
411
  # 2.5. DSQL
364
412
  _destroy_dsql(context)
365
413
 
@@ -434,5 +482,5 @@ def destroy(stage: str, without_secrets: bool, with_state_bucket: bool, yes: boo
434
482
  if not yes:
435
483
  click.confirm("stage '%s' の全リソースを削除しますか?" % stage, abort=True)
436
484
 
437
- _destroy_resources(context, with_secrets, with_state_bucket)
485
+ _destroy_resources(context, with_secrets, with_state_bucket, yes=yes)
438
486
  echo.success("stage '%s' の全リソースを削除しました。" % stage)
@@ -6,10 +6,10 @@ from botocore.exceptions import ClientError
6
6
  from pocket.context import Context
7
7
  from pocket.utils import echo
8
8
  from pocket_cli.cli.resource_helper import require_configured
9
+ from pocket_cli.resources.aws.backup_common import BACKUP_ROLE_NAME, BACKUP_VAULT_NAME
9
10
  from pocket_cli.resources.dsql import (
10
- BACKUP_ROLE_NAME,
11
11
  BACKUP_TERMINAL_STATES,
12
- BACKUP_VAULT_NAME,
12
+ DEFAULT_ON_DEMAND_RETENTION_DAYS,
13
13
  Dsql,
14
14
  )
15
15
 
@@ -87,9 +87,12 @@ def endpoint(stage, format_):
87
87
  )
88
88
  @click.option(
89
89
  "--retention-days",
90
- type=int,
90
+ type=click.IntRange(min=0),
91
91
  default=None,
92
- help="recovery point の保持日数 (省略時: vault のライフサイクル既定)",
92
+ help="recovery point の保持日数"
93
+ " (省略時: [backup] の delete_after_days、宣言が無ければ %d 日。"
94
+ "0 を指定すると無期限保持だが pocket からは削除できなくなる)"
95
+ % DEFAULT_ON_DEMAND_RETENTION_DAYS,
93
96
  )
94
97
  @click.option("--watch", is_flag=True, help="バックアップ完了まで待機する")
95
98
  def backup(stage, vault, iam_role_arn, retention_days, watch):
@@ -4,6 +4,7 @@ from pocket import __version__
4
4
  from pocket_cli import django_cli
5
5
  from pocket_cli.cli import (
6
6
  awscontainer_cli,
7
+ backup_cli,
7
8
  cloudfront_cli,
8
9
  cloudfront_keys_cli,
9
10
  cloudfront_waf_cli,
@@ -80,6 +81,7 @@ main.add_command(runtime_config_cli.runtime_config)
80
81
  main.add_command(migrate_cli.migrate)
81
82
  main.add_command(permissions_cli.permissions)
82
83
  main.add_command(waf_cli.waf)
84
+ main.add_command(backup_cli.backup)
83
85
 
84
86
 
85
87
  @main.group()
@@ -0,0 +1,84 @@
1
+ """AWS Backup の前提リソース (vault / サービスロール) の共有 ensure。
2
+
3
+ 定期バックアップ (resources/backup.py の Backup) と dsql のオンデマンド
4
+ バックアップ (resources/dsql.py) の両方が使うため、どちらからも import できる
5
+ 場所に置く (backup.py は Dsql / Rds を import するので、dsql.py が backup.py を
6
+ import すると循環になる)。
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import time
13
+
14
+ from botocore.exceptions import ClientError
15
+
16
+ from pocket.utils import echo
17
+
18
+ # pocket 管理のバックアップ前提リソース。AWS Backup の Default vault /
19
+ # AWSBackupDefaultServiceRole は console 初回操作で作られるもので、API しか
20
+ # 使わないアカウントには存在しないため、pocket 側で冪等に ensure する。
21
+ # ロール名の forge- prefix は codebuild ロールの命名に合わせている
22
+ BACKUP_VAULT_NAME = "pocket-backup"
23
+ BACKUP_ROLE_NAME = "forge-pocket-backup-role"
24
+ _BACKUP_ROLE_POLICIES = (
25
+ "arn:aws:iam::aws:policy/service-role/AWSBackupServiceRolePolicyForBackup",
26
+ "arn:aws:iam::aws:policy/service-role/AWSBackupServiceRolePolicyForRestores",
27
+ )
28
+
29
+
30
+ def ensure_backup_vault(backup_client, name: str) -> None:
31
+ """pocket 管理の vault を冪等に確保する。
32
+
33
+ describe → 無ければ create の順は不可。vault が 1 つも無いアカウントでは
34
+ AWS Backup が存在しない vault への Describe に ResourceNotFoundException
35
+ ではなく AccessDeniedException を返すため「未作成」を判定できない。
36
+ CreateBackupVault は同名 vault があると AlreadyExistsException を返すので
37
+ create を先に撃って握る (既存なら noop = 冪等)。
38
+ """
39
+ try:
40
+ backup_client.create_backup_vault(BackupVaultName=name)
41
+ except ClientError as e:
42
+ if e.response["Error"]["Code"] == "AlreadyExistsException":
43
+ return
44
+ raise
45
+ echo.log("Created backup vault: %s" % name)
46
+
47
+
48
+ def ensure_backup_role(iam_client, permissions_boundary: str | None) -> str:
49
+ """AWS Backup サービスロールを冪等に ensure して ARN を返す。
50
+
51
+ codebuild の _ensure_role と同型。boundary 必須のアカウントでも作成
52
+ できるよう permissions_boundary を付与する。
53
+ """
54
+ try:
55
+ return iam_client.get_role(RoleName=BACKUP_ROLE_NAME)["Role"]["Arn"]
56
+ except ClientError as e:
57
+ if e.response["Error"]["Code"] != "NoSuchEntity":
58
+ raise
59
+ echo.log("Creating AWS Backup service role: %s" % BACKUP_ROLE_NAME)
60
+ assume_role_policy = json.dumps(
61
+ {
62
+ "Version": "2012-10-17",
63
+ "Statement": [
64
+ {
65
+ "Effect": "Allow",
66
+ "Principal": {"Service": "backup.amazonaws.com"},
67
+ "Action": "sts:AssumeRole",
68
+ }
69
+ ],
70
+ }
71
+ )
72
+ create_kwargs: dict = {
73
+ "RoleName": BACKUP_ROLE_NAME,
74
+ "AssumeRolePolicyDocument": assume_role_policy,
75
+ }
76
+ if permissions_boundary:
77
+ create_kwargs["PermissionsBoundary"] = permissions_boundary
78
+ role_arn: str = iam_client.create_role(**create_kwargs)["Role"]["Arn"]
79
+ for policy_arn in _BACKUP_ROLE_POLICIES:
80
+ iam_client.attach_role_policy(RoleName=BACKUP_ROLE_NAME, PolicyArn=policy_arn)
81
+ # IAM ロールの伝播待ち (作成直後の StartBackupJob 失敗を避ける)
82
+ echo.log("Waiting for IAM role propagation...")
83
+ time.sleep(10)
84
+ return role_arn
@@ -0,0 +1,320 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ import boto3
6
+ from botocore.exceptions import ClientError
7
+
8
+ from pocket.resources.base import ResourceStatus
9
+ from pocket.utils import echo
10
+ from pocket_cli.resources.aws.backup_common import (
11
+ BACKUP_VAULT_NAME,
12
+ ensure_backup_role,
13
+ ensure_backup_vault,
14
+ )
15
+ from pocket_cli.resources.dsql import Dsql
16
+ from pocket_cli.resources.rds import Rds
17
+
18
+ if TYPE_CHECKING:
19
+ from pocket.context import (
20
+ BackupContext,
21
+ BackupPlanContext,
22
+ DsqlContext,
23
+ RdsContext,
24
+ )
25
+
26
+ # backup plan の drift 判定で比較する項目。AWS が既定値で補完する項目
27
+ # (StartWindowMinutes / RuleId 等) を含めると毎 deploy で update が走るため、
28
+ # pocket が宣言した項目だけを見る
29
+ _PLAN_RULE_KEYS = (
30
+ "TargetBackupVaultName",
31
+ "ScheduleExpression",
32
+ "ScheduleExpressionTimezone",
33
+ )
34
+ _PLAN_LIFECYCLE_KEYS = ("MoveToColdStorageAfterDays", "DeleteAfterDays")
35
+
36
+
37
+ def _rules_by_name(rules: list[dict]) -> dict[str, dict]:
38
+ return {rule.get("RuleName", ""): rule for rule in rules}
39
+
40
+
41
+ def _plan_matches(current: dict, desired: dict) -> bool:
42
+ """rule 名で突き合わせて宣言項目を比較する (GetBackupPlan の順序に依存しない)。"""
43
+ current_rules = _rules_by_name(current.get("Rules", []))
44
+ desired_rules = _rules_by_name(desired["Rules"])
45
+ if set(current_rules) != set(desired_rules):
46
+ return False
47
+ for name, want in desired_rules.items():
48
+ cur = current_rules[name]
49
+ if any(cur.get(key) != want.get(key) for key in _PLAN_RULE_KEYS):
50
+ return False
51
+ cur_lifecycle = cur.get("Lifecycle", {})
52
+ want_lifecycle = want["Lifecycle"]
53
+ if any(
54
+ cur_lifecycle.get(key) != want_lifecycle.get(key)
55
+ for key in _PLAN_LIFECYCLE_KEYS
56
+ ):
57
+ return False
58
+ return True
59
+
60
+
61
+ class Backup:
62
+ """[backup.dsql] / [backup.rds] 宣言に沿った AWS Backup plan の管理。
63
+
64
+ エンジンごとに rule 構成が違う (rds は daily を PITR が担う / cold storage
65
+ 不可) ため、plan はエンジン別に provision する。plan 管理は cluster の
66
+ 作成後に走る必要があるため、実作業は ensure_post_deploy_state で行う
67
+ (status は常に COMPLETED)。
68
+
69
+ バックアップ「データ」(recovery point) は原則触らない。削除できるのは
70
+ [backup] deletable = true の宣言下で、利用者が明示的に確認した場合のみ
71
+ (`pocket backup cleanup` / destroy の確認プロンプト)。
72
+ """
73
+
74
+ context: BackupContext
75
+
76
+ def __init__(
77
+ self,
78
+ context: BackupContext,
79
+ dsql_context: DsqlContext | None = None,
80
+ rds_context: RdsContext | None = None,
81
+ ) -> None:
82
+ self.context = context
83
+ self._dsql_context = dsql_context
84
+ self._rds_context = rds_context
85
+ self._backup = boto3.client("backup", region_name=context.region)
86
+ self._iam = boto3.client("iam", region_name=context.region)
87
+
88
+ @property
89
+ def status(self) -> ResourceStatus:
90
+ # plan は cluster 作成後でないと selection を張れないため、作成・更新は
91
+ # ensure_post_deploy_state で冪等に行う
92
+ return "COMPLETED"
93
+
94
+ @property
95
+ def description(self):
96
+ return "Configure AWS Backup plans: %s" % ", ".join(
97
+ plan.plan_name for plan in self.context.plans
98
+ )
99
+
100
+ def deploy_init(self):
101
+ pass
102
+
103
+ def create(self):
104
+ self.ensure_post_deploy_state()
105
+
106
+ def update(self):
107
+ self.ensure_post_deploy_state()
108
+
109
+ def state_info(self):
110
+ return {
111
+ "backup": {
112
+ "plans": [plan.plan_name for plan in self.context.plans],
113
+ }
114
+ }
115
+
116
+ def _service_target_arn(self, service: str) -> str | None:
117
+ if service == "dsql" and self._dsql_context:
118
+ return Dsql(self._dsql_context).backup_target_arn
119
+ if service == "rds" and self._rds_context:
120
+ return Rds(self._rds_context).backup_target_arn
121
+ return None
122
+
123
+ def target_arns(self) -> list[str]:
124
+ """recovery point 操作の対象 ARN 一覧 (未作成 cluster は除く)。
125
+
126
+ [backup] の宣言に依らず、stage に存在する対象 DB 全部を返す
127
+ (destroy 時のデータ残存案内は宣言を外した後も出すため)。
128
+ """
129
+ arns = [self._service_target_arn(s) for s in ("dsql", "rds")]
130
+ return sorted(arn for arn in arns if arn is not None)
131
+
132
+ def ensure_post_deploy_state(self):
133
+ """宣言されたエンジンごとに plan / selection を冪等に確保する。
134
+
135
+ 未宣言なら何もしない (opt-in。宣言を外した後の既存 plan にも触らない。
136
+ plan の掃除は destroy の責務)。
137
+ """
138
+ if not self.context.plans:
139
+ return
140
+ try:
141
+ targets = [
142
+ (plan, arn)
143
+ for plan in self.context.plans
144
+ if (arn := self._service_target_arn(plan.service)) is not None
145
+ ]
146
+ if not targets:
147
+ return
148
+ ensure_backup_vault(self._backup, BACKUP_VAULT_NAME)
149
+ role_arn = ensure_backup_role(self._iam, self.context.permissions_boundary)
150
+ for plan, arn in targets:
151
+ plan_id = self._ensure_plan(plan)
152
+ self._ensure_selection(plan, plan_id, role_arn, [arn])
153
+ except ClientError as e:
154
+ if e.response["Error"]["Code"] != "AccessDeniedException":
155
+ raise
156
+ echo.warning(
157
+ "AWS Backup の設定に必要な権限が無いため、定期バックアップの"
158
+ " provisioning をスキップしました: %s" % e
159
+ )
160
+ echo.warning(
161
+ " deploy role に backup:* を付与してください"
162
+ " (`pocket permissions list` 参照)。"
163
+ )
164
+
165
+ def _plan_document(self, plan: BackupPlanContext) -> dict:
166
+ rules = []
167
+ for rule in plan.rules:
168
+ lifecycle: dict = {"DeleteAfterDays": rule.delete_after_days}
169
+ if rule.cold_storage_after_days:
170
+ lifecycle["MoveToColdStorageAfterDays"] = rule.cold_storage_after_days
171
+ rules.append(
172
+ {
173
+ "RuleName": rule.name,
174
+ "TargetBackupVaultName": BACKUP_VAULT_NAME,
175
+ "ScheduleExpression": rule.schedule_expression,
176
+ "ScheduleExpressionTimezone": self.context.timezone,
177
+ "Lifecycle": lifecycle,
178
+ }
179
+ )
180
+ return {"BackupPlanName": plan.plan_name, "Rules": rules}
181
+
182
+ def _find_plan_id(self, plan_name: str) -> str | None:
183
+ paginator = self._backup.get_paginator("list_backup_plans")
184
+ for page in paginator.paginate():
185
+ for plan in page["BackupPlansList"]:
186
+ if plan["BackupPlanName"] == plan_name:
187
+ return plan["BackupPlanId"]
188
+ return None
189
+
190
+ def existing_plan_names(self) -> list[str]:
191
+ """destroy の対象一覧表示用 (権限が無ければ「無し」扱い)。"""
192
+ try:
193
+ return [
194
+ name
195
+ for name in self.context.cleanup_plan_names
196
+ if self._find_plan_id(name) is not None
197
+ ]
198
+ except ClientError as e:
199
+ if e.response["Error"]["Code"] != "AccessDeniedException":
200
+ raise
201
+ return []
202
+
203
+ def _ensure_plan(self, plan: BackupPlanContext) -> str:
204
+ document = self._plan_document(plan)
205
+ plan_id = self._find_plan_id(plan.plan_name)
206
+ if plan_id is None:
207
+ echo.log("Creating backup plan: %s" % plan.plan_name)
208
+ return self._backup.create_backup_plan(BackupPlan=document)["BackupPlanId"]
209
+ # 宣言が変わっていれば追従する (cron / lifecycle の drift 収束)
210
+ current = self._backup.get_backup_plan(BackupPlanId=plan_id)["BackupPlan"]
211
+ if not _plan_matches(current, document):
212
+ echo.log("Updating backup plan: %s" % plan.plan_name)
213
+ self._backup.update_backup_plan(BackupPlanId=plan_id, BackupPlan=document)
214
+ return plan_id
215
+
216
+ def _ensure_selection(
217
+ self,
218
+ plan: BackupPlanContext,
219
+ plan_id: str,
220
+ role_arn: str,
221
+ arns: list[str],
222
+ ) -> None:
223
+ """対象 DB の ARN を指した selection を確保する。
224
+
225
+ selection には更新 API が無いため、対象 ARN が変わっていたら
226
+ (cluster 再作成など) 作り直す。
227
+ """
228
+ selections = self._backup.list_backup_selections(BackupPlanId=plan_id)
229
+ for item in selections["BackupSelectionsList"]:
230
+ if item["SelectionName"] != plan.plan_name:
231
+ continue
232
+ detail = self._backup.get_backup_selection(
233
+ BackupPlanId=plan_id, SelectionId=item["SelectionId"]
234
+ )
235
+ if sorted(detail["BackupSelection"].get("Resources", [])) == arns:
236
+ return
237
+ self._backup.delete_backup_selection(
238
+ BackupPlanId=plan_id, SelectionId=item["SelectionId"]
239
+ )
240
+ echo.log("Creating backup selection for %s" % plan.plan_name)
241
+ self._backup.create_backup_selection(
242
+ BackupPlanId=plan_id,
243
+ BackupSelection={
244
+ "SelectionName": plan.plan_name,
245
+ "IamRoleArn": role_arn,
246
+ "Resources": arns,
247
+ },
248
+ )
249
+
250
+ def delete(self) -> None:
251
+ """backup plan / selection を削除する。
252
+
253
+ recovery point と vault は消さない。バックアップ「設定」は stack の
254
+ 付属物だが、バックアップ「データ」は stack より長生きさせるべきで、
255
+ cluster を消した後こそ必要になりうるため。
256
+
257
+ [backup] の宣言の有無に関わらず、stage に存在しうる全 plan 名を走査する
258
+ (宣言を外した後の destroy でも plan を残さないため)。backup 権限を
259
+ 持たない deploy role でも destroy 自体は完遂させたいので、権限エラーは
260
+ 警告に留める。
261
+ """
262
+ for plan_name in self.context.cleanup_plan_names:
263
+ try:
264
+ plan_id = self._find_plan_id(plan_name)
265
+ except ClientError as e:
266
+ if e.response["Error"]["Code"] != "AccessDeniedException":
267
+ raise
268
+ echo.warning(
269
+ "backup plan を確認する権限がないため、削除をスキップしました"
270
+ " (backup:ListBackupPlans)。plan が残っている場合は手動で"
271
+ " 削除してください。"
272
+ )
273
+ return
274
+ if plan_id is None:
275
+ continue
276
+ selections = self._backup.list_backup_selections(BackupPlanId=plan_id)
277
+ for item in selections["BackupSelectionsList"]:
278
+ self._backup.delete_backup_selection(
279
+ BackupPlanId=plan_id, SelectionId=item["SelectionId"]
280
+ )
281
+ self._backup.delete_backup_plan(BackupPlanId=plan_id)
282
+ echo.log("Deleted backup plan: %s" % plan_name)
283
+
284
+ def list_recovery_points(self) -> list[dict] | None:
285
+ """対象 DB の recovery point 一覧 (pocket-backup vault 内のもの)。
286
+
287
+ 現存する cluster の ARN でしか引けない (削除済み cluster の分は対象外 =
288
+ console でしか消せない)。権限が無ければ None を返す (呼び出し側が案内を
289
+ スキップできるように、空リストと区別する)。
290
+ """
291
+ points: list[dict] = []
292
+ try:
293
+ for arn in self.target_arns():
294
+ paginator = self._backup.get_paginator(
295
+ "list_recovery_points_by_resource"
296
+ )
297
+ for page in paginator.paginate(ResourceArn=arn):
298
+ points.extend(page["RecoveryPoints"])
299
+ except ClientError as e:
300
+ if e.response["Error"]["Code"] != "AccessDeniedException":
301
+ raise
302
+ return None
303
+ # pocket 管理の vault 分だけを扱う (--vault で利用者の vault に取った
304
+ # オンデマンド分は利用者の管理物とみなし、pocket からは消さない)
305
+ return [p for p in points if p.get("BackupVaultName") == BACKUP_VAULT_NAME]
306
+
307
+ def delete_recovery_points(self, points: list[dict]) -> int:
308
+ """recovery point を削除して件数を返す。
309
+
310
+ [backup] deletable = true + 利用者の明示確認を経た経路 (cleanup CLI /
311
+ destroy の確認プロンプト) からのみ呼ぶこと。
312
+ """
313
+ for point in points:
314
+ self._backup.delete_recovery_point(
315
+ BackupVaultName=point["BackupVaultName"],
316
+ RecoveryPointArn=point["RecoveryPointArn"],
317
+ )
318
+ if points:
319
+ echo.log("Deleted %d recovery point(s)." % len(points))
320
+ return len(points)
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
- import time
5
4
  from functools import cached_property
6
5
  from typing import TYPE_CHECKING
7
6
 
@@ -15,6 +14,11 @@ from pocket.secret_store import (
15
14
  read_stored_value,
16
15
  )
17
16
  from pocket.utils import echo
17
+ from pocket_cli.resources.aws.backup_common import (
18
+ BACKUP_VAULT_NAME,
19
+ ensure_backup_role,
20
+ ensure_backup_vault,
21
+ )
18
22
  from pocket_cli.resources.aws.poll import wait_until
19
23
 
20
24
  if TYPE_CHECKING:
@@ -25,44 +29,13 @@ BACKUP_TERMINAL_STATES = {"COMPLETED", "FAILED", "ABORTED", "EXPIRED", "PARTIAL"
25
29
  # restore job の終端状態
26
30
  RESTORE_TERMINAL_STATES = {"COMPLETED", "FAILED", "ABORTED"}
27
31
 
28
- # pocket 管理のバックアップ前提リソース。AWS Backup の Default vault /
29
- # AWSBackupDefaultServiceRole は console 初回操作で作られるもので、API しか
30
- # 使わないアカウントには存在しないため、pocket 側で冪等に ensure する。
31
- # ロール名の forge- prefix は codebuild ロールの命名に合わせている
32
- BACKUP_VAULT_NAME = "pocket-backup"
33
- BACKUP_ROLE_NAME = "forge-pocket-backup-role"
34
- _BACKUP_ROLE_POLICIES = (
35
- "arn:aws:iam::aws:policy/service-role/AWSBackupServiceRolePolicyForBackup",
36
- "arn:aws:iam::aws:policy/service-role/AWSBackupServiceRolePolicyForRestores",
37
- )
38
- # backup plan の drift 判定で比較する項目。AWS が既定値で補完する項目
39
- # (StartWindowMinutes / RuleId 等) を含めると毎 deploy で update が走るため、
40
- # pocket が宣言した項目だけを見る
41
- _PLAN_RULE_KEYS = (
42
- "RuleName",
43
- "TargetBackupVaultName",
44
- "ScheduleExpression",
45
- "ScheduleExpressionTimezone",
46
- )
47
- _PLAN_LIFECYCLE_KEYS = ("MoveToColdStorageAfterDays", "DeleteAfterDays")
48
-
49
-
50
- def _plan_matches(current: dict, desired: dict) -> bool:
51
- current_rules = current.get("Rules", [])
52
- desired_rules = desired["Rules"]
53
- if len(current_rules) != len(desired_rules):
54
- return False
55
- for cur, want in zip(current_rules, desired_rules, strict=True):
56
- if any(cur.get(key) != want.get(key) for key in _PLAN_RULE_KEYS):
57
- return False
58
- cur_lifecycle = cur.get("Lifecycle", {})
59
- want_lifecycle = want["Lifecycle"]
60
- if any(
61
- cur_lifecycle.get(key) != want_lifecycle.get(key)
62
- for key in _PLAN_LIFECYCLE_KEYS
63
- ):
64
- return False
65
- return True
32
+ # オンデマンドバックアップで保持日数の指定が無く [backup] も未宣言なときの既定。
33
+ # AWS Backup の vault は既定ライフサイクルを持たないため、Lifecycle を渡さないと
34
+ # recovery point は無期限に残る。pocket にも deploy role にもデータ削除権限が無く
35
+ # (destroy も recovery point を残す)、console 作業でしか消せなくなるため、
36
+ # 「いつかは失効する」ことを既定で保証する。3 年は復元要件として十分長く、
37
+ # 意図せず消えるより増え続けるほうが困る、という判断
38
+ DEFAULT_ON_DEMAND_RETENTION_DAYS = 1095
66
39
 
67
40
 
68
41
  class Dsql:
@@ -149,7 +122,7 @@ class Dsql:
149
122
  "DSQL には自動バックアップ (PITR / スナップショット) がありません。"
150
123
  )
151
124
  echo.warning(
152
- " 定期バックアップは [dsql.backup] を宣言してください。"
125
+ " 定期バックアップは [backup.dsql] を宣言してください。"
153
126
  " 単発なら `pocket resource dsql backup` で取得できます。"
154
127
  )
155
128
 
@@ -173,9 +146,6 @@ class Dsql:
173
146
  def delete(self):
174
147
  if not self.identifier:
175
148
  return
176
- # cluster より先に plan を消す (selection が消える cluster の ARN を
177
- # 指したまま残ると、以後の backup job が失敗し続ける)
178
- self.delete_backup_plan()
179
149
  echo.log("Deleting DSQL cluster: %s" % self.identifier)
180
150
  self._client.delete_cluster(identifier=self.identifier)
181
151
  echo.log("Waiting for DSQL cluster deletion...")
@@ -186,128 +156,13 @@ class Dsql:
186
156
  def ensure_post_deploy_state(self):
187
157
  # create の有無に関わらず毎 deploy で publish を冪等に確保する。cluster
188
158
  # 再作成や旧バージョンで deploy 済みの既存 cluster も常に実体を反映する
159
+ # (定期バックアップの plan / selection は Backup resource の責務)
189
160
  self.publish_endpoint()
190
- # plan の selection は cluster ARN を指すため、cluster 作成後に ensure する
191
- self.ensure_backup_plan()
192
161
 
193
162
  @property
194
- def backup_plan_name(self) -> str:
195
- return "%s-backup" % self.context.tag_name
196
-
197
- def ensure_backup_plan(self) -> None:
198
- """[dsql.backup] 宣言に沿って backup plan / selection を冪等に確保する。
199
-
200
- DSQL は組み込みの自動バックアップを持たないため、宣言された stage では
201
- pocket が plan まで provision する (native 属性で表現できる RDS の PITR
202
- と違い、宣言だけでは何も起きないため)。未宣言なら何もしない。
203
- """
204
- backup = self.context.backup
205
- if backup is None or not self.arn:
206
- return
207
- self._ensure_backup_vault(BACKUP_VAULT_NAME)
208
- role_arn = self._ensure_backup_role()
209
- plan_id = self._ensure_plan(backup)
210
- self._ensure_selection(plan_id, role_arn)
211
-
212
- def _plan_document(self, backup) -> dict:
213
- lifecycle: dict = {"DeleteAfterDays": backup.delete_after_days}
214
- if backup.cold_storage_after_days:
215
- lifecycle["MoveToColdStorageAfterDays"] = backup.cold_storage_after_days
216
- return {
217
- "BackupPlanName": self.backup_plan_name,
218
- "Rules": [
219
- {
220
- "RuleName": "daily",
221
- "TargetBackupVaultName": BACKUP_VAULT_NAME,
222
- "ScheduleExpression": backup.schedule_expression,
223
- "ScheduleExpressionTimezone": backup.timezone,
224
- "Lifecycle": lifecycle,
225
- }
226
- ],
227
- }
228
-
229
- def _find_plan_id(self) -> str | None:
230
- paginator = self._backup.get_paginator("list_backup_plans")
231
- for page in paginator.paginate():
232
- for plan in page["BackupPlansList"]:
233
- if plan["BackupPlanName"] == self.backup_plan_name:
234
- return plan["BackupPlanId"]
235
- return None
236
-
237
- def _ensure_plan(self, backup) -> str:
238
- document = self._plan_document(backup)
239
- plan_id = self._find_plan_id()
240
- if plan_id is None:
241
- echo.log("Creating backup plan: %s" % self.backup_plan_name)
242
- return self._backup.create_backup_plan(BackupPlan=document)["BackupPlanId"]
243
- # 宣言が変わっていれば追従する (cron / lifecycle の drift 収束)
244
- current = self._backup.get_backup_plan(BackupPlanId=plan_id)["BackupPlan"]
245
- if not _plan_matches(current, document):
246
- echo.log("Updating backup plan: %s" % self.backup_plan_name)
247
- self._backup.update_backup_plan(BackupPlanId=plan_id, BackupPlan=document)
248
- return plan_id
249
-
250
- def _ensure_selection(self, plan_id: str, role_arn: str) -> None:
251
- """cluster ARN を対象にした selection を確保する。
252
-
253
- selection には更新 API が無いため、対象 ARN が変わっていたら
254
- (cluster 再作成など) 作り直す。
255
- """
256
- if not self.arn:
257
- return
258
- selections = self._backup.list_backup_selections(BackupPlanId=plan_id)
259
- for item in selections["BackupSelectionsList"]:
260
- if item["SelectionName"] != self.backup_plan_name:
261
- continue
262
- detail = self._backup.get_backup_selection(
263
- BackupPlanId=plan_id, SelectionId=item["SelectionId"]
264
- )
265
- if detail["BackupSelection"].get("Resources") == [self.arn]:
266
- return
267
- self._backup.delete_backup_selection(
268
- BackupPlanId=plan_id, SelectionId=item["SelectionId"]
269
- )
270
- echo.log("Creating backup selection for %s" % self.context.tag_name)
271
- self._backup.create_backup_selection(
272
- BackupPlanId=plan_id,
273
- BackupSelection={
274
- "SelectionName": self.backup_plan_name,
275
- "IamRoleArn": role_arn,
276
- "Resources": [self.arn],
277
- },
278
- )
279
-
280
- def delete_backup_plan(self) -> None:
281
- """backup plan / selection を削除する (cluster 削除との対称操作)。
282
-
283
- recovery point と vault は消さない。バックアップ「設定」は stack の
284
- 付属物だが、バックアップ「データ」は stack より長生きさせるべきで、
285
- cluster を消した後こそ必要になりうるため。
286
-
287
- 宣言の有無に関わらず走査する ([dsql.backup] を外した後の destroy でも
288
- plan を残さないため)。backup 権限を持たない古い deploy role でも
289
- destroy 自体は完遂させたいので、権限エラーは警告に留める。
290
- """
291
- try:
292
- plan_id = self._find_plan_id()
293
- except ClientError as e:
294
- if e.response["Error"]["Code"] != "AccessDeniedException":
295
- raise
296
- echo.warning(
297
- "backup plan を確認する権限がないため、削除をスキップしました"
298
- " (backup:ListBackupPlans)。plan が残っている場合は手動で"
299
- " 削除してください。"
300
- )
301
- return
302
- if plan_id is None:
303
- return
304
- selections = self._backup.list_backup_selections(BackupPlanId=plan_id)
305
- for item in selections["BackupSelectionsList"]:
306
- self._backup.delete_backup_selection(
307
- BackupPlanId=plan_id, SelectionId=item["SelectionId"]
308
- )
309
- self._backup.delete_backup_plan(BackupPlanId=plan_id)
310
- echo.log("Deleted backup plan: %s" % self.backup_plan_name)
163
+ def backup_target_arn(self) -> str | None:
164
+ """[backup] の selection 対象 ARN (Backup resource が参照する)。"""
165
+ return self.arn
311
166
 
312
167
  def publish_endpoint(self):
313
168
  """endpoint を stored user secret 正準パスへ publish する (作成記録)。
@@ -370,64 +225,43 @@ class Dsql:
370
225
  "ResourceArn": self.arn,
371
226
  "IamRoleArn": iam_role_arn,
372
227
  }
373
- if retention_days is not None:
374
- params["Lifecycle"] = {"DeleteAfterDays": retention_days}
228
+ resolved = self._resolve_retention_days(retention_days)
229
+ if resolved is not None:
230
+ params["Lifecycle"] = {"DeleteAfterDays": resolved}
375
231
  res = self._backup.start_backup_job(**params)
376
232
  return res["BackupJobId"]
377
233
 
378
- def _ensure_backup_vault(self, name: str) -> None:
379
- # describe → 無ければ create の順は不可。vault が 1 つも無いアカウントでは
380
- # AWS Backup が存在しない vault への Describe に ResourceNotFoundException
381
- # ではなく AccessDeniedException を返すため「未作成」を判定できない。
382
- # CreateBackupVault は同名 vault があると AlreadyExistsException を返すので
383
- # create を先に撃って握る (既存なら noop = 冪等)。
384
- try:
385
- self._backup.create_backup_vault(BackupVaultName=name)
386
- except ClientError as e:
387
- if e.response["Error"]["Code"] == "AlreadyExistsException":
388
- return
389
- raise
390
- echo.log("Created backup vault: %s" % name)
391
-
392
- def _ensure_backup_role(self) -> str:
393
- """AWS Backup サービスロールを冪等に ensure して ARN を返す。
234
+ def _resolve_retention_days(self, retention_days: int | None) -> int | None:
235
+ """オンデマンドバックアップの保持日数を決める (None を返すと無期限)。
394
236
 
395
- codebuild の _ensure_role と同型。boundary 必須のアカウントでも作成
396
- できるよう context.permissions_boundary を付与する。
237
+ 指定 > [backup.dsql] の最長階層 (monthly) > 既定 の順で解決する。
238
+ 復元時の内部バックアップ (利用者が明示的に頼んでいない recovery point)
239
+ も start_backup を通るため同じ既定が効く。0 は「無期限」の明示指定。
397
240
  """
398
- try:
399
- return self._iam.get_role(RoleName=BACKUP_ROLE_NAME)["Role"]["Arn"]
400
- except ClientError as e:
401
- if e.response["Error"]["Code"] != "NoSuchEntity":
402
- raise
403
- echo.log("Creating AWS Backup service role: %s" % BACKUP_ROLE_NAME)
404
- assume_role_policy = json.dumps(
405
- {
406
- "Version": "2012-10-17",
407
- "Statement": [
408
- {
409
- "Effect": "Allow",
410
- "Principal": {"Service": "backup.amazonaws.com"},
411
- "Action": "sts:AssumeRole",
412
- }
413
- ],
414
- }
415
- )
416
- create_kwargs: dict = {
417
- "RoleName": BACKUP_ROLE_NAME,
418
- "AssumeRolePolicyDocument": assume_role_policy,
419
- }
420
- if self.context.permissions_boundary:
421
- create_kwargs["PermissionsBoundary"] = self.context.permissions_boundary
422
- role_arn: str = self._iam.create_role(**create_kwargs)["Role"]["Arn"]
423
- for policy_arn in _BACKUP_ROLE_POLICIES:
424
- self._iam.attach_role_policy(
425
- RoleName=BACKUP_ROLE_NAME, PolicyArn=policy_arn
241
+ if retention_days == 0:
242
+ echo.warning(
243
+ "この recovery point は無期限保持です。"
244
+ "pocket からは削除できないため、消すには console 作業が必要です。"
426
245
  )
427
- # IAM ロールの伝播待ち (作成直後の StartBackupJob 失敗を避ける)
428
- echo.log("Waiting for IAM role propagation...")
429
- time.sleep(10)
430
- return role_arn
246
+ return None
247
+ if retention_days is not None:
248
+ echo.log("Retention: %d days (--retention-days)" % retention_days)
249
+ return retention_days
250
+ backup = self.context.backup
251
+ if backup is not None:
252
+ echo.log(
253
+ "Retention: %d days (from [backup.dsql] monthly)"
254
+ % backup.monthly_delete_after_days
255
+ )
256
+ return backup.monthly_delete_after_days
257
+ echo.log("Retention: %d days (default)" % DEFAULT_ON_DEMAND_RETENTION_DAYS)
258
+ return DEFAULT_ON_DEMAND_RETENTION_DAYS
259
+
260
+ def _ensure_backup_vault(self, name: str) -> None:
261
+ ensure_backup_vault(self._backup, name)
262
+
263
+ def _ensure_backup_role(self) -> str:
264
+ return ensure_backup_role(self._iam, self.context.permissions_boundary)
431
265
 
432
266
  def latest_recovery_point(self) -> dict | None:
433
267
  """現用クラスターの最新 recovery point (完了済み) を返す。"""
@@ -163,6 +163,19 @@ class Rds:
163
163
  def database_name(self) -> str:
164
164
  return self.context.database_name
165
165
 
166
+ @property
167
+ def backup_target_arn(self) -> str | None:
168
+ """[backup] の selection 対象 ARN (Backup resource が参照する)。
169
+
170
+ managed = false の既存クラスタは対象外 (他 stack の管理物にバックアップ
171
+ 設定を足さない)。
172
+ """
173
+ if not self.context.managed:
174
+ return None
175
+ if self.cluster:
176
+ return self.cluster.get("DBClusterArn")
177
+ return None
178
+
166
179
  @property
167
180
  def status(self) -> ResourceStatus:
168
181
  if not self.context.managed:
@@ -1,12 +1,12 @@
1
1
  [project]
2
2
  name = "magic-pocket-cli"
3
- version = "0.26.0"
3
+ version = "0.28.0"
4
4
  description = "CLI and deploy tools for magic-pocket."
5
5
  requires-python = ">=3.10"
6
6
  dependencies = [
7
7
  # lib は同 minor に束縛する。0.x では minor が breaking の単位のため、
8
8
  # 上限なしだと cli を pin した消費者にも lib の破壊的変更が届いてしまう
9
- "magic-pocket>=0.26.0,<0.27",
9
+ "magic-pocket>=0.28.0,<0.29",
10
10
  "click>=8.1.7",
11
11
  "jinja2>=3.1.3",
12
12
  "python-on-whales>=0.68.0",