magic-pocket-cli 0.40.0__tar.gz → 0.41.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 (93) hide show
  1. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/PKG-INFO +2 -2
  2. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/cli/deploy_cli.py +3 -0
  3. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/cli/destroy_cli.py +1 -0
  4. magic_pocket_cli-0.41.0/pocket_cli/cli/permissions_cli.py +96 -0
  5. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/aws/builders/__init__.py +2 -0
  6. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/aws/builders/codebuild.py +20 -13
  7. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/aws/cloudformation.py +88 -39
  8. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/aws/iam_roles.py +293 -49
  9. magic_pocket_cli-0.41.0/pocket_cli/resources/aws/stage_roles.py +88 -0
  10. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/backup.py +5 -1
  11. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/container.py +1 -0
  12. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/dsql.py +1 -0
  13. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/rds.py +0 -10
  14. magic_pocket_cli-0.41.0/pocket_cli/templates/cloudformation/cf_function_cache_control.js +8 -0
  15. magic_pocket_cli-0.41.0/pocket_cli/templates/cloudformation/cf_function_deploy_hash_strip.js +19 -0
  16. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/templates/cloudformation/cloudfront.yaml +60 -28
  17. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/templates/cloudformation/container.yaml +10 -4
  18. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pyproject.toml +2 -2
  19. magic_pocket_cli-0.40.0/pocket_cli/cli/permissions_cli.py +0 -46
  20. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/.gitignore +0 -0
  21. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/__init__.py +0 -0
  22. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/cli/__init__.py +0 -0
  23. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/cli/aws_auth.py +0 -0
  24. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/cli/backup_cli.py +0 -0
  25. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/cli/build_cli.py +0 -0
  26. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/cli/cleanup_deprecated_cli.py +0 -0
  27. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/cli/cloudfront_cli.py +0 -0
  28. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/cli/cloudfront_keys_cli.py +0 -0
  29. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/cli/cloudfront_waf_cli.py +0 -0
  30. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/cli/container_cli.py +0 -0
  31. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/cli/dsql_cli.py +0 -0
  32. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/cli/image_cli.py +0 -0
  33. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/cli/inbound_cli.py +0 -0
  34. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/cli/interaction.py +0 -0
  35. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/cli/main_cli.py +0 -0
  36. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/cli/migrate_cli.py +0 -0
  37. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/cli/neon_cli.py +0 -0
  38. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/cli/rds_cli.py +0 -0
  39. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/cli/removed_flags.py +0 -0
  40. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/cli/resource_helper.py +0 -0
  41. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/cli/runtime_config_cli.py +0 -0
  42. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/cli/s3_cli.py +0 -0
  43. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/cli/status_cli.py +0 -0
  44. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/cli/store_url_helper.py +0 -0
  45. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/cli/tidb_cli.py +0 -0
  46. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/cli/upstash_cli.py +0 -0
  47. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/cli/url_helper.py +0 -0
  48. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/cli/vpc_cli.py +0 -0
  49. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/cli/waf_cli.py +0 -0
  50. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/django_cli.py +0 -0
  51. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/mediator.py +0 -0
  52. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/migrations.py +0 -0
  53. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/__init__.py +0 -0
  54. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/aws/__init__.py +0 -0
  55. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/aws/backup_common.py +0 -0
  56. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/aws/backup_legacy.py +0 -0
  57. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/aws/builders/context_check.py +0 -0
  58. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/aws/builders/depot.py +0 -0
  59. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/aws/builders/docker.py +0 -0
  60. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/aws/builders/dockerignore.py +0 -0
  61. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/aws/ecr.py +0 -0
  62. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/aws/efs.py +0 -0
  63. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/aws/lambdahandler.py +0 -0
  64. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/aws/poll.py +0 -0
  65. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/aws/s3_utils.py +0 -0
  66. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/aws/stack_backed.py +0 -0
  67. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/aws/state.py +0 -0
  68. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/cloudfront.py +0 -0
  69. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/cloudfront_acm.py +0 -0
  70. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/cloudfront_keys.py +0 -0
  71. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/cloudfront_waf.py +0 -0
  72. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/inbound.py +0 -0
  73. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/inbound_domain.py +0 -0
  74. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/inbound_template.py +0 -0
  75. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/neon.py +0 -0
  76. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/s3.py +0 -0
  77. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/sqs_alert.py +0 -0
  78. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/tidb.py +0 -0
  79. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/upstash.py +0 -0
  80. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/resources/vpc.py +0 -0
  81. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/templates/cloudformation/cf_function_api_host.js +0 -0
  82. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/templates/cloudformation/cf_function_basic_auth_prelude.js +0 -0
  83. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/templates/cloudformation/cf_function_host_redirect_prelude.js +0 -0
  84. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/templates/cloudformation/cf_function_spa_auth.js +0 -0
  85. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/templates/cloudformation/cf_function_spa_fallback.js +0 -0
  86. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/templates/cloudformation/cloudfront_acm.yaml +0 -0
  87. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/templates/cloudformation/cloudfront_keys.yaml +0 -0
  88. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/templates/cloudformation/cloudfront_waf.yaml +0 -0
  89. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/templates/cloudformation/vpc.yaml +0 -0
  90. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/templates/init/django-dotenv.env +0 -0
  91. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/templates/init/django-settings.py +0 -0
  92. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.0}/pocket_cli/templates/init/pocket.Dockerfile +0 -0
  93. {magic_pocket_cli-0.40.0 → magic_pocket_cli-0.41.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.40.0
3
+ Version: 0.41.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.41,>=0.40.0
10
+ Requires-Dist: magic-pocket<0.42,>=0.41.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
@@ -12,6 +12,7 @@ from pocket_cli.mediator import Mediator
12
12
  from pocket_cli.resources.aws.builders.context_check import (
13
13
  resummarize_world_read_warnings,
14
14
  )
15
+ from pocket_cli.resources.aws.stage_roles import verify_stage_roles
15
16
  from pocket_cli.resources.aws.state import StateStore, create_state_store
16
17
  from pocket_cli.resources.backup import Backup
17
18
  from pocket_cli.resources.cloudfront import CloudFront
@@ -230,6 +231,8 @@ def _deploy_pipeline(context: Context, *, openpath=None, skip_frontend=False):
230
231
  deploy_hash_message = deploy_hash_report(context)
231
232
  if deploy_hash_message:
232
233
  echo.info(deploy_hash_message)
234
+ # [iam] external_roles: 事前に作られた role を、何かを変更する前に検査する
235
+ verify_stage_roles(context)
233
236
  # CodeBuildがソースアップロードにstate bucketを必要とするため、先に作成
234
237
  state_store = _create_state_store(context)
235
238
  state_store.ensure_bucket()
@@ -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))
@@ -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
 
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
4
+ import re
3
5
  import time
