magic-pocket-cli 0.40.0__tar.gz → 0.41.1__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 (93) hide show
  1. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/PKG-INFO +2 -2
  2. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/cli/deploy_cli.py +102 -8
  3. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/cli/destroy_cli.py +1 -0
  4. magic_pocket_cli-0.41.1/pocket_cli/cli/permissions_cli.py +96 -0
  5. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/django_cli.py +69 -17
  6. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/aws/builders/__init__.py +2 -0
  7. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/aws/builders/codebuild.py +20 -13
  8. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/aws/cloudformation.py +88 -39
  9. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/aws/iam_roles.py +293 -49
  10. magic_pocket_cli-0.41.1/pocket_cli/resources/aws/stage_roles.py +88 -0
  11. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/backup.py +5 -1
  12. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/cloudfront.py +11 -2
  13. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/container.py +1 -0
  14. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/dsql.py +1 -0
  15. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/rds.py +0 -10
  16. magic_pocket_cli-0.41.1/pocket_cli/templates/cloudformation/cf_function_cache_control.js +8 -0
  17. magic_pocket_cli-0.41.1/pocket_cli/templates/cloudformation/cf_function_deploy_hash_strip.js +19 -0
  18. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/templates/cloudformation/cloudfront.yaml +69 -28
  19. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/templates/cloudformation/container.yaml +10 -4
  20. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pyproject.toml +2 -2
  21. magic_pocket_cli-0.40.0/pocket_cli/cli/permissions_cli.py +0 -46
  22. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/.gitignore +0 -0
  23. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/__init__.py +0 -0
  24. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/cli/__init__.py +0 -0
  25. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/cli/aws_auth.py +0 -0
  26. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/cli/backup_cli.py +0 -0
  27. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/cli/build_cli.py +0 -0
  28. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/cli/cleanup_deprecated_cli.py +0 -0
  29. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/cli/cloudfront_cli.py +0 -0
  30. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/cli/cloudfront_keys_cli.py +0 -0
  31. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/cli/cloudfront_waf_cli.py +0 -0
  32. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/cli/container_cli.py +0 -0
  33. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/cli/dsql_cli.py +0 -0
  34. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/cli/image_cli.py +0 -0
  35. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/cli/inbound_cli.py +0 -0
  36. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/cli/interaction.py +0 -0
  37. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/cli/main_cli.py +0 -0
  38. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/cli/migrate_cli.py +0 -0
  39. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/cli/neon_cli.py +0 -0
  40. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/cli/rds_cli.py +0 -0
  41. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/cli/removed_flags.py +0 -0
  42. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/cli/resource_helper.py +0 -0
  43. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/cli/runtime_config_cli.py +0 -0
  44. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/cli/s3_cli.py +0 -0
  45. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/cli/status_cli.py +0 -0
  46. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/cli/store_url_helper.py +0 -0
  47. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/cli/tidb_cli.py +0 -0
  48. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/cli/upstash_cli.py +0 -0
  49. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/cli/url_helper.py +0 -0
  50. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/cli/vpc_cli.py +0 -0
  51. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/cli/waf_cli.py +0 -0
  52. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/mediator.py +0 -0
  53. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/migrations.py +0 -0
  54. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/__init__.py +0 -0
  55. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/aws/__init__.py +0 -0
  56. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/aws/backup_common.py +0 -0
  57. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/aws/backup_legacy.py +0 -0
  58. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/aws/builders/context_check.py +0 -0
  59. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/aws/builders/depot.py +0 -0
  60. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/aws/builders/docker.py +0 -0
  61. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/aws/builders/dockerignore.py +0 -0
  62. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/aws/ecr.py +0 -0
  63. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/aws/efs.py +0 -0
  64. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/aws/lambdahandler.py +0 -0
  65. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/aws/poll.py +0 -0
  66. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/aws/s3_utils.py +0 -0
  67. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/aws/stack_backed.py +0 -0
  68. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/aws/state.py +0 -0
  69. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/cloudfront_acm.py +0 -0
  70. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/cloudfront_keys.py +0 -0
  71. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/cloudfront_waf.py +0 -0
  72. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/inbound.py +0 -0
  73. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/inbound_domain.py +0 -0
  74. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/inbound_template.py +0 -0
  75. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/neon.py +0 -0
  76. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/s3.py +0 -0
  77. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/sqs_alert.py +0 -0
  78. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/tidb.py +0 -0
  79. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/upstash.py +0 -0
  80. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/resources/vpc.py +0 -0
  81. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/templates/cloudformation/cf_function_api_host.js +0 -0
  82. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/templates/cloudformation/cf_function_basic_auth_prelude.js +0 -0
  83. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/templates/cloudformation/cf_function_host_redirect_prelude.js +0 -0
  84. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/templates/cloudformation/cf_function_spa_auth.js +0 -0
  85. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/templates/cloudformation/cf_function_spa_fallback.js +0 -0
  86. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/templates/cloudformation/cloudfront_acm.yaml +0 -0
  87. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/templates/cloudformation/cloudfront_keys.yaml +0 -0
  88. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/templates/cloudformation/cloudfront_waf.yaml +0 -0
  89. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/templates/cloudformation/vpc.yaml +0 -0
  90. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/templates/init/django-dotenv.env +0 -0
  91. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/templates/init/django-settings.py +0 -0
  92. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/pocket_cli/templates/init/pocket.Dockerfile +0 -0
  93. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.1}/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.40.0
