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,68 @@
1
+ import click
2
+
3
+ from pocket.context import Context
4
+ from pocket.utils import echo
5
+ from pocket_cli.resources.cloudfront_waf import CloudFrontWaf
6
+
7
+
8
+ @click.group()
9
+ def cloudfront_waf():
10
+ pass
11
+
12
+
13
+ def get_cloudfront_waf_resources(stage, name=None):
14
+ context = Context.from_toml(stage=stage)
15
+ if not context.cloudfront:
16
+ echo.danger("cloudfront is not configured for this stage")
17
+ raise Exception("cloudfront is not configured for this stage")
18
+ results = []
19
+ for cf_name, cf_ctx in context.cloudfront.items():
20
+ if cf_ctx.waf is None:
21
+ continue
22
+ if name and cf_name != name:
23
+ continue
24
+ results.append(CloudFrontWaf(cf_ctx))
25
+ if name and not results:
26
+ echo.danger("cloudfront_waf '%s' is not configured" % name)
27
+ raise Exception("cloudfront_waf '%s' is not configured" % name)
28
+ return results
29
+
30
+
31
+ @cloudfront_waf.command()
32
+ @click.option("--stage", envvar="POCKET_DEPLOY_STAGE", prompt=True)
33
+ @click.option("--name", default=None)
34
+ def yaml(stage, name):
35
+ for waf in get_cloudfront_waf_resources(stage, name):
36
+ echo.info("[%s]" % waf.context.name)
37
+ print(waf.stack.yaml)
38
+
39
+
40
+ @cloudfront_waf.command()
41
+ @click.option("--stage", envvar="POCKET_DEPLOY_STAGE", prompt=True)
42
+ @click.option("--name", default=None)
43
+ def yaml_diff(stage, name):
44
+ for waf in get_cloudfront_waf_resources(stage, name):
45
+ echo.info("[%s]" % waf.context.name)
46
+ print(waf.stack.yaml_diff.to_json(indent=2))
47
+
48
+
49
+ @cloudfront_waf.command()
50
+ @click.option("--stage", envvar="POCKET_DEPLOY_STAGE", prompt=True)
51
+ @click.option("--name", default=None)
52
+ def status(stage, name):
53
+ for waf in get_cloudfront_waf_resources(stage, name):
54
+ echo.info("[%s]" % waf.context.name)
55
+ if waf.status == "COMPLETED":
56
+ echo.success("COMPLETED")
57
+ else:
58
+ print(waf.status)
59
+
60
+
61
+ @cloudfront_waf.command()
62
+ @click.option("--stage", envvar="POCKET_DEPLOY_STAGE", prompt=True)
63
+ @click.option("--name", default=None)
64
+ def destroy(stage, name):
65
+ for waf in get_cloudfront_waf_resources(stage, name):
66
+ echo.info("[%s]" % waf.context.name)
67
+ waf.delete()
68
+ echo.success("cloudfront_waf was deleted successfully.")
@@ -0,0 +1,274 @@
1
+ import inspect
2
+ import webbrowser
3
+
4
+ import click
5
+
6
+ from pocket.context import Context
7
+ from pocket.utils import echo
8
+ from pocket_cli.mediator import Mediator
9
+ from pocket_cli.resources.aws.state import StateStore
10
+ from pocket_cli.resources.awscontainer import AwsContainer
11
+ from pocket_cli.resources.cloudfront import CloudFront
12
+ from pocket_cli.resources.cloudfront_acm import CloudFrontAcm
13
+ from pocket_cli.resources.cloudfront_keys import CloudFrontKeys
14
+ from pocket_cli.resources.cloudfront_waf import CloudFrontWaf
15
+ from pocket_cli.resources.dsql import Dsql
16
+ from pocket_cli.resources.neon import Neon
17
+ from pocket_cli.resources.rds import Rds
18
+ from pocket_cli.resources.s3 import S3
19
+ from pocket_cli.resources.tidb import TiDb
20
+ from pocket_cli.resources.upstash import Upstash
21
+ from pocket_cli.resources.vpc import Vpc
22
+
23
+
24
+ def _append_infra_resources(resources, context: Context, state_bucket: str):
25
+ """VPC / RDS / CloudFrontKeys / AwsContainer をまとめて追加"""
26
+ if context.awscontainer and context.awscontainer.vpc:
27
+ if context.awscontainer.vpc.manage:
28
+ resources.append(Vpc(context.awscontainer.vpc))
29
+ if context.dsql:
30
+ resources.append(Dsql(context.dsql))
31
+ if context.rds:
32
+ resources.append(Rds(context.rds))
33
+ for _name, cf_ctx in context.cloudfront.items():
34
+ if cf_ctx.signing_key:
35
+ resources.append(CloudFrontKeys(cf_ctx))
36
+ if context.awscontainer:
37
+ resources.append(
38
+ AwsContainer(
39
+ context.awscontainer,
40
+ state_bucket=state_bucket,
41
+ rds_context=context.rds,
42
+ dsql_context=context.dsql,
43
+ scheduler_context=context.scheduler,
44
+ )
45
+ )
46
+
47
+
48
+ def get_resources(context: Context, *, state_bucket: str = ""):
49
+ resources = []
50
+ # ACM 証明書を最初にデプロイ(us-east-1、DNS 検証に時間がかかる)
51
+ for _name, cf_ctx in context.cloudfront.items():
52
+ if cf_ctx.domain:
53
+ resources.append(CloudFrontAcm(cf_ctx))
54
+ # WAF (IPSet + WebACL) も us-east-1 必須、CloudFront stack より前に作成
55
+ for _name, cf_ctx in context.cloudfront.items():
56
+ if cf_ctx.waf is not None:
57
+ resources.append(CloudFrontWaf(cf_ctx))
58
+ if context.neon:
59
+ resources.append(Neon(context.neon))
60
+ if context.tidb:
61
+ resources.append(TiDb(context.tidb))
62
+ if context.upstash:
63
+ resources.append(Upstash(context.upstash))
64
+ if context.s3:
65
+ resources.append(S3(context.s3, cloudfront_contexts=context.cloudfront))
66
+ _append_infra_resources(resources, context, state_bucket)
67
+ for _name, cf_ctx in context.cloudfront.items():
68
+ resources.append(CloudFront(cf_ctx))
69
+ return resources
70
+
71
+
72
+ def _create_state_store(context: Context) -> StateStore:
73
+ assert context.general
74
+ resource_prefix = context.general.prefix_template.format(
75
+ stage=context.stage,
76
+ project=context.project_name,
77
+ namespace=context.general.namespace,
78
+ )
79
+ bucket_name = f"{resource_prefix}state"
80
+ return StateStore(bucket_name, context.general.region)
81
+
82
+
83
+ def deploy_init_resources(context: Context, *, state_bucket: str = ""):
84
+ for resource in get_resources(context, state_bucket=state_bucket):
85
+ target_name = resource.__class__.__name__
86
+ echo.log("Deploy init %s..." % target_name)
87
+ resource.deploy_init()
88
+
89
+
90
+ def deploy_frontend(context: Context, *, skip_build: bool = False):
91
+ for _name, cf_ctx in context.cloudfront.items():
92
+ cf = CloudFront(cf_ctx)
93
+ if not cf_ctx.uploadable_routes:
94
+ continue
95
+ if cf.status == "NOEXIST":
96
+ echo.warning("CloudFront '%s' が未作成です。スキップします。" % cf_ctx.name)
97
+ continue
98
+ cf.upload(skip_build=skip_build)
99
+
100
+
101
+ def upload_managed_assets(context: Context):
102
+ """CloudFront resource ごとに managed_assets を S3 に同期する。
103
+
104
+ deploy_resources の後で呼ぶことで、CFn stack の有無に関わらず毎回実行される。
105
+ 差分検知 (ローカル MD5 vs S3 ETag) により変更ファイルのみ PutObject される。
106
+ """
107
+ for _name, cf_ctx in context.cloudfront.items():
108
+ if not cf_ctx.managed_assets:
109
+ continue
110
+ cf = CloudFront(cf_ctx)
111
+ cf.upload_managed_assets()
112
+
113
+
114
+ def deploy_resources(context: Context, *, state_bucket: str = ""):
115
+ state_store = _create_state_store(context)
116
+ # state bucket は deploy_init_resources の前に作成済み
117
+ # ここでは念のため再確認
118
+ state_store.ensure_bucket()
119
+
120
+ mediator = Mediator(context)
121
+ resources = get_resources(context, state_bucket=state_bucket)
122
+ for resource in resources:
123
+ target_name = resource.__class__.__name__
124
+ if resource.status == "NOEXIST":
125
+ echo.log("Creating %s..." % target_name)
126
+ if "mediator" in inspect.signature(resource.create).parameters:
127
+ resource.create(mediator)
128
+ else:
129
+ resource.create()
130
+ state_store.record(resource.state_info())
131
+ elif resource.status == "REQUIRE_UPDATE":
132
+ echo.log("Updating %s..." % target_name)
133
+ if "mediator" in inspect.signature(resource.update).parameters:
134
+ resource.update(mediator)
135
+ else:
136
+ resource.update()
137
+ state_store.record(resource.state_info())
138
+ else:
139
+ echo.log("%s is already the latest version." % target_name)
140
+ # stack 作成/更新が終わった後の後付け状態 (bucket policy / KVS など) を
141
+ # 冪等に確保する。wait_status が timeout した次の deploy でも復旧できる。
142
+ for resource in resources:
143
+ hook = getattr(resource, "ensure_post_deploy_state", None)
144
+ if hook is None:
145
+ continue
146
+ if "mediator" in inspect.signature(hook).parameters:
147
+ hook(mediator)
148
+ else:
149
+ hook()
150
+
151
+
152
+ def apply_skip_check_existing(context: Context) -> None:
153
+ """DB リソース (neon/tidb/upstash) の存在確認を一律 skip させる。
154
+
155
+ `--skip-check-existing` 指定時に呼ぶ。pocket.toml を編集せず、その deploy
156
+ 実行に限り外部 SaaS API への存在確認 call を回避する (deploy ロールに
157
+ DB credentials を渡さず deploy を完走させる用途)。toml 側の
158
+ `skip_check_existing` フラグと同義で、こちらは実行時上書き。
159
+ """
160
+ for db_ctx in (context.neon, context.tidb, context.upstash):
161
+ if db_ctx is not None:
162
+ db_ctx.skip_check_existing = True
163
+
164
+
165
+ def build_image(context: Context, *, tag: str) -> str:
166
+ """awscontainer image を指定 tag で build & push する (deploy はしない)。
167
+
168
+ build once 用。codebuild backend は source upload に state bucket を要するため、
169
+ deploy と同様に先に state bucket を確保してから build する。戻り値は ecr_name:tag。
170
+ """
171
+ if context.awscontainer is None:
172
+ raise click.ClickException("awscontainer がこの stage に設定されていません。")
173
+ state_store = _create_state_store(context)
174
+ state_store.ensure_bucket()
175
+ ac = AwsContainer(context.awscontainer, state_bucket=state_store.bucket_name)
176
+ ac.build(tag)
177
+ return f"{context.awscontainer.ecr_name}:{tag}"
178
+
179
+
180
+ def _deploy_pipeline(context: Context, *, openpath=None, skip_frontend=False):
181
+ """deploy / promote 共通のパイプライン本体。
182
+
183
+ promote 時は context.awscontainer.promote_commit_hash が設定済みで、
184
+ deploy_init 内の image build が retag に置き換わる以外は deploy と同一。
185
+ """
186
+ # CodeBuildがソースアップロードにstate bucketを必要とするため、先に作成
187
+ state_store = _create_state_store(context)
188
+ state_store.ensure_bucket()
189
+ state_bucket = state_store.bucket_name
190
+ deploy_init_resources(context, state_bucket=state_bucket)
191
+ deploy_resources(context, state_bucket=state_bucket)
192
+ upload_managed_assets(context)
193
+ if not skip_frontend:
194
+ deploy_frontend(context)
195
+ # デプロイ完了後の URL 表示
196
+ url = _get_deploy_url(context)
197
+ if url:
198
+ echo.success(f"url: {url}")
199
+ if openpath:
200
+ webbrowser.open(url + "/" + openpath)
201
+
202
+
203
+ @click.command()
204
+ @click.option("--stage", envvar="POCKET_DEPLOY_STAGE", prompt=True)
205
+ @click.option("--openpath")
206
+ @click.option("--skip-frontend", is_flag=True, default=False)
207
+ @click.option(
208
+ "--skip-check-existing",
209
+ is_flag=True,
210
+ default=False,
211
+ help="neon/tidb/upstash の存在確認 API を skip し COMPLETED 扱いで deploy",
212
+ )
213
+ def deploy(stage: str, openpath, skip_frontend, skip_check_existing):
214
+ from pocket_cli.cli.aws_auth import check_aws_credentials
215
+
216
+ check_aws_credentials()
217
+ context = Context.from_toml(stage=stage)
218
+ if skip_check_existing:
219
+ apply_skip_check_existing(context)
220
+ _deploy_pipeline(context, openpath=openpath, skip_frontend=skip_frontend)
221
+
222
+
223
+ @click.command()
224
+ @click.option("--stage", envvar="POCKET_DEPLOY_STAGE", prompt=True)
225
+ @click.option("--commit-hash", required=True, help="昇格する image の git commit hash")
226
+ @click.option("--openpath")
227
+ @click.option("--skip-frontend", is_flag=True, default=False)
228
+ @click.option(
229
+ "--skip-check-existing",
230
+ is_flag=True,
231
+ default=False,
232
+ help="neon/tidb/upstash の存在確認 API を skip し COMPLETED 扱いで deploy",
233
+ )
234
+ def promote(stage: str, commit_hash, openpath, skip_frontend, skip_check_existing):
235
+ """build 済みの :<commit-hash> image へ stage を向けて deploy する (再ビルドなし)。
236
+
237
+ `pocket django build` で push した image に :<stage> タグを移し、
238
+ インフラ/Lambda を更新する。image build は行わない (build once の昇格)。
239
+ """
240
+ from pocket_cli.cli.aws_auth import check_aws_credentials
241
+
242
+ check_aws_credentials()
243
+ context = Context.from_toml(stage=stage)
244
+ if context.awscontainer is None:
245
+ raise click.ClickException("awscontainer がこの stage に設定されていません。")
246
+ if skip_check_existing:
247
+ apply_skip_check_existing(context)
248
+ context.awscontainer.promote_commit_hash = commit_hash
249
+ _deploy_pipeline(context, openpath=openpath, skip_frontend=skip_frontend)
250
+
251
+
252
+ def _get_deploy_url(context: Context) -> str | None:
253
+ """デプロイ後に表示する URL を決定する。
254
+
255
+ CloudFront がある場合はそのドメイン(カスタム or 自動生成)を優先し、
256
+ なければ API Gateway の wsgi エンドポイントを返す。
257
+ """
258
+ # CloudFront ドメインを優先
259
+ for _name, cf_ctx in context.cloudfront.items():
260
+ if cf_ctx.domain:
261
+ return f"https://{cf_ctx.domain}"
262
+ cf = CloudFront(cf_ctx)
263
+ if cf.stack.output:
264
+ domain = cf.stack.output.get("DistributionDomainName")
265
+ if domain:
266
+ return f"https://{domain}"
267
+
268
+ # フォールバック: API Gateway
269
+ if context.awscontainer:
270
+ ac = AwsContainer(context.awscontainer)
271
+ endpoint = ac.endpoints.get("wsgi")
272
+ if endpoint:
273
+ return endpoint
274
+ return None
@@ -0,0 +1,358 @@
1
+ import boto3
2
+ import click
3
+
4
+ from pocket.context import Context
5
+ from pocket.utils import echo
6
+ from pocket_cli.resources.aws.builders.codebuild import CodeBuildBuilder
7
+ from pocket_cli.resources.aws.state import StateStore
8
+ from pocket_cli.resources.awscontainer import AwsContainer
9
+ from pocket_cli.resources.cloudfront import CloudFront
10
+ from pocket_cli.resources.cloudfront_acm import CloudFrontAcm
11
+ from pocket_cli.resources.cloudfront_keys import CloudFrontKeys
12
+ from pocket_cli.resources.dsql import Dsql
13
+ from pocket_cli.resources.neon import Neon
14
+ from pocket_cli.resources.rds import Rds
15
+ from pocket_cli.resources.s3 import S3
16
+ from pocket_cli.resources.tidb import TiDb
17
+ from pocket_cli.resources.upstash import Upstash
18
+ from pocket_cli.resources.vpc import Vpc
19
+
20
+
21
+ def _create_state_store(context: Context) -> StateStore:
22
+ assert context.general
23
+ resource_prefix = context.general.prefix_template.format(
24
+ stage=context.stage,
25
+ project=context.project_name,
26
+ namespace=context.general.namespace,
27
+ )
28
+ bucket_name = f"{resource_prefix}state"
29
+ return StateStore(bucket_name, context.general.region)
30
+
31
+
32
+ def _create_codebuild_builder(context: Context) -> CodeBuildBuilder | None:
33
+ """CodeBuildBuilder インスタンスを作成(リソース確認用)"""
34
+ if not context.awscontainer or not context.general:
35
+ return None
36
+ resource_prefix = context.general.prefix_template.format(
37
+ stage=context.stage,
38
+ project=context.project_name,
39
+ namespace=context.general.namespace,
40
+ )
41
+ state_bucket = f"{resource_prefix}state"
42
+ return CodeBuildBuilder(
43
+ region=context.general.region,
44
+ resource_prefix=resource_prefix,
45
+ state_bucket=state_bucket,
46
+ permissions_boundary=context.awscontainer.permissions_boundary,
47
+ )
48
+
49
+
50
+ def _collect_vpc_targets(context: Context) -> list[str]:
51
+ """VPC 関連の削除対象を収集"""
52
+ if not context.awscontainer or not context.awscontainer.vpc:
53
+ return []
54
+ if not context.awscontainer.vpc.manage:
55
+ return ["VPC (外部 VPC consumer タグ削除)"]
56
+ vpc = Vpc(context.awscontainer.vpc)
57
+ vpc_parts = []
58
+ if vpc.stack.status != "NOEXIST":
59
+ vpc_parts.append("CFNスタック")
60
+ if vpc.stack.consumers:
61
+ vpc_parts.append("consumers: %s" % ", ".join(vpc.stack.consumers))
62
+ if vpc.efs and vpc.efs.exists():
63
+ vpc_parts.append("EFS")
64
+ if vpc_parts:
65
+ return ["VPC (%s)" % " + ".join(vpc_parts)]
66
+ return []
67
+
68
+
69
+ def _collect_awscontainer_targets(context: Context, with_secrets: bool):
70
+ """AwsContainer 関連の削除対象を収集"""
71
+ targets: list[str] = []
72
+ if not context.awscontainer:
73
+ return targets
74
+
75
+ ac = AwsContainer(context.awscontainer)
76
+ parts = ["CFNスタック"]
77
+ if ac.ecr.exists():
78
+ if context.awscontainer.ecr_name_overridden:
79
+ parts.append("ECR は ecr_name 明示指定のため削除対象外")
80
+ else:
81
+ parts.append("ECR")
82
+ if with_secrets and context.awscontainer.secrets:
83
+ parts.append("secrets")
84
+
85
+ # CodeBuildリソースの存在チェック(設定に関わらず)
86
+ cb = _create_codebuild_builder(context)
87
+ if cb and (cb.project_exists() or cb.role_exists()):
88
+ parts.append("CodeBuild")
89
+
90
+ targets.append("AwsContainer (%s)" % " + ".join(parts))
91
+ targets.extend(_collect_vpc_targets(context))
92
+
93
+ return targets
94
+
95
+
96
+ def _collect_database_targets(context: Context) -> list[str]:
97
+ """データベース関連の削除対象を収集"""
98
+ targets: list[str] = []
99
+ if context.dsql:
100
+ dsql = Dsql(context.dsql)
101
+ if dsql.status != "NOEXIST":
102
+ targets.append("DSQL クラスター: %s" % context.dsql.tag_name)
103
+ if context.rds:
104
+ rds = Rds(context.rds)
105
+ if rds.status != "NOEXIST":
106
+ targets.append("RDS Aurora クラスター: %s" % context.rds.cluster_identifier)
107
+ if context.tidb:
108
+ targets.append("TiDB クラスタ")
109
+ if context.upstash:
110
+ targets.append("Upstash Redis: %s" % context.upstash.database_name)
111
+ if context.neon:
112
+ targets.append("Neon ブランチ")
113
+ return targets
114
+
115
+
116
+ def _collect_targets(context: Context, with_secrets: bool, with_state_bucket: bool):
117
+ """削除対象のリソース一覧を収集"""
118
+ targets: list[str] = []
119
+
120
+ for name, cf_ctx in context.cloudfront.items():
121
+ targets.append("CloudFront '%s' (CFNスタック + バケットポリシー)" % name)
122
+ if cf_ctx.domain:
123
+ targets.append("CloudFront ACM '%s' (us-east-1 証明書)" % name)
124
+
125
+ targets.extend(_collect_awscontainer_targets(context, with_secrets))
126
+ targets.extend(_collect_database_targets(context))
127
+
128
+ for name, cf_ctx in context.cloudfront.items():
129
+ if cf_ctx.signing_key:
130
+ targets.append("CloudFrontKeys '%s' (CFNスタック)" % name)
131
+
132
+ if context.s3 and S3(context.s3).exists():
133
+ targets.append("S3 バケット: %s" % context.s3.bucket_name)
134
+
135
+ if with_state_bucket:
136
+ targets.append("ステートバケット")
137
+
138
+ return targets
139
+
140
+
141
+ def _destroy_codebuild(context: Context) -> None:
142
+ """CodeBuildプロジェクト + IAMロールを削除(設定に関わらず存在すれば削除)"""
143
+ cb = _create_codebuild_builder(context)
144
+ if cb is None:
145
+ return
146
+ if cb.project_exists() or cb.role_exists():
147
+ echo.log("Destroying CodeBuild resources...")
148
+ cb.delete()
149
+ echo.success("CodeBuild resources were deleted.")
150
+
151
+
152
+ def _destroy_log_groups(context: Context):
153
+ """Lambda が自動作成したロググループを削除"""
154
+ if not context.awscontainer:
155
+ return
156
+ logs_client = boto3.client("logs", region_name=context.awscontainer.region)
157
+ for handler_ctx in context.awscontainer.handlers.values():
158
+ log_group_name = handler_ctx.log_group_name
159
+ try:
160
+ logs_client.delete_log_group(logGroupName=log_group_name)
161
+ echo.log("Deleted log group: %s" % log_group_name)
162
+ except logs_client.exceptions.ResourceNotFoundException:
163
+ pass
164
+
165
+
166
+ def _destroy_awscontainer(context: Context, with_secrets: bool):
167
+ """AwsContainer 関連リソースを削除"""
168
+ if not context.awscontainer:
169
+ return
170
+
171
+ ac = AwsContainer(context.awscontainer)
172
+ if ac.stack.status != "NOEXIST":
173
+ echo.log("Destroying AwsContainer stack...")
174
+ ac.stack.delete()
175
+ echo.success("AwsContainer stack was destroyed.")
176
+
177
+ if ac.ecr.exists():
178
+ if context.awscontainer.ecr_name_overridden:
179
+ echo.warning(
180
+ "ECR repository '%s' は ecr_name で明示指定されているため削除"
181
+ "しません (他 stage と共有の可能性があります)。"
182
+ "不要な場合は手動で削除してください。" % context.awscontainer.ecr_name
183
+ )
184
+ else:
185
+ echo.log("Destroying ECR repository...")
186
+ ac.ecr.delete()
187
+ echo.success("ECR repository was deleted.")
188
+
189
+ _destroy_codebuild(context)
190
+
191
+ _destroy_log_groups(context)
192
+
193
+ if with_secrets and context.awscontainer.secrets:
194
+ echo.log("Destroying pocket managed secrets...")
195
+ context.awscontainer.secrets.pocket_store.delete_secrets()
196
+ echo.success("Pocket managed secrets were deleted.")
197
+
198
+ _destroy_vpc(context)
199
+
200
+
201
+ def _destroy_dsql(context: Context):
202
+ """DSQL クラスターを削除"""
203
+ if not context.dsql:
204
+ return
205
+ dsql = Dsql(context.dsql)
206
+ if dsql.status == "NOEXIST":
207
+ return
208
+ echo.log("Destroying DSQL cluster...")
209
+ dsql.delete()
210
+
211
+
212
+ def _destroy_rds(context: Context):
213
+ """RDS Aurora クラスターを削除"""
214
+ if not context.rds:
215
+ return
216
+ rds = Rds(context.rds)
217
+ if rds.status == "NOEXIST":
218
+ return
219
+ echo.log("Destroying RDS Aurora cluster...")
220
+ rds.delete()
221
+ echo.success("RDS Aurora cluster was destroyed. Final snapshot was created.")
222
+
223
+
224
+ def _destroy_vpc(context: Context):
225
+ """VPC 関連リソースを削除"""
226
+ if not context.awscontainer or not context.awscontainer.vpc:
227
+ return
228
+ vpc_ctx = context.awscontainer.vpc
229
+ if not vpc_ctx.manage:
230
+ # 外部 VPC: consumer タグのみ削除
231
+ from pocket_cli.resources.aws.cloudformation import VpcStack
232
+
233
+ vpc_stack = VpcStack(vpc_ctx)
234
+ if vpc_stack.status != "NOEXIST":
235
+ slug = context.stage + "-" + context.project_name
236
+ vpc_stack.remove_consumer_tag(slug)
237
+ echo.log("外部 VPC の consumer タグを削除しました。")
238
+ return
239
+ # managed VPC: consumer チェック後に削除
240
+ vpc = Vpc(vpc_ctx)
241
+ if vpc.stack.consumers:
242
+ echo.danger("VPC に consumer がいるため削除できません:")
243
+ for c in vpc.stack.consumers:
244
+ echo.info(" - %s" % c)
245
+ return
246
+ has_stack = vpc.stack.status != "NOEXIST"
247
+ has_efs = vpc.efs and vpc.efs.exists()
248
+ if has_stack or has_efs:
249
+ echo.log("Destroying VPC...")
250
+ vpc.delete()
251
+ echo.success("VPC was destroyed.")
252
+
253
+
254
+ def _destroy_cloudfront_and_acm(context: Context):
255
+ """CloudFront ディストリビューションと ACM 証明書を削除"""
256
+ for name, cf_ctx in context.cloudfront.items():
257
+ cf = CloudFront(cf_ctx)
258
+ if cf.stack.status != "NOEXIST":
259
+ echo.log("Destroying CloudFront '%s'..." % name)
260
+ cf.delete()
261
+ echo.success("CloudFront '%s' was destroyed." % name)
262
+ if cf_ctx.domain:
263
+ acm = CloudFrontAcm(cf_ctx)
264
+ if acm.stack.status != "NOEXIST":
265
+ echo.log("Destroying CloudFront ACM '%s'..." % name)
266
+ acm.delete()
267
+ echo.success("CloudFront ACM '%s' was destroyed." % name)
268
+
269
+
270
+ def _destroy_resources(context: Context, with_secrets: bool, with_state_bucket: bool):
271
+ """リソースをデプロイの逆順で削除"""
272
+ # 1. CloudFront + ACM
273
+ _destroy_cloudfront_and_acm(context)
274
+
275
+ # 2. AwsContainer (CFNスタック + ECR + secrets) + 3. VPC
276
+ _destroy_awscontainer(context, with_secrets)
277
+
278
+ # 2.5. DSQL
279
+ _destroy_dsql(context)
280
+
281
+ # 2.6. RDS(AwsContainer の後、VPC の前)
282
+ _destroy_rds(context)
283
+
284
+ # 3.5. CloudFrontKeys(AwsContainer の後、S3 の前)
285
+ for name, cf_ctx in context.cloudfront.items():
286
+ if cf_ctx.signing_key:
287
+ cfk = CloudFrontKeys(cf_ctx)
288
+ if cfk.stack.status != "NOEXIST":
289
+ echo.log("Destroying CloudFrontKeys '%s'..." % name)
290
+ cfk.delete()
291
+ echo.success("CloudFrontKeys '%s' was destroyed." % name)
292
+
293
+ # 4. S3 バケット
294
+ if context.s3 and S3(context.s3).exists():
295
+ echo.log("Destroying S3 bucket...")
296
+ S3(context.s3).delete()
297
+ echo.success("S3 bucket was deleted.")
298
+
299
+ # 5. TiDB クラスタ
300
+ if context.tidb and TiDb(context.tidb).cluster:
301
+ echo.log("Destroying TiDB cluster...")
302
+ TiDb(context.tidb).delete_cluster()
303
+ echo.success("TiDB cluster was deleted.")
304
+
305
+ # 5.5. Upstash Redis
306
+ if context.upstash:
307
+ upstash = Upstash(context.upstash)
308
+ if upstash.database:
309
+ upstash.delete_database()
310
+
311
+ # 6. Neon ブランチ
312
+ if context.neon and Neon(context.neon).branch:
313
+ echo.log("Destroying Neon branch...")
314
+ Neon(context.neon).delete_branch()
315
+ echo.success("Neon branch was deleted.")
316
+
317
+ # 7. ステートバケット
318
+ if with_state_bucket:
319
+ state_store = _create_state_store(context)
320
+ echo.log("Destroying state bucket...")
321
+ state_store.delete_bucket()
322
+ echo.success("State bucket was deleted.")
323
+
324
+
325
+ @click.command()
326
+ @click.option("--stage", envvar="POCKET_DEPLOY_STAGE", prompt=True)
327
+ @click.option(
328
+ "--without-secrets",
329
+ is_flag=True,
330
+ default=False,
331
+ help="シークレットを削除せずに残す",
332
+ )
333
+ @click.option("--with-state-bucket", is_flag=True, default=False)
334
+ @click.option(
335
+ "--yes", "-y", is_flag=True, default=False, help="確認プロンプトをスキップ"
336
+ )
337
+ def destroy(stage: str, without_secrets: bool, with_state_bucket: bool, yes: bool):
338
+ """ステージの全リソースを一括削除"""
339
+ from pocket_cli.cli.aws_auth import check_aws_credentials
340
+
341
+ check_aws_credentials()
342
+ context = Context.from_toml(stage=stage)
343
+ with_secrets = not without_secrets
344
+ targets = _collect_targets(context, with_secrets, with_state_bucket)
345
+
346
+ if not targets:
347
+ echo.warning("削除対象のリソースが見つかりません。")
348
+ return
349
+
350
+ echo.danger("以下のリソースを削除します:")
351
+ for target in targets:
352
+ echo.info(" - %s" % target)
353
+ echo.danger("この操作は取り消せません!")
354
+ if not yes:
355
+ click.confirm("stage '%s' の全リソースを削除しますか?" % stage, abort=True)
356
+
357
+ _destroy_resources(context, with_secrets, with_state_bucket)
358
+ echo.success("stage '%s' の全リソースを削除しました。" % stage)