4
6
  from functools import cached_property
5
7
  from typing import TYPE_CHECKING, Any
@@ -436,6 +438,12 @@ class CloudFrontKeysStack(Stack):
436
438
  )
437
439
 
438
440
 
441
+ # deploy_hash route の URL で hash とみなすセグメントの形 (git の short / full hash)。
442
+ DEPLOY_HASH_PATTERN = "[0-9a-f]{7,40}"
443
+ # strip Function が URI から外した hash を載せるリクエストヘッダ (キャッシュキー用)。
444
+ DEPLOY_HASH_HEADER = "x-pocket-deploy-hash"
445
+
446
+
439
447
  class CloudFrontStack(Stack):
440
448
  context: CloudFrontContext
441
449
  template_filename = "cloudfront"
@@ -627,25 +635,67 @@ class CloudFrontStack(Stack):
627
635
  return self._reindent(code, 8)
628
636
 
629
637
  def _build_deploy_hash_function_codes(self) -> dict[str, str]:
630
- """deploy_hash route 用の hash prefix strip Function コードを生成する"""
638
+ """deploy_hash route 用の hash prefix strip Function コードを生成する
639
+
640
+ route の prefix 直下の 1 セグメントが git hash の形 (`DEPLOY_HASH_PATTERN`)
641
+ なら、値を問わず外す。現在の hash だけを外すと、Lambda (新 hash の URL を出す)
642
+ とこの Function の切り替えがずれる間、および旧 hash の HTML を開いたままの
643
+ 端末で、存在しないキーを読んで 403 になる (KN1670)。S3 のキーは hash に依存
644
+ しないので、どの hash の URL でも同じオブジェクトを返せる。
645
+
646
+ `DEPLOY_HASH` を git hash の形でない値で上書きしている場合だけ、その値も外す
647
+ (この場合は deploy のたびに Function が更新される)。
648
+
649
+ CloudFront のキャッシュキーは viewer-request で書き換えた後の URI になるため、
650
+ 外した hash は `DEPLOY_HASH_HEADER` に載せ、テンプレート側の CachePolicy で
651
+ キャッシュキーに含める。こうしないと全 hash の URL が 1 つの edge キャッシュを
652
+ 共有し、deploy しても中身の変わったファイルが古いまま返る。
653
+ """
631
654
  codes: dict[str, str] = {}