3
+ Version: 0.41.1
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.41,>=0.40.0
10
+ Requires-Dist: magic-pocket<0.42,>=0.41.1
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
@@ -1,5 +1,6 @@
1
1
  import inspect
2
2
  import webbrowser
3
+ from collections.abc import Callable
3
4
 
4
5
  import click
5
6
 
@@ -12,6 +13,7 @@ from pocket_cli.mediator import Mediator
12
13
  from pocket_cli.resources.aws.builders.context_check import (
13
14
  resummarize_world_read_warnings,
14
15
  )
16
+ from pocket_cli.resources.aws.stage_roles import verify_stage_roles
15
17
  from pocket_cli.resources.aws.state import StateStore, create_state_store
16
18
  from pocket_cli.resources.backup import Backup
17
19
  from pocket_cli.resources.cloudfront import CloudFront
@@ -112,15 +114,53 @@ def deploy_init_resources(context: Context, *, state_bucket: str = ""):
112
114
  resource.deploy_init()
113
115
 
114
116
 
115
- def deploy_frontend(context: Context, *, skip_build: bool = False):
117
+ def deploy_frontend(
118
+ context: Context,
119
+ *,
120
+ skip_build: bool = False,
121
+ exclude: frozenset[tuple[str, str]] = frozenset(),
122
+ ):
123
+ """uploadable な route を upload する。
124
+
125
+ exclude は upload 済みの (cloudfront, route) で、対象から外す。
126
+ """
116
127
  for _name, cf_ctx in context.cloudfront.items():
117
- cf = CloudFront(cf_ctx)
118
- if not cf_ctx.uploadable_routes:
128
+ routes = [
129
+ r for r in cf_ctx.uploadable_routes if (cf_ctx.name, r.name) not in exclude
130
+ ]
131
+ if not routes:
119
132
  continue
133
+ cf = CloudFront(cf_ctx)
120
134
  if cf.status == "NOEXIST":
121
135
  echo.warning("CloudFront '%s' が未作成です。スキップします。" % cf_ctx.name)
122
136
  continue
123
- cf.upload(skip_build=skip_build)
137
+ cf.upload(skip_build=skip_build, routes=routes)
138
+
139
+
140
+ def deploy_versioned_frontend(context: Context) -> frozenset[tuple[str, str]]:
141
+ """versioning route を Lambda 更新より先に upload する。
142
+
143
+ Lambda が新しい版の URL を出し始めた後に upload すると、その間に読まれた
144
+ ファイルは upload 前の内容 (新規ファイルなら 403) が新しい URL のキャッシュに入り、
145
+ ブラウザに versioned_max_age の間残る (KN1676)。versioning route の URL は版ごとに
146
+ 変わるので、先に upload しても稼働中の版の表示は壊れない。
147
+
148
+ SPA など versioning の無い route は対象外 (HTML が Lambda より先に切り替わる)。
149
+ CloudFront が未作成 (初回 deploy) の場合は upload せず、deploy_frontend に任せる。
150
+ 戻り値は upload した (cloudfront, route) で、deploy_frontend の exclude に渡す。
151
+ """
152
+ uploaded: set[tuple[str, str]] = set()
153
+ for _name, cf_ctx in context.cloudfront.items():
154
+ routes = [r for r in cf_ctx.uploadable_routes if r.versioning]
155
+ if not routes:
156
+ continue
157
+ cf = CloudFront(cf_ctx)
158
+ if cf.status == "NOEXIST":
159
+ continue
160
+ echo.log("versioning route を Lambda 更新より先に upload します...")
161
+ cf.upload(routes=routes)
162
+ uploaded.update((cf_ctx.name, r.name) for r in routes)
163
+ return frozenset(uploaded)
124
164
 
125
165
 
126
166
  def upload_managed_assets(context: Context):
@@ -215,11 +255,20 @@ def build_image(context: Context, *, tag: str) -> list[str]:
215
255
  return targets
216
256
 
217
257
 
218
- def _deploy_pipeline(context: Context, *, openpath=None, skip_frontend=False):
258
+ def _deploy_pipeline(
259
+ context: Context,
260
+ *,
261
+ openpath=None,
262
+ skip_frontend=False,
263
+ before_lambda_update: Callable[[], None] | None = None,
264
+ ):
219
265
  """deploy / promote 共通のパイプライン本体。
220
266
 
221
267
  promote 時は各 container の promote_commit_hash が設定済みで、
222
268
  deploy_init 内の image build が retag に置き換わる以外は deploy と同一。
269
+
270
+ before_lambda_update は image build の後・Lambda 更新 (deploy_resources) の前に
271
+ 呼ばれる。`pocket django deploy` が static を先に upload するのに使う。
223
272
  """
224
273
  # DEPLOY_HASH の解決結果を deploy 時に 1 回可視化する (env 伝播漏れで
225
274
  # 黙って git short hash に落ちる footgun の早期発見用)。
@@ -230,12 +279,20 @@ def _deploy_pipeline(context: Context, *, openpath=None, skip_frontend=False):
230
279
  deploy_hash_message = deploy_hash_report(context)
231
280
  if deploy_hash_message:
232
281
  echo.info(deploy_hash_message)
282
+ # [iam] external_roles: 事前に作られた role を、何かを変更する前に検査する
283
+ verify_stage_roles(context)
233
284
  # CodeBuildがソースアップロードにstate bucketを必要とするため、先に作成
234
285
  state_store = _create_state_store(context)
235
286
  state_store.ensure_bucket()
236
287
  state_bucket = state_store.bucket_name
237
288
  check_removed_inbound(context, state_store)
238
289
  deploy_init_resources(context, state_bucket=state_bucket)
290
+ # static は Lambda が新しい版の URL を出し始める前に upload する (KN1676)
291
+ uploaded_routes: frozenset[tuple[str, str]] = frozenset()
292
+ if not skip_frontend:
293
+ uploaded_routes = deploy_versioned_frontend(context)
294
+ if before_lambda_update is not None:
295
+ before_lambda_update()
239
296
  deploy_resources(context, state_bucket=state_bucket)
240
297
  cleanup_unused_inbound_domains(context, state_store)
241
298
  # リリース跨ぎ移行の掃除フェーズ (旧配置の削除は deploy 成功後にしか
@@ -243,7 +300,7 @@ def _deploy_pipeline(context: Context, *, openpath=None, skip_frontend=False):
243
300
  migrations.run_deploy_cleanup(context)
244
301
  upload_managed_assets(context)
245
302
  if not skip_frontend:
246
- deploy_frontend(context)
303
+ deploy_frontend(context, exclude=uploaded_routes)
247
304
  # build 時の other-read 警告はログに埋もれて気付けないため最後に再掲する
248
305
  resummarize_world_read_warnings()
249
306
  # DLQ アラートの email 購読が未確認のままだと通知が届かないため、
@@ -269,12 +326,29 @@ def _deploy_pipeline(context: Context, *, openpath=None, skip_frontend=False):
269
326
  )
270
327
  @removed_skip_check_existing
271
328
  def deploy(stage: str, openpath, skip_frontend, yes):
329
+ run_deploy(stage, openpath=openpath, skip_frontend=skip_frontend, yes=yes)
330
+
331
+
332
+ def run_deploy(
333
+ stage: str,
334
+ *,
335
+ openpath=None,
336
+ skip_frontend: bool = False,
337
+ yes: bool = False,
338
+ before_lambda_update: Callable[[], None] | None = None,
339
+ ):
340
+ """`pocket deploy` の本体。`pocket django deploy` からも呼ばれる。"""
272
341
  from pocket_cli.cli.aws_auth import check_aws_credentials
273
342
 
274
343
  interaction.set_assume_yes(yes)
275
344
  check_aws_credentials()
276
345
  context = Context.from_toml(stage=stage)
277
- _deploy_pipeline(context, openpath=openpath, skip_frontend=skip_frontend)
346
+ _deploy_pipeline(
347
+ context,
348
+ openpath=openpath,
349
+ skip_frontend=skip_frontend,
350
+ before_lambda_update=before_lambda_update,
351
+ )
278
352
 
279
353
 
280
354
  @click.command()
@@ -292,6 +366,21 @@ def promote(stage: str, commit_hash, openpath, skip_frontend, yes):
292
366
  `pocket build` で push した image に :<stage> タグを移し、
293
367
  インフラ/Lambda を更新する。image build は行わない (build once の昇格)。
294
368
  """
369
+ run_promote(
370
+ stage, commit_hash, openpath=openpath, skip_frontend=skip_frontend, yes=yes
371
+ )
372
+
373
+
374
+ def run_promote(
375
+ stage: str,
376
+ commit_hash: str,
377
+ *,
378
+ openpath=None,
379
+ skip_frontend: bool = False,
380
+ yes: bool = False,
381
+ before_lambda_update: Callable[[], None] | None = None,
382
+ ):
383
+ """`pocket promote` の本体。`pocket django promote` からも呼ばれる。"""
295
384
  from pocket_cli.cli.aws_auth import check_aws_credentials
296
385
 
297
386
  interaction.set_assume_yes(yes)
@@ -301,7 +390,12 @@ def promote(stage: str, commit_hash, openpath, skip_frontend, yes):
301
390
  raise click.ClickException("container がこの stage に設定されていません。")
302
391
  for c_ctx in context.container.values():
303
392
  c_ctx.promote_commit_hash = commit_hash
304
- _deploy_pipeline(context, openpath=openpath, skip_frontend=skip_frontend)
393
+ _deploy_pipeline(
394
+ context,
395
+ openpath=openpath,
396
+ skip_frontend=skip_frontend,
397
+ before_lambda_update=before_lambda_update,
398
+ )
305
399
 
306
400
 
307
401
  def _get_deploy_url(context: Context) -> str | None:
@@ -45,6 +45,7 @@ def _create_codebuild_builder(context: Context) -> CodeBuildBuilder | None:
45
45
  resource_prefix=resource_prefix,
46
46
  state_bucket=f"{resource_prefix}state",
47
47
  permissions_boundary=permissions_boundary,
48
+ external_roles=context.external_roles,
48
49
  )
49
50
 
50
51
 
@@ -0,0 +1,96 @@
1
+ """`pocket permissions` サブコマンド群。
2
+
3
+ - `list`: `pocket.toml` の構成から deploy に必要な AWS IAM Action を出力する。
4
+ 外部の IAM Role プロビジョニング処理が GitHub Actions デプロイ用 IAM Role を
5
+ 作る際の inline policy 生成に使用することを想定。
6
+ - `roles`: pocket の Lambda / scheduler / CodeBuild / AWS Backup が使う IAM role
7
+ の定義を出力する。`[iam] external_roles = true` で role を事前に作る用途。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+
14
+ import boto3
15
+ import click
16
+
17
+ from pocket.context import Context
18
+ from pocket.permissions import compute_actions
19
+ from pocket.settings import Settings
20
+ from pocket_cli.resources.aws.stage_roles import stage_role_specs
21
+
22
+
23
+ @click.group()
24
+ def permissions():
25
+ """IAM 権限関連のサブコマンド。"""
26
+
27
+
28
+ @permissions.command("list")
29
+ @click.option("--stage", envvar="POCKET_DEPLOY_STAGE", prompt=True)
30
+ @click.option(
31
+ "--format",
32
+ "format_",
33
+ type=click.Choice(["text", "json"]),
34
+ default="text",
35
+ show_default=True,
36
+ help='出力形式。text: 1 行 1 Action / json: {"actions": [...]}',
37
+ )
38
+ def list_(stage: str, format_: str):
39
+ """pocket.toml から必要な AWS Action 一覧を出力する。
40
+
41
+ docs/permissions/aws.md のテーブルに基づき、`[cloudfront]` / `[rds]` /
42
+ `[ses]` などの設定有無に応じて必要 Action を組み立てる。粒度は
43
+ ワイルドカード中心 (`cloudformation:*` 等)。
44
+ """
45
+ settings = Settings.from_toml(stage=stage)
46
+ actions = compute_actions(settings)
47
+ if format_ == "json":
48
+ click.echo(json.dumps({"actions": actions}, indent=2))
49
+ else:
50
+ for action in actions:
51
+ click.echo(action)
52
+
53
+
54
+ @permissions.command("roles")
55
+ @click.option("--stage", envvar="POCKET_DEPLOY_STAGE", prompt=True)
56
+ @click.option(
57
+ "--account-id",
58
+ help="role の ARN と policy に埋め込む AWS account ID。省略時は"
59
+ " sts:GetCallerIdentity で取得する。",
60
+ )
61
+ def roles(stage: str, account_id: str | None):
62
+ """pocket が使う IAM role の定義を JSON で出力する。
63
+
64
+ role ごとに名前・ARN・信頼ポリシー・managed policy・inline policy を出す。
65
+ policy 中の CloudFormation 変数 (${AWS::Region} 等) は展開済みなので、
66
+ そのまま IAM の CreateRole / PutRolePolicy に渡せる。`[iam] external_roles
67
+ = true` の stage では、deploy がこの定義と実際の role を突き合わせる。
68
+ """
69
+ context = Context.from_toml(stage=stage)
70
+ if not context.general:
71
+ raise click.ClickException("[general] が見つかりません")
72
+ region = context.general.region
73
+ account = (
74
+ account_id
75
+ or (boto3.client("sts", region_name=region).get_caller_identity()["Account"])
76
+ )
77
+ specs = stage_role_specs(context, account_id=account)
78
+ output = {
79
+ "stage": stage,
80
+ "region": region,
81
+ "account_id": account,
82
+ "external_roles": context.external_roles,
83
+ "roles": [
84
+ {
85
+ "name": spec.name,
86
+ "arn": f"arn:aws:iam::{account}:role/{spec.name}",
87
+ "assume_role_policy": spec.assume_role_policy,
88
+ "managed_policy_arns": spec.managed_policy_arns,
89
+ "inline_policies": spec.resolved_inline_policies(
90
+ region=region, account_id=account
91
+ ),
92
+ }
93
+ for spec in specs
94
+ ],
95
+ }
96
+ click.echo(json.dumps(output, indent=2, ensure_ascii=False))
@@ -16,8 +16,10 @@ from pocket.django.utils import get_storages, resolve_django_container
16
16
  from pocket.utils import echo
