magic-pocket-cli 0.34.0__tar.gz → 0.35.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 (87) hide show
  1. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/PKG-INFO +2 -2
  2. magic_pocket_cli-0.35.0/pocket_cli/cli/build_cli.py +44 -0
  3. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/cli/deploy_cli.py +15 -1
  4. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/cli/destroy_cli.py +93 -1
  5. magic_pocket_cli-0.35.0/pocket_cli/cli/inbound_cli.py +288 -0
  6. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/cli/main_cli.py +4 -0
  7. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/cli/status_cli.py +21 -0
  8. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/cli/vpc_cli.py +54 -14
  9. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/django_cli.py +3 -38
  10. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/migrations.py +53 -20
  11. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/resources/aws/cloudformation.py +46 -8
  12. magic_pocket_cli-0.35.0/pocket_cli/resources/aws/s3_utils.py +105 -0
  13. magic_pocket_cli-0.35.0/pocket_cli/resources/inbound.py +276 -0
  14. magic_pocket_cli-0.35.0/pocket_cli/resources/inbound_template.py +309 -0
  15. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/resources/rds.py +25 -14
  16. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/resources/sqs_alert.py +30 -7
  17. magic_pocket_cli-0.35.0/pocket_cli/resources/vpc.py +148 -0
  18. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/templates/cloudformation/cf_function_spa_auth.js +6 -1
  19. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/templates/cloudformation/container.yaml +48 -0
  20. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pyproject.toml +2 -2
  21. magic_pocket_cli-0.34.0/pocket_cli/resources/aws/s3_utils.py +0 -58
  22. magic_pocket_cli-0.34.0/pocket_cli/resources/vpc.py +0 -67
  23. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/.gitignore +0 -0
  24. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/__init__.py +0 -0
  25. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/cli/__init__.py +0 -0
  26. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/cli/aws_auth.py +0 -0
  27. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/cli/backup_cli.py +0 -0
  28. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/cli/cloudfront_cli.py +0 -0
  29. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/cli/cloudfront_keys_cli.py +0 -0
  30. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/cli/cloudfront_waf_cli.py +0 -0
  31. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/cli/container_cli.py +0 -0
  32. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/cli/dsql_cli.py +0 -0
  33. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/cli/image_cli.py +0 -0
  34. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/cli/interaction.py +0 -0
  35. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/cli/migrate_cli.py +0 -0
  36. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/cli/neon_cli.py +0 -0
  37. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/cli/permissions_cli.py +0 -0
  38. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/cli/rds_cli.py +0 -0
  39. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/cli/removed_flags.py +0 -0
  40. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/cli/resource_helper.py +0 -0
  41. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/cli/runtime_config_cli.py +0 -0
  42. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/cli/s3_cli.py +0 -0
  43. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/cli/store_url_helper.py +0 -0
  44. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/cli/tidb_cli.py +0 -0
  45. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/cli/upstash_cli.py +0 -0
  46. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/cli/url_helper.py +0 -0
  47. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/cli/waf_cli.py +0 -0
  48. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/mediator.py +0 -0
  49. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/resources/__init__.py +0 -0
  50. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/resources/aws/__init__.py +0 -0
  51. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/resources/aws/backup_common.py +0 -0
  52. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/resources/aws/builders/__init__.py +0 -0
  53. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/resources/aws/builders/codebuild.py +0 -0
  54. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/resources/aws/builders/context_check.py +0 -0
  55. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/resources/aws/builders/depot.py +0 -0
  56. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/resources/aws/builders/docker.py +0 -0
  57. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/resources/aws/builders/dockerignore.py +0 -0
  58. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/resources/aws/ecr.py +0 -0
  59. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/resources/aws/efs.py +0 -0
  60. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/resources/aws/lambdahandler.py +0 -0
  61. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/resources/aws/poll.py +0 -0
  62. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/resources/aws/stack_backed.py +0 -0
  63. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/resources/aws/state.py +0 -0
  64. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/resources/backup.py +0 -0
  65. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/resources/cloudfront.py +0 -0
  66. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/resources/cloudfront_acm.py +0 -0
  67. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/resources/cloudfront_keys.py +0 -0
  68. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/resources/cloudfront_waf.py +0 -0
  69. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/resources/container.py +0 -0
  70. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/resources/dsql.py +0 -0
  71. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/resources/neon.py +0 -0
  72. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/resources/s3.py +0 -0
  73. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/resources/tidb.py +0 -0
  74. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/resources/upstash.py +0 -0
  75. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/templates/cloudformation/cf_function_api_host.js +0 -0
  76. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/templates/cloudformation/cf_function_basic_auth_prelude.js +0 -0
  77. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/templates/cloudformation/cf_function_host_redirect_prelude.js +0 -0
  78. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/templates/cloudformation/cf_function_spa_fallback.js +0 -0
  79. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/templates/cloudformation/cloudfront.yaml +0 -0
  80. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/templates/cloudformation/cloudfront_acm.yaml +0 -0
  81. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/templates/cloudformation/cloudfront_keys.yaml +0 -0
  82. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/templates/cloudformation/cloudfront_waf.yaml +0 -0
  83. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/templates/cloudformation/vpc.yaml +0 -0
  84. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/templates/init/django-dotenv.env +0 -0
  85. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/templates/init/django-settings.py +0 -0
  86. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.0}/pocket_cli/templates/init/pocket.Dockerfile +0 -0
  87. {magic_pocket_cli-0.34.0 → magic_pocket_cli-0.35.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.34.0
3
+ Version: 0.35.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.35,>=0.34.0
10
+ Requires-Dist: magic-pocket<0.36,>=0.35.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,44 @@
1
+ """フレームワーク共通の build once コマンド。"""
2
+
3
+ import click
4
+
5
+ from pocket.context import Context, get_commit_hash, is_working_tree_dirty
6
+ from pocket.utils import echo
7
+ from pocket_cli.cli.aws_auth import check_aws_credentials
8
+ from pocket_cli.cli.deploy_cli import build_image
9
+
10
+
11
+ @click.command()
12
+ @click.option("--stage", envvar="POCKET_DEPLOY_STAGE", prompt=True)
13
+ @click.option(
14
+ "--allow-dirty",
15
+ is_flag=True,
16
+ default=False,
17
+ help="working tree が dirty でも build する (ローカル検証用)",
18
+ )
19
+ def build(stage: str, allow_dirty: bool):
20
+ """現在の作業ツリーを build し、git commit hash をタグにして ECR へ push する。
21
+
22
+ deploy はしない (build once)。`pocket promote --commit-hash <sha>` で
23
+ このイメージへ昇格する。タグは COMMIT_HASH 環境変数があればそれを、なければ
24
+ `git rev-parse HEAD` を使う。
25
+
26
+ commit hash = image 内容 の同一性が前提のため、working tree が dirty の場合は
27
+ エラーになる (--allow-dirty で回避可能)。
28
+ """
29
+ check_aws_credentials()
30
+ if not allow_dirty and is_working_tree_dirty():
31
+ raise click.ClickException(
32
+ "working tree に未コミットの変更があります。build once では"
33
+ " commit hash と image 内容の一致が前提のため、commit してから"
34
+ " build してください (--allow-dirty で回避できますが、その image の"
35
+ " 昇格は推奨しません)。"
36
+ )
37
+ try:
38
+ tag = get_commit_hash()
39
+ except RuntimeError as e:
40
+ raise click.ClickException(str(e)) from e
41
+ context = Context.from_toml(stage=stage)
42
+ targets = build_image(context, tag=tag)
43
+ for target in targets:
44
+ echo.success("built and pushed: %s" % target)
@@ -4,6 +4,7 @@ import webbrowser
4
4
  import click
5
5
 
6
6
  from pocket.context import Context, deploy_hash_report
7
+ from pocket.settings import parse_handler_ref
7
8
  from pocket.utils import echo
8
9
  from pocket_cli import migrations
9
10
  from pocket_cli.cli import interaction
@@ -20,6 +21,11 @@ from pocket_cli.resources.cloudfront_keys import CloudFrontKeys
20
21
  from pocket_cli.resources.cloudfront_waf import CloudFrontWaf
21
22
  from pocket_cli.resources.container import Container
22
23
  from pocket_cli.resources.dsql import Dsql
24
+ from pocket_cli.resources.inbound import (
25
+ Inbound,
26
+ check_removed_inbound,
27
+ echo_inbound_details,
28
+ )
23
29
  from pocket_cli.resources.neon import Neon
24
30
  from pocket_cli.resources.rds import Rds
25
31
  from pocket_cli.resources.s3 import S3
@@ -82,6 +88,7 @@ def get_resources(context: Context, *, state_bucket: str = ""):
82
88
  if context.s3:
83
89
  resources.append(S3(context.s3, cloudfront_contexts=context.cloudfront))
84
90
  _append_infra_resources(resources, context, state_bucket)
91
+ resources.extend(Inbound(ctx) for ctx in context.inbound.values())
85
92
  for _name, cf_ctx in context.cloudfront.items():
86
93
  resources.append(CloudFront(cf_ctx))
87
94
  return resources
@@ -209,6 +216,11 @@ def _deploy_pipeline(context: Context, *, openpath=None, skip_frontend=False):
209
216
  """
210
217
  # DEPLOY_HASH の解決結果を deploy 時に 1 回可視化する (env 伝播漏れで
211
218
  # 黙って git short hash に落ちる footgun の早期発見用)。
219
+ for inlet in context.inbound.values():
220
+ container_name, _ = parse_handler_ref(inlet.config.handler)
221
+ if not context.container[container_name].permissions_boundary:
222
+ raise ValueError("inbound workerにpermissions_boundaryが必要です")
223
+ Inbound(inlet).prepare_deploy()
212
224
  deploy_hash_message = deploy_hash_report(context)
213
225
  if deploy_hash_message:
214
226
  echo.info(deploy_hash_message)
@@ -216,6 +228,7 @@ def _deploy_pipeline(context: Context, *, openpath=None, skip_frontend=False):
216
228
  state_store = _create_state_store(context)
217
229
  state_store.ensure_bucket()
218
230
  state_bucket = state_store.bucket_name
231
+ check_removed_inbound(context, state_store)
219
232
  deploy_init_resources(context, state_bucket=state_bucket)
220
233
  deploy_resources(context, state_bucket=state_bucket)
221
234
  # リリース跨ぎ移行の掃除フェーズ (旧配置の削除は deploy 成功後にしか
@@ -229,6 +242,7 @@ def _deploy_pipeline(context: Context, *, openpath=None, skip_frontend=False):
229
242
  # DLQ アラートの email 購読が未確認のままだと通知が届かないため、
230
243
  # deploy の最後に購読状態を確認して警告する
231
244
  echo_dead_letter_alert_warnings(context)
245
+ echo_inbound_details(context)
232
246
  # デプロイ完了後の URL 表示
233
247
  url = _get_deploy_url(context)
234
248
  if url:
@@ -266,7 +280,7 @@ def deploy(stage: str, openpath, skip_frontend, yes):
266
280
  def promote(stage: str, commit_hash, openpath, skip_frontend, yes):
267
281
  """build 済みの :<commit-hash> image へ stage を向けて deploy する (再ビルドなし)。
268
282
 
269
- `pocket django build` で push した image に :<stage> タグを移し、
283
+ `pocket build` で push した image に :<stage> タグを移し、
270
284
  インフラ/Lambda を更新する。image build は行わない (build once の昇格)。
271
285
  """
272
286
  from pocket_cli.cli.aws_auth import check_aws_credentials
@@ -3,6 +3,12 @@ import click
3
3
 
4
4
  from pocket.context import Context
5
5
  from pocket.utils import echo
6
+ from pocket_cli.migrations import (
7
+ legacy_container_stack_exists,
8
+ legacy_container_stack_name,
9
+ legacy_ecr_repo_exists,
10
+ legacy_ecr_repo_name,
11
+ )
6
12
  from pocket_cli.resources.aws.builders.codebuild import CodeBuildBuilder
7
13
  from pocket_cli.resources.aws.state import (
8
14
  context_resource_prefix,
@@ -14,6 +20,7 @@ from pocket_cli.resources.cloudfront_acm import CloudFrontAcm
14
20
  from pocket_cli.resources.cloudfront_keys import CloudFrontKeys
15
21
  from pocket_cli.resources.container import Container
16
22
  from pocket_cli.resources.dsql import Dsql
23
+ from pocket_cli.resources.inbound import Inbound
17
24
  from pocket_cli.resources.neon import Neon
18
25
  from pocket_cli.resources.rds import Rds
19
26
  from pocket_cli.resources.s3 import S3
@@ -89,6 +96,8 @@ def _collect_container_targets(context: Context, with_secrets: bool):
89
96
  parts.append("ECR")
90
97
  targets.append("Container '%s' (%s)" % (c_name, " + ".join(parts)))
91
98
 
99
+ targets.extend(_collect_legacy_container_targets(context))
100
+
92
101
  parts = []
93
102
  has_secrets = context.secrets or any(c.secrets for c in context.container.values())
94
103
  if with_secrets and has_secrets:
@@ -105,6 +114,38 @@ def _collect_container_targets(context: Context, with_secrets: bool):
105
114
  return targets
106
115
 
107
116
 
117
+ def _collect_legacy_container_targets(context: Context) -> list[str]:
118
+ """0.29.0 以前の単数 [awscontainer] 命名 ({slug}-container) の残骸を削除対象にする。
119
+
120
+ 移行 deploy を経ずに旧 stack だけ消したいケース (region 移設・廃止) では
121
+ 現行命名の stack が NOEXIST で destroy の対象から漏れるため (KN1456)。
122
+ """
123
+ targets: list[str] = []
124
+ if legacy_container_stack_exists(context):
125
+ targets.append(
126
+ "旧形式の container stack '%s' (0.29.0 以前の [awscontainer] 命名。"
127
+ "CFNスタック)" % legacy_container_stack_name(context)
128
+ )
129
+ if legacy_ecr_repo_exists(context):
130
+ targets.append(
131
+ "旧形式の ECR repository '%s' (0.29.0 以前の命名)"
132
+ % legacy_ecr_repo_name(context)
133
+ )
134
+ return targets
135
+
136
+
137
+ def _neon_credential_missing(context: Context) -> bool:
138
+ """[neon] 宣言があるのに NEON_API_KEY が無ければ warning を出して True。"""
139
+ if not context.neon or context.neon.api_key:
140
+ return False
141
+ echo.warning(
142
+ "Neon: NEON_API_KEY が未設定のため Neon の確認・削除をスキップします "
143
+ "(stack の削除に Neon の資格情報は不要です。Neon branch は "
144
+ "pocket resource neon delete か Neon コンソールで削除してください)。"
145
+ )
146
+ return True
147
+
148
+
108
149
  def _warn_command_provisioned(label: str, hint: str):
109
150
  echo.warning(
110
151
  '%s は provisioning="command" のため destroy では削除しません。'
@@ -144,7 +185,11 @@ def _collect_external_database_targets(context: Context) -> list[str]:
144
185
  if context.upstash and context.upstash.provisioning != "command":
145
186
  if Upstash(context.upstash).database:
146
187
  targets.append("Upstash Redis: %s" % context.upstash.database_name)
147
- if context.neon and context.neon.provisioning != "command":
188
+ if (
189
+ context.neon
190
+ and context.neon.provisioning != "command"
191
+ and not _neon_credential_missing(context)
192
+ ):
148
193
  neon = Neon(context.neon)
149
194
  if neon.branch:
150
195
  plan = neon.destroy_plan()
@@ -176,6 +221,9 @@ def _collect_targets(context: Context, with_secrets: bool, with_state_bucket: bo
176
221
  if cf_ctx.domain:
177
222
  targets.append("CloudFront ACM '%s' (us-east-1 証明書)" % name)
178
223
 
224
+ targets.extend(
225
+ f"Inbound {name} (原本バケットと配送DLQは保持)" for name in context.inbound
226
+ )
179
227
  targets.extend(_collect_container_targets(context, with_secrets))
180
228
  targets.extend(_collect_database_targets(context))
181
229
  targets.extend(_collect_vpc_targets(context))
@@ -247,6 +295,8 @@ def _destroy_containers(context: Context, with_secrets: bool):
247
295
  c.ecr.delete()
248
296
  echo.success("ECR repository was deleted.")
249
297
 
298
+ _destroy_legacy_containers(context)
299
+
250
300
  _destroy_codebuild(context)
251
301
 
252
302
  _destroy_log_groups(context)
@@ -263,6 +313,43 @@ def _destroy_containers(context: Context, with_secrets: bool):
263
313
  echo.success("Pocket managed secrets were deleted.")
264
314
 
265
315
 
316
+ def _destroy_legacy_containers(context: Context):
317
+ """0.29.0 以前の命名の container stack / ECR repo / log group を削除する。"""
318
+ if not context.general:
319
+ return
320
+ region = context.general.region
321
+ if legacy_container_stack_exists(context):
322
+ stack_name = legacy_container_stack_name(context)
323
+ cfn = boto3.client("cloudformation", region_name=region)
324
+ echo.log("Destroying legacy container stack '%s'..." % stack_name)
325
+ cfn.delete_stack(StackName=stack_name)
326
+ # 現行 stack と同様、ENI 解放を含む削除完了を待たないと後続の VPC 削除が
327
+ # subnet 使用中で DELETE_FAILED になる
328
+ cfn.get_waiter("stack_delete_complete").wait(
329
+ StackName=stack_name, WaiterConfig={"Delay": 10, "MaxAttempts": 180}
330
+ )
331
+ echo.success("Legacy container stack '%s' was destroyed." % stack_name)
332
+ if legacy_ecr_repo_exists(context):
333
+ repo = legacy_ecr_repo_name(context)
334
+ ecr = boto3.client("ecr", region_name=region)
335
+ echo.log("Destroying legacy ECR repository '%s'..." % repo)
336
+ ecr.delete_repository(repositoryName=repo, force=True)
337
+ echo.success("Legacy ECR repository was deleted.")
338
+ # 旧命名の Lambda ({prefix}{handler}) が自動作成した log group
339
+ prefix = context_resource_prefix(context)
340
+ logs_client = boto3.client("logs", region_name=region)
341
+ handler_keys = sorted(
342
+ {key for c in context.container.values() for key in c.handlers}
343
+ )
344
+ for key in handler_keys:
345
+ log_group_name = f"/aws/lambda/{prefix}{key}"
346
+ try:
347
+ logs_client.delete_log_group(logGroupName=log_group_name)
348
+ echo.log("Deleted legacy log group: %s" % log_group_name)
349
+ except logs_client.exceptions.ResourceNotFoundException:
350
+ pass
351
+
352
+
266
353
  def _destroy_backup(context: Context, yes: bool):
267
354
  """backup plan / selection を削除し、recovery point の扱いを案内する。
268
355
 
@@ -386,6 +473,8 @@ def _destroy_neon(context: Context):
386
473
  if context.neon.provisioning == "command":
387
474
  _warn_command_provisioned("Neon", "pocket resource neon delete")
388
475
  return
476
+ if _neon_credential_missing(context):
477
+ return
389
478
  neon = Neon(context.neon)
390
479
  if not neon.branch:
391
480
  return
@@ -431,6 +520,9 @@ def _destroy_resources(
431
520
  yes: bool = False,
432
521
  ):
433
522
  """リソースをデプロイの逆順で削除"""
523
+ for inlet in context.inbound.values():
524
+ Inbound(inlet).delete()
525
+
434
526
  # 1. CloudFront + ACM
435
527
  _destroy_cloudfront_and_acm(context)
436
528
 
@@ -0,0 +1,288 @@
1
+ """受信口の初期設定と原本・受信情報の運用コマンド。"""
2
+
3
+ import hashlib
4
+ import json
5
+
6
+ import boto3
7
+ import click
8
+ from botocore.config import Config
9
+
10
+ from pocket.inbound import SETUP_NOTIFICATION, receipt_id, validate_receipt_id
11
+ from pocket.inbound_context import InboundContext
12
+ from pocket.settings import Settings, parse_handler_ref
13
+ from pocket_cli.resources.inbound import Inbound
14
+
15
+
16
+ @click.group()
17
+ @click.option("--stage", envvar="POCKET_DEPLOY_STAGE", required=True)
18
+ @click.option("--name", required=True, help="inboundの設定名")
19
+ @click.pass_context
20
+ def inbound(ctx, stage, name):
21
+ """SES受信口を管理する。"""
22
+ settings = Settings.from_toml(stage=stage)
23
+ if name not in settings.inbound:
24
+ raise click.ClickException(f"inbound.{name} がありません")
25
+ ctx.obj = Inbound(InboundContext.from_settings(name, settings))
26
+ ctx.meta["settings"] = settings
27
+
28
+
29
+ @inbound.command()
30
+ @click.pass_obj
31
+ def init(resource):
32
+ """共有rule setの初期化とドメイン検証の申請(初回のみ)。"""
33
+ ses = boto3.client("ses", region_name=resource.context.region)
34
+ active = ses.describe_active_receipt_rule_set().get("Metadata", {}).get("Name")
35
+ if not active:
36
+ name = "pocket-inbound"
37
+ names = [
38
+ item["Name"]
39
+ for page in ses.get_paginator("list_receipt_rule_sets").paginate()
40
+ for item in page["RuleSets"]
41
+ ]
42
+ if name in names:
43
+ raise click.ClickException(
44
+ "pocket-inboundが既にあります。内容を確認し管理者が有効化してください"
45
+ )
46
+ click.confirm(
47
+ "このaccount/regionで初期化を同時実行していないことを確認してください。"
48
+ "共有rule set pocket-inboundを作成・有効化しますか?",
49
+ abort=True,
50
+ )
51
+ if ses.describe_active_receipt_rule_set().get("Metadata"):
52
+ raise click.ClickException("active setが変わりました。再実行してください")
53
+ ses.create_receipt_rule_set(RuleSetName=name)
54
+ if ses.describe_active_receipt_rule_set().get("Metadata"):
55
+ raise click.ClickException(
56
+ "初期化中にactive setが変わりました。有効化を中止します"
57
+ )
58
+ ses.set_active_receipt_rule_set(RuleSetName=name)
59
+ active = ses.describe_active_receipt_rule_set().get("Metadata", {}).get("Name")
60
+ if active != name:
61
+ raise click.ClickException("有効化後のactive setが一致しません")
62
+ domain = resource.context.config.domain
63
+ result = ses.get_identity_verification_attributes(Identities=[domain])
64
+ identity = result.get("VerificationAttributes", {}).get(domain, {})
65
+ token = identity.get("VerificationToken")
66
+ if not token and identity.get("VerificationStatus") != "Success":
67
+ token = ses.verify_domain_identity(Domain=domain)["VerificationToken"]
68
+ click.echo(f"active rule set: {active}")
69
+ if token:
70
+ click.echo(f"TXT _amazonses.{domain} {token}")
71
+ click.echo(f"MX {domain} 10 inbound-smtp.{resource.context.region}.amazonaws.com")
72
+ click.echo("SES受信対応リージョンとMXの既存配送先を確認してDNSを登録してください。")
73
+
74
+
75
+ @inbound.command()
76
+ @click.pass_obj
77
+ def status(resource):
78
+ """スタック・active set・検証状態・監視先を表示する。"""
79
+ resource.prepare_deploy()
80
+ click.echo(f"stack: {resource.stack.name} / {resource.status}")
81
+ click.echo(json.dumps(resource.stack.output or {}, ensure_ascii=False, indent=2))
82
+ if resource.context.config.delivery_alert.eventbridge:
83
+ click.echo(
84
+ "EventBridge: source=aws.cloudwatch, "
85
+ "detail-type=CloudWatch Alarm State Change"
86
+ )
87
+
88
+
89
+ @inbound.command(name="yaml")
90
+ @click.pass_obj
91
+ def show_yaml(resource):
92
+ """active setと既存bindingを照会しテンプレートを表示する。"""
93
+ click.echo(resource.stack.yaml)
94
+
95
+
96
+ @inbound.command()
97
+ @click.pass_obj
98
+ def destroy(resource):
99
+ """無効化・配送猶予・滞留解消後に自分のルールとスタックを削除する。"""
100
+ click.confirm(
101
+ "受信停止後36時間以上経過し、原本の照合・必要な退避を終えましたか?", abort=True
102
+ )
103
+ resource.delete()
104
+
105
+
106
+ @inbound.command()
107
+ @click.pass_obj
108
+ def reconcile(resource):
109
+ """原本に対応する受信情報の欠落を検出する(復元・推測はしない)。"""
110
+ output = resource.stack.output
111
+ if not output:
112
+ raise click.ClickException("受信スタックがありません")
113
+ bucket = output["Bucket"]
114
+ s3 = boto3.client("s3", region_name=resource.context.region)
115
+ config = resource.context.config
116
+ metadata = {
117
+ item["Key"]
118
+ for page in s3.get_paginator("list_objects_v2").paginate(
119
+ Bucket=bucket, Prefix=config.metadata_prefix
120
+ )
121
+ for item in page.get("Contents", [])
122
+ }
123
+ missing = 0
124
+ for page in s3.get_paginator("list_objects_v2").paginate(
125
+ Bucket=bucket, Prefix=config.raw_prefix
126
+ ):
127
+ for item in page.get("Contents", []):
128
+ if item["Key"] == config.raw_prefix + SETUP_NOTIFICATION:
129
+ continue
130
+ identifier = receipt_id(bucket, item["Key"])
131
+ if config.metadata_prefix + identifier + ".json" not in metadata:
132
+ click.echo(f"受信情報なし: {item['Key']} / {identifier}")
133
+ missing += 1
134
+ if missing:
135
+ raise click.ClickException(
136
+ f"{missing}件の受信情報がありません。通知の遅延・DLQを確認してください"
137
+ )
138
+ click.echo("原本に対応する受信情報を確認しました。")
139
+
140
+
141
+ @inbound.command(name="copy")
142
+ @click.option("--receipt-id", "identifier", required=True)
143
+ @click.option("--to-stage", required=True)
144
+ @click.option("--to-name", required=True)
145
+ @click.pass_obj
146
+ def copy_receipt(resource, identifier, to_stage, to_name):
147
+ """指定した1件の原本と受信情報を別stageへコピーしmanifestを最後に保存する。"""
148
+ validate_receipt_id(identifier)
149
+ settings = Settings.from_toml(stage=to_stage)
150
+ if to_name not in settings.inbound:
151
+ raise click.ClickException(f"コピー先のinbound.{to_name}がありません")
152
+ target = Inbound(InboundContext.from_settings(to_name, settings))
153
+ source_output, target_output = resource.stack.output, target.stack.output
154
+ if not source_output or not target_output:
155
+ raise click.ClickException("コピー元と先の受信スタックが必要です")
156
+ source = boto3.client("s3", region_name=resource.context.region)
157
+ destination = boto3.client("s3", region_name=target.context.region)
158
+ key = resource.context.config.metadata_prefix + identifier + ".json"
159
+ metadata_bytes = source.get_object(Bucket=source_output["Bucket"], Key=key)[
160
+ "Body"
161
+ ].read()
162
+ metadata = json.loads(metadata_bytes)
163
+ reference = metadata["source"]
164
+ if reference["bucket"] != source_output["Bucket"] or not reference[
165
+ "key"
166
+ ].startswith(resource.context.config.raw_prefix):
167
+ raise click.ClickException("原本の参照先が設定と一致しません")
168
+ raw = source.get_object(
169
+ Bucket=reference["bucket"],
170
+ Key=reference["key"],
171
+ VersionId=reference["version_id"],
172
+ )["Body"].read()
173
+ if (
174
+ metadata["receipt_id"] != identifier
175
+ or hashlib.sha256(raw).hexdigest() != reference["sha256"]
176
+ ):
177
+ raise click.ClickException("原本のhashまたは受信IDが一致しません")
178
+ # 同じ受信IDのcopyは同じ内容になる。manifestが揃うまで解析ジョブを投入しない。
179
+ prefix = target.context.config.import_prefix + identifier + "/"
180
+ for suffix, body, content_type in (
181
+ ("raw.eml", raw, "message/rfc822"),
182
+ ("metadata.json", metadata_bytes, "application/json"),
183
+ (
184
+ "manifest.json",
185
+ json.dumps({"schema_version": 1, "receipt_id": identifier}).encode(),
186
+ "application/json",
187
+ ),
188
+ ):
189
+ destination.put_object(
190
+ Bucket=target_output["Bucket"],
191
+ Key=prefix + suffix,
192
+ Body=body,
193
+ ContentType=content_type,
194
+ )
195
+ click.echo(prefix + "manifest.json")
196
+ click.echo(
197
+ "コピー完了。取り込み用handlerからReceiver.load_importで明示的に解析してください。"
198
+ )
199
+
200
+
201
+ @inbound.command(name="import")
202
+ @click.option("--manifest-key", required=True)
203
+ @click.option("--handler", "handler_ref", required=True, help="取り込み用handlerの参照")
204
+ @click.pass_context
205
+ def import_receipt(ctx, manifest_key, handler_ref):
206
+ """コピー済みmanifestを指定し、利用側の取り込みhandlerを同期実行する。"""
207
+ resource = ctx.obj
208
+ settings = ctx.meta["settings"]
209
+ container, key, _ = settings.resolve_handler(handler_ref)
210
+ receiving_container, _ = parse_handler_ref(resource.context.config.handler)
211
+ if (
212
+ container != receiving_container
213
+ or handler_ref == resource.context.config.handler
214
+ ):
215
+ raise click.ClickException(
216
+ "受信workerと同じcontainerの別handlerを指定してください"
217
+ )
218
+ if not manifest_key.startswith(
219
+ resource.context.config.import_prefix
220
+ ) or not manifest_key.endswith("/manifest.json"):
221
+ raise click.ClickException("imports内のmanifest.jsonを指定してください")
222
+ prefix = settings.prefix_template.format(**settings.format_vars)
223
+ client = boto3.client(
224
+ "lambda",
225
+ region_name=resource.context.region,
226
+ config=Config(read_timeout=900, retries={"total_max_attempts": 1}),
227
+ )
228
+ response = client.invoke(
229
+ FunctionName=f"{prefix}{container}-{key}",
230
+ InvocationType="RequestResponse",
231
+ Payload=json.dumps(
232
+ {"inbound": resource.context.name, "manifest_key": manifest_key}
233
+ ).encode(),
234
+ )
235
+ response["Payload"].read()
236
+ if response.get("FunctionError"):
237
+ raise click.ClickException(
238
+ "取り込みhandlerが失敗しました。Lambdaログを確認してください"
239
+ )
240
+ click.echo("取り込みhandlerの実行が完了しました。")
241
+
242
+
243
+ @inbound.command()
244
+ @click.option("--queue", type=click.Choice(["delivery", "worker"]), required=True)
245
+ @click.option("--limit", type=click.IntRange(1, 100), default=10, show_default=True)
246
+ @click.pass_obj
247
+ def redrive(resource, queue, limit):
248
+ """DLQの保存形式を検証し、受信queueへ戻す。成功した送信だけ削除する。"""
249
+ output = resource.stack.output
250
+ if not output:
251
+ raise click.ClickException("受信スタックがありません")
252
+ sqs = boto3.client("sqs", region_name=resource.context.region)
253
+ source = output["DeliveryDLQUrl" if queue == "delivery" else "WorkerDLQUrl"]
254
+ count = 0
255
+ for _ in range(limit):
256
+ messages = sqs.receive_message(
257
+ QueueUrl=source, MaxNumberOfMessages=1, VisibilityTimeout=60
258
+ ).get("Messages", [])
259
+ if not messages:
260
+ break
261
+ message = messages[0]
262
+ envelope = json.loads(message["Body"])
263
+ if (
264
+ envelope.get("Type") != "Notification"
265
+ or envelope.get("TopicArn") != output["TopicArn"]
266
+ ):
267
+ raise click.ClickException(
268
+ "想定外の通知形式です。DLQから削除せず中止します"
269
+ )
270
+ notification = json.loads(envelope["Message"])
271
+ action = notification.get("receipt", {}).get("action", {})
272
+ if (
273
+ notification.get("notificationType") != "Received"
274
+ or action.get("type") != "S3"
275
+ or action.get("bucketName") != output["Bucket"]
276
+ or not action.get("objectKey", "").startswith(
277
+ resource.context.config.raw_prefix
278
+ )
279
+ ):
280
+ raise click.ClickException(
281
+ "想定外のSES保存先です。DLQから削除せず中止します"
282
+ )
283
+ sqs.send_message(QueueUrl=output["QueueUrl"], MessageBody=message["Body"])
284
+ sqs.delete_message(QueueUrl=source, ReceiptHandle=message["ReceiptHandle"])
285
+ count += 1
286
+ click.echo(
287
+ f"{count}件を受信queueへ再投入しました。重複実行に備え業務側で冪等化してください。"
288
+ )
@@ -16,6 +16,7 @@ except ModuleNotFoundError as e:
16
16
 
17
17
  from pocket_cli.cli import (
18
18
  backup_cli,
19
+ build_cli,
19
20
  cloudfront_cli,
20
21
  cloudfront_keys_cli,
21
22
  cloudfront_waf_cli,
@@ -24,6 +25,7 @@ from pocket_cli.cli import (
24
25
  destroy_cli,
25
26
  dsql_cli,
26
27
  image_cli,
28
+ inbound_cli,
27
29
  migrate_cli,
28
30
  neon_cli,
29
31
  permissions_cli,
@@ -121,6 +123,7 @@ def settings(stage):
121
123
  print(json.dumps(mask_secret_values(s.model_dump(mode="json")), indent=2))
122
124
 
123
125
 
126
+ main.add_command(build_cli.build)
124
127
  main.add_command(deploy_cli.deploy)
125
128
  main.add_command(deploy_cli.promote)
126
129
  main.add_command(destroy_cli.destroy)
@@ -138,6 +141,7 @@ def resource():
138
141
  pass
139
142
 
140
143
 
144
+ resource.add_command(inbound_cli.inbound)
141
145
  resource.add_command(vpc_cli.vpc)
142
146
  resource.add_command(container_cli.container)
143
147
  resource.add_command(neon_cli.neon)
@@ -5,9 +5,25 @@ from pocket.resources.aws.secretsmanager import PocketSecretIsNotReady
5
5
  from pocket.utils import echo
6
6
  from pocket_cli.cli.deploy_cli import get_resources
7
7
  from pocket_cli.resources.container import Container
8
+ from pocket_cli.resources.inbound import echo_inbound_details
9
+ from pocket_cli.resources.neon import Neon
8
10
  from pocket_cli.resources.sqs_alert import echo_dead_letter_alert_statuses
9
11
 
10
12
 
13
+ def skip_reason(resource) -> str | None:
14
+ """status を取らずに skip する理由 (無ければ None)。
15
+
16
+ 外部 provider の資格情報が無い環境 (例: NEON_API_KEY を持たない VM) でも、
17
+ stack の状態確認に provider は無関係なので止めずに続行する (KN1456)。
18
+ """
19
+ if isinstance(resource, Neon) and not resource.has_credential:
20
+ return (
21
+ "Neon status: NEON_API_KEY が未設定のため状態確認をスキップします "
22
+ "(stack の状態確認・削除に Neon の資格情報は不要です)"
23
+ )
24
+ return None
25
+
26
+
11
27
  def show_status_message(resource):
12
28
  target_name = resource.__class__.__name__
13
29
  message = f"{target_name} status: {resource.status}"
@@ -54,8 +70,13 @@ def status(stage, show_info):
54
70
  context = Context.from_toml(stage=stage)
55
71
  resources = get_resources(context)
56
72
  for resource in resources:
73
+ reason = skip_reason(resource)
74
+ if reason:
75
+ echo.warning(reason)
76
+ continue
57
77
  show_status_message(resource)
58
78
  if show_info:
59
79
  show_info_message(resource)
60
80
  # DLQ アラートの email 購読状態 (PendingConfirmation のままだと通知が届かない)
61
81
  echo_dead_letter_alert_statuses(context)
82
+ echo_inbound_details(context)