632
655
  deploy_hash = self.context.deploy_hash
633
656
  if not deploy_hash:
634
657
  return codes
658
+ segment_condition = "/^%s$/.test(segment)" % DEPLOY_HASH_PATTERN
659
+ if not re.fullmatch(DEPLOY_HASH_PATTERN, deploy_hash):
660
+ segment_condition += " || segment === %s" % json.dumps(deploy_hash)
661
+ env = Environment(
662
+ loader=PackageLoader("pocket_cli"),
663
+ autoescape=select_autoescape(),
664
+ )
665
+ template = env.get_template("cloudformation/cf_function_deploy_hash_strip.js")
635
666
  for route in self.context.routes:
636
667
  if not route.is_deploy_hash:
637
668
  continue
638
- code = (
639
- "function handler(event) {\n"
640
- " var request = event.request;\n"
641
- ' request.uri = request.uri.replace("/%s/", "/");\n'
642
- " return request;\n"
643
- "}" % deploy_hash
669
+ code = template.render(
670
+ prefix=route.path_pattern.rstrip("*").rstrip("/") + "/",
671
+ hash_header=DEPLOY_HASH_HEADER,
672
+ segment_condition=segment_condition,
644
673
  )
645
674
  code = self._inject_viewer_preludes(code)
646
675
  codes[route.yaml_key] = self._reindent(code, 8)
647
676
  return codes
648
677
 
678
+ def _build_cache_control_function_codes(self) -> dict[str, str]:
679
+ """versioning route の cache-control を付ける viewer-response Function コード。
680
+
681
+ ResponseHeadersPolicy はステータスを問わずヘッダを付けるため、存在しない
682
+ キーへの 403 にも immutable が付き、ブラウザがエラーを max-age の間保存して
683
+ しまう (KN1670)。
684
+ 成功応答 (2xx / 304) だけに付ける。
685
+ """
686
+ env = Environment(
687
+ loader=PackageLoader("pocket_cli"),
688
+ autoescape=select_autoescape(),
689
+ )
690
+ template = env.get_template("cloudformation/cf_function_cache_control.js")
691
+ codes: dict[str, str] = {}
692
+ for route in self.context.routes:
693
+ if not route.versioning:
694
+ continue
695
+ code = template.render(max_age=route.versioned_max_age)
696
+ codes[route.yaml_key] = self._reindent(code, 8)
697
+ return codes
698
+
649
699
  @staticmethod
650
700
  def _spa_fallback_uri(route) -> str:
651
701
  """SPA fallback の書き換え先 URI を組み立てる。
@@ -704,6 +754,7 @@ class CloudFrontStack(Stack):
704
754
  waf_acl_arn = self._resolve_waf_arn()
705
755
  function_codes = self._build_function_codes()
706
756
  deploy_hash_function_codes = self._build_deploy_hash_function_codes()
757
+ cache_control_function_codes = self._build_cache_control_function_codes()
707
758
  api_host_function_code = ""
708
759
  if self.context.has_lambda_route:
709
760
  api_host_function_code = self._generate_api_host_function()
@@ -726,6 +777,8 @@ class CloudFrontStack(Stack):
726
777
  waf_acl_arn=waf_acl_arn,
727
778
  function_codes=function_codes,
728
779
  deploy_hash_function_codes=deploy_hash_function_codes,
780
+ cache_control_function_codes=cache_control_function_codes,
781
+ deploy_hash_header=DEPLOY_HASH_HEADER,
729
782
  api_host_function_code=api_host_function_code,
730
783
  host_redirect_function_code=host_redirect_function_code,
731
784
  basic_auth_function_code=basic_auth_function_code,
@@ -761,9 +814,7 @@ class ContainerStack(Stack):
761
814
  "rds_security_group_id": self._rds_context.security_group_id,
762
815
  "rds_secret_store": "sm",
763
816
  "rds_secret_arn": self._rds_context.secret_arn,
764
- "rds_kms_key_id": None,
765
817
  "rds_ssm_param_name": None,
766
- "rds_ssm_param_arn": None,
767
818
  "rds_endpoint": None,
768
819
  "rds_port": None,
769
820
  "rds_dbname": None,
@@ -784,30 +835,23 @@ class ContainerStack(Stack):
784
835
  **base,
785
836
  "rds_secret_store": "ssm",
786
837
  "rds_secret_arn": None,
787
- "rds_kms_key_id": None,
788
838
  "rds_ssm_param_name": ssm_param_name,
789
- "rds_ssm_param_arn": (
790
- "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/"
791
- + ssm_param_name
792
- ),
793
839
  }
794
840
  return {
795
841
  **base,
796
842
  "rds_secret_store": "sm",
797
843
  "rds_secret_arn": rds.master_user_secret_arn,
798
- "rds_kms_key_id": rds.master_user_secret_kms_key_id,
799
844
  "rds_ssm_param_name": None,
800
- "rds_ssm_param_arn": None,
801
845
  }
802
846
 
803
- def _resolve_dsql(self) -> tuple[str | None, str | None, str | None]:
804
- """DSQL のエンドポイント、リージョン、ARN を動的に取得"""
847
+ def _resolve_dsql(self) -> tuple[str | None, str | None]:
848
+ """DSQL のエンドポイントとリージョンを動的に取得"""
805
849
  if self._dsql_context is None:
806
- return None, None, None
850
+ return None, None
807
851
  from pocket_cli.resources.dsql import Dsql
808
852
 
809
853
  dsql = Dsql(self._dsql_context)
810
- return dsql.endpoint, self._dsql_context.region, dsql.arn
854
+ return dsql.endpoint, self._dsql_context.region
811
855
 
812
856
  @property
813
857
  def name(self):
@@ -841,10 +885,31 @@ class ContainerStack(Stack):
841
885
  break
842
886
  return count
843
887
 
888
+ @property
889
+ def lambda_role(self) -> iam_roles.RoleSpec:
890
+ return iam_roles.lambda_role(
891
+ self.context, rds=self._rds_context, dsql=self._dsql_context
892
+ )
893
+
894
+ @property
895
+ def scheduler_role(self) -> iam_roles.RoleSpec | None:
896
+ if not (self._scheduler_context and self._scheduler_context.has_schedules):
897
+ return None
898
+ return iam_roles.scheduler_role(
899
+ self._scheduler_context,
900
+ permissions_boundary=self.context.permissions_boundary,
901
+ external=self.context.external_roles,
902
+ )
903
+
904
+ @property
905
+ def role_specs(self) -> list[iam_roles.RoleSpec]:
906
+ """この stack の Lambda が使う role (external_roles では事前作成が要る)。"""
907
+ return [r for r in (self.lambda_role, self.scheduler_role) if r]
908
+
844
909
  @property
845
910
  def yaml(self) -> str:
846
911
  rds_info = self._resolve_rds()
847
- dsql_endpoint, dsql_region, dsql_cluster_arn = self._resolve_dsql()
912
+ dsql_endpoint, dsql_region = self._resolve_dsql()
848
913
  context_dump = self.context.model_dump()
849
914
 
850
915
  # 外部 VPC: zones を動的取得
@@ -859,37 +924,21 @@ class ContainerStack(Stack):
859
924
  rds_security_group_id=rds_info.get("rds_security_group_id"),
860
925
  rds_secret_store=rds_info.get("rds_secret_store"),
861
926
  rds_secret_arn=rds_info.get("rds_secret_arn"),
862
- rds_kms_key_id=rds_info.get("rds_kms_key_id"),
863
927
  rds_ssm_param_name=rds_info.get("rds_ssm_param_name"),
864
- rds_ssm_param_arn=rds_info.get("rds_ssm_param_arn"),
865
928
  rds_endpoint=rds_info.get("rds_endpoint"),
866
929
  rds_port=rds_info.get("rds_port"),
867
930
  rds_dbname=rds_info.get("rds_dbname"),
868
931
  use_rds=bool(rds_info),
869
932
  dsql_endpoint=dsql_endpoint,
870
933
  dsql_region=dsql_region,
871
- dsql_cluster_arn=dsql_cluster_arn,
872
934
  use_dsql=dsql_endpoint is not None,
873
935
  scheduler=(
874
936
  self._scheduler_context.model_dump()
875
937
  if self._scheduler_context
876
938
  else None
877
939
  ),
878
- lambda_role=iam_roles.lambda_role(
879
- self.context,
880
- rds_secret_arn=rds_info.get("rds_secret_arn"),
881
- rds_kms_key_id=rds_info.get("rds_kms_key_id"),
882
- rds_ssm_param_arn=rds_info.get("rds_ssm_param_arn"),
883
- dsql_cluster_arn=dsql_cluster_arn if dsql_endpoint else None,
884
- ),
885
- scheduler_role=(
886
- iam_roles.scheduler_role(
887
- self._scheduler_context,
888
- permissions_boundary=self.context.permissions_boundary,
889
- )
890
- if self._scheduler_context
891
- else None
892
- ),
940
+ lambda_role=self.lambda_role,
941
+ scheduler_role=self.scheduler_role,
893
942
  )
894
943
 
895
944