17
17
  from pocket_cli.cli import interaction
18
18
  from pocket_cli.cli.build_cli import build
19
+ from pocket_cli.cli.deploy_cli import run_deploy, run_promote
19
20
  from pocket_cli.cli.removed_flags import removed_skip_check_existing
20
21
  from pocket_cli.resources.container import Container
22
+ from pocket_cli.resources.s3 import S3
21
23
 
22
24
 
23
25
  @click.group()
@@ -92,23 +94,65 @@ def _update_dotenv(jinja2_env: Environment):
92
94
  )
93
95
  @removed_skip_check_existing
94
96
  def deploy(stage: str, openpath, yes, skip_migrate):
95
- from pocket_cli.cli.deploy_cli import deploy as pocket_deploy
96
-
97
97
  # pocket deploy を実行(インフラ + SPA フロントエンド)
98
- ctx = click.Context(pocket_deploy)
99
- ctx.invoke(
100
- pocket_deploy,
101
- stage=stage,
98
+ static_handled: list[bool] = []
99
+ run_deploy(
100
+ stage,
102
101
  openpath=None,
103
102
  skip_frontend=False,
104
103
  yes=yes,
104
+ before_lambda_update=lambda: static_handled.append(
105
+ _deploystatic_before_lambda_update(stage)
106
+ ),
107
+ )
108
+ _django_post_deploy(
109
+ stage,
110
+ yes=yes,
111
+ openpath=openpath,
112
+ skip_migrate=skip_migrate,
113
+ static_handled=any(static_handled),
105
114
  )
106
- _django_post_deploy(stage, yes=yes, openpath=openpath, skip_migrate=skip_migrate)
107
115
 
108
116
 
109
- def _django_post_deploy(stage: str, *, yes: bool, openpath, skip_migrate: bool = False):
117
+ def _deploystatic_before_lambda_update(stage: str) -> bool:
118
+ """static を Lambda 更新より先に upload する。deploystatic を済ませたら True。
119
+
120
+ Lambda が新しい版の URL を出し始めた後に upload すると、その間に読まれた
121
+ ファイルは upload 前の内容が新しい URL のキャッシュに入り、ブラウザに長期間残る
122
+ (KN1676)。collectstatic の失敗も Lambda を更新する前に分かる。
123
+
124
+ bucket が未作成 (初回 deploy) の場合と、staticfiles が S3 でない場合は False を
125
+ 返し、従来どおり deploy 後の `_django_post_deploy` に任せる。確認に No と
126
+ 答えた場合も True を返す (deploy 後にもう一度聞かない)。
127
+ """
128
+ context = Context.from_toml(stage=stage)
129
+ if _staticfiles_publish_mode(context) == "command":
130
+ return False
131
+ c = resolve_django_container(context)
132
+ storage = c.django.storages.get("staticfiles") if c and c.django else None
133
+ if not storage or storage.store != "s3":
134
+ return False
135
+ if not context.s3 or not S3(context.s3).exists():
136
+ return False
137
+ if interaction.confirm("deploystatic?", default=True):
138
+ collectstatic_locally(stage, link=_staticfiles_link(context))
139
+ upload_collected_staticfiles(stage)
140
+ return True
141
+
142
+
143
+ def _django_post_deploy(
144
+ stage: str,
145
+ *,
146
+ yes: bool,
147
+ openpath,
148
+ skip_migrate: bool = False,
149
+ static_handled: bool = False,
150
+ ):
110
151
  """deploy / promote 共通の Django 固有後処理 (collectstatic + migrate + URL)。
111
152
 
153
+ static_handled=True は、Lambda 更新の前に deploystatic を済ませた
154
+ (`_deploystatic_before_lambda_update`) ことを表し、ここでは繰り返さない。
155
+
112
156
  skip_migrate=True なら migrate は確認ごと省く。`-y` は「聞かれたことに全部
113
157
  yes」の意味を保ちたいので、非対話で migrate を外す手段はフラグ側に置く
114
158
  (DB が到達不能でもインフラ更新だけ通したい、という状況が実在する)。
@@ -116,7 +160,9 @@ def _django_post_deploy(stage: str, *, yes: bool, openpath, skip_migrate: bool =
116
160
  if yes:
117
161
  interaction.set_assume_yes(True)
118
162
  context = Context.from_toml(stage=stage)
119
- if _staticfiles_publish_mode(context) == "command":
163
+ if static_handled:
164
+ pass
165
+ elif _staticfiles_publish_mode(context) == "command":
120
166
  echo.info(
121
167
  'staticfiles is publish = "command": skipping deploystatic. '
122
168
  "Publish with `pocket django deploystatic --stage %s`." % stage
@@ -163,18 +209,24 @@ def promote(stage: str, commit_hash, openpath, yes, skip_migrate):
163
209
  を更新する。image build は行わない (build once の昇格)。静的アセットは deploy 時
164
210
  と同様にローカルでビルドして upload する。
165
211
  """
166
- from pocket_cli.cli.deploy_cli import promote as pocket_promote
167
-
168
- ctx = click.Context(pocket_promote)
169
- ctx.invoke(
170
- pocket_promote,
171
- stage=stage,
172
- commit_hash=commit_hash,
212
+ static_handled: list[bool] = []
213
+ run_promote(
214
+ stage,
215
+ commit_hash,
173
216
  openpath=None,
174
217
  skip_frontend=False,
175
218
  yes=yes,
219
+ before_lambda_update=lambda: static_handled.append(
220
+ _deploystatic_before_lambda_update(stage)
221
+ ),
222
+ )
223
+ _django_post_deploy(
224
+ stage,
225
+ yes=yes,
226
+ openpath=openpath,
227
+ skip_migrate=skip_migrate,
228
+ static_handled=any(static_handled),
176
229
  )
177
- _django_post_deploy(stage, yes=yes, openpath=openpath, skip_migrate=skip_migrate)
178
230
 
179
231
 
180
232
  # 旧コマンドは同じ Click command の別名として維持する。
@@ -27,6 +27,7 @@ def create_builder(
27
27
  resource_prefix: str,
28
28
  state_bucket: str,
29
29
  permissions_boundary: str | None = None,
30
+ external_roles: bool = False,
30
31
  ) -> Builder:
31
32
  backend = build_context.backend
32
33
 
@@ -44,6 +45,7 @@ def create_builder(
44
45
  state_bucket=state_bucket,
45
46
  compute_type=build_context.compute_type,
46
47
  permissions_boundary=permissions_boundary,
48
+ external_roles=external_roles,
47
49
  )
48
50
 
49
51
  if backend == "depot":
@@ -50,6 +50,7 @@ class CodeBuildBuilder:
50
50
  state_bucket: str,
51
51
  compute_type: str = "BUILD_GENERAL1_MEDIUM",
52
52
  permissions_boundary: str | None = None,
53
+ external_roles: bool = False,
53
54
  ) -> None:
54
55
  self.region = region
55
56
  self.resource_prefix = resource_prefix
@@ -58,6 +59,8 @@ class CodeBuildBuilder:
58
59
  self.permissions_boundary = (
59
60
  os.environ.get("CODEBUILD_PERMISSIONS_BOUNDARY") or permissions_boundary
60
61
  )
62
+ # [iam] external_roles。role は利用者が事前に作り、pocket は作成も削除もしない
63
+ self.external_roles = external_roles
61
64
 
62
65
  self.codebuild = boto3.client("codebuild", region_name=region)
63
66
  self.iam = boto3.client("iam", region_name=region)
@@ -91,7 +94,8 @@ class CodeBuildBuilder:
91
94
  role_arn = self._ensure_role(account_id)
92
95
  self._ensure_project(platform, role_arn)
93
96
  # project の serviceRole を新ロールへ付け替えた後なので、旧名ロールは孤児
94
- self._delete_role(self._legacy_role_name)
97
+ if not self.external_roles:
98
+ self._delete_role(self._legacy_role_name)
95
99
  self._upload_source(dockerfile_path)
96
100
 
97
101
  build_id = self._start_build(
@@ -106,26 +110,29 @@ class CodeBuildBuilder:
106
110
  print("CodeBuild ビルド完了")
107
111
 
108
112
  def delete(self) -> None:
109
- """CodeBuildプロジェクトとIAMロールを削除"""
113
+ """CodeBuildプロジェクトとIAMロールを削除 (external_roles の role は残す)"""
110
114
  self._delete_project()
115
+ if self.external_roles:
116
+ return
111
117
  self._delete_role(self._role_name)
112
118
  self._delete_role(self._legacy_role_name)
113
119
 
114
120
  # --- IAM ロール ---
115
121
 
116
- def _ensure_role(self, account_id: str) -> str:
117
- return iam_roles.ensure_role(
118
- self.iam,
119
- iam_roles.codebuild_role(
120
- name=self._role_name,
121
- region=self.region,
122
- account_id=account_id,
123
- state_bucket=self.state_bucket,
124
- project_name=self._project_name,
125
- permissions_boundary=self.permissions_boundary,
126
- ),
122
+ def role_spec(self, account_id: str) -> iam_roles.RoleSpec:
123
+ return iam_roles.codebuild_role(
124
+ name=self._role_name,
125
+ region=self.region,
126
+ account_id=account_id,
127
+ state_bucket=self.state_bucket,
128
+ project_name=self._project_name,
129
+ permissions_boundary=self.permissions_boundary,
130
+ external=self.external_roles,
127
131
  )
128
132
 
133
+ def _ensure_role(self, account_id: str) -> str:
134
+ return iam_roles.ensure_role(self.iam, self.role_spec(account_id))
135
+
129
136
  def _delete_role(self, role_name: str) -> None:
130
137
  iam_roles.delete_role(self.iam, role_name)
131
138