magic-pocket-cli 0.18.0__tar.gz → 0.19.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 (73) hide show
  1. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/PKG-INFO +2 -2
  2. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/cli/destroy_cli.py +39 -7
  3. magic_pocket_cli-0.19.0/pocket_cli/cli/image_cli.py +59 -0
  4. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/cli/main_cli.py +2 -0
  5. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/cli/neon_cli.py +29 -0
  6. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/mediator.py +44 -19
  7. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/resources/aws/cloudformation.py +90 -13
  8. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/resources/cloudfront.py +46 -10
  9. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/templates/cloudformation/awscontainer.yaml +16 -1
  10. magic_pocket_cli-0.19.0/pocket_cli/templates/cloudformation/cf_function_basic_auth_prelude.js +11 -0
  11. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/templates/cloudformation/cloudfront.yaml +47 -10
  12. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pyproject.toml +2 -2
  13. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/.gitignore +0 -0
  14. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/__init__.py +0 -0
  15. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/cli/__init__.py +0 -0
  16. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/cli/aws_auth.py +0 -0
  17. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/cli/awscontainer_cli.py +0 -0
  18. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/cli/cloudfront_cli.py +0 -0
  19. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/cli/cloudfront_keys_cli.py +0 -0
  20. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/cli/cloudfront_waf_cli.py +0 -0
  21. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/cli/deploy_cli.py +0 -0
  22. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/cli/dsql_cli.py +0 -0
  23. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/cli/migrate_cli.py +0 -0
  24. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/cli/permissions_cli.py +0 -0
  25. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/cli/rds_cli.py +0 -0
  26. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/cli/resource_helper.py +0 -0
  27. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/cli/runtime_config_cli.py +0 -0
  28. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/cli/s3_cli.py +0 -0
  29. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/cli/status_cli.py +0 -0
  30. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/cli/store_url_helper.py +0 -0
  31. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/cli/tidb_cli.py +0 -0
  32. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/cli/upstash_cli.py +0 -0
  33. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/cli/url_helper.py +0 -0
  34. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/cli/vpc_cli.py +0 -0
  35. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/cli/waf_cli.py +0 -0
  36. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/django_cli.py +0 -0
  37. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/resources/__init__.py +0 -0
  38. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/resources/aws/__init__.py +0 -0
  39. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/resources/aws/builders/__init__.py +0 -0
  40. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/resources/aws/builders/codebuild.py +0 -0
  41. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/resources/aws/builders/depot.py +0 -0
  42. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/resources/aws/builders/docker.py +0 -0
  43. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/resources/aws/builders/dockerignore.py +0 -0
  44. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/resources/aws/ecr.py +0 -0
  45. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/resources/aws/efs.py +0 -0
  46. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/resources/aws/lambdahandler.py +0 -0
  47. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/resources/aws/poll.py +0 -0
  48. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/resources/aws/s3_utils.py +0 -0
  49. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/resources/aws/stack_backed.py +0 -0
  50. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/resources/aws/state.py +0 -0
  51. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/resources/awscontainer.py +0 -0
  52. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/resources/cloudfront_acm.py +0 -0
  53. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/resources/cloudfront_keys.py +0 -0
  54. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/resources/cloudfront_waf.py +0 -0
  55. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/resources/dsql.py +0 -0
  56. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/resources/neon.py +0 -0
  57. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/resources/rds.py +0 -0
  58. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/resources/s3.py +0 -0
  59. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/resources/tidb.py +0 -0
  60. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/resources/upstash.py +0 -0
  61. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/resources/vpc.py +0 -0
  62. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/templates/cloudformation/cf_function_api_host.js +0 -0
  63. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/templates/cloudformation/cf_function_host_redirect_prelude.js +0 -0
  64. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/templates/cloudformation/cf_function_spa_auth.js +0 -0
  65. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/templates/cloudformation/cf_function_spa_fallback.js +0 -0
  66. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/templates/cloudformation/cloudfront_acm.yaml +0 -0
  67. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/templates/cloudformation/cloudfront_keys.yaml +0 -0
  68. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/templates/cloudformation/cloudfront_waf.yaml +0 -0
  69. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/templates/cloudformation/vpc.yaml +0 -0
  70. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/templates/init/django-dotenv.env +0 -0
  71. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/templates/init/django-settings.py +0 -0
  72. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/templates/init/pocket.Dockerfile +0 -0
  73. {magic_pocket_cli-0.18.0 → magic_pocket_cli-0.19.0}/pocket_cli/templates/init/pocket_simple.toml +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: magic-pocket-cli
3
- Version: 0.18.0
3
+ Version: 0.19.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.18.0
10
+ Requires-Dist: magic-pocket>=0.19.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
@@ -114,8 +114,18 @@ def _collect_external_database_targets(context: Context) -> list[str]:
114
114
  if Upstash(context.upstash).database:
115
115
  targets.append("Upstash Redis: %s" % context.upstash.database_name)
116
116
  if context.neon and context.neon.provisioning != "command":
117
- if Neon(context.neon).branch:
118
- targets.append("Neon ブランチ")
117
+ neon = Neon(context.neon)
118
+ if neon.branch:
119
+ plan = neon.destroy_plan()
120
+ if plan == "project":
121
+ targets.append(
122
+ "Neon プロジェクト '%s' (root branch のため project ごと削除)"
123
+ % context.neon.project_name
124
+ )
125
+ elif plan == "branch":
126
+ targets.append("Neon ブランチ")
127
+ # "blocked" (root だが他 branch 同居) は削除できないため一覧に載せない
128
+ # (destroy 実行時に警告する)
119
129
  return targets
120
130
 
121
131
 
@@ -292,16 +302,38 @@ def _destroy_upstash(context: Context):
292
302
 
293
303
 
294
304
  def _destroy_neon(context: Context):
295
- """Neon ブランチを削除 (provisioning="command" は管理外)"""
305
+ """Neon ブランチを削除 (provisioning="command" は管理外)
306
+
307
+ root branch は Neon 仕様で branch 単位の削除ができない (422: cannot delete the
308
+ root branch) ため、project 内に他 branch が無ければ project ごと削除する。他
309
+ branch が同居している場合は巻き添えになるため何も消さず警告して続行する
310
+ (destroy 全体を異常終了させない)。
311
+ """
296
312
  if not context.neon:
297
313
  return
298
314
  if context.neon.provisioning == "command":
299
315
  _warn_command_provisioned("Neon", "pocket resource neon delete")
300
316
  return
301
- if Neon(context.neon).branch:
302
- echo.log("Destroying Neon branch...")
303
- Neon(context.neon).delete_branch()
304
- echo.success("Neon branch was deleted.")
317
+ neon = Neon(context.neon)
318
+ if not neon.branch:
319
+ return
320
+ plan = neon.destroy_plan()
321
+ if plan == "blocked":
322
+ echo.warning(
323
+ "Neon branch '%s' は root branch のため単体削除できず、project '%s' には"
324
+ "他の branch が残っているため project 削除も行いません。"
325
+ "他 stage の destroy 後に pocket resource neon delete で削除してください。"
326
+ % (context.neon.branch_name, context.neon.project_name)
327
+ )
328
+ return
329
+ if plan == "project":
330
+ echo.log("Destroying Neon project (root branch のため project ごと削除)...")
331
+ neon.delete_project()
332
+ echo.success("Neon project '%s' was deleted." % context.neon.project_name)
333
+ return
334
+ echo.log("Destroying Neon branch...")
335
+ neon.delete_branch()
336
+ echo.success("Neon branch was deleted.")
305
337
 
306
338
 
307
339
  def _destroy_cloudfront_and_acm(context: Context):
@@ -0,0 +1,59 @@
1
+ """deploy 済みコンテナイメージの参照情報を出力する CLI。
2
+
3
+ 外部ツール (pocket と併走する deploy 系) が pocket のビルドしたイメージを
4
+ 参照する際、pocket.toml を読んだ正確な値をスクリプトから取得できるようにする
5
+ (`pocket.naming.ecr_repo_name` の「toml を読む版」)。stdout には値のみを出し、
6
+ `$(pocket resource image uri --stage <s>)` の形で食えるようにする (診断は stderr)。
7
+ """
8
+
9
+ import click
10
+
11
+ from pocket.context import Context
12
+ from pocket_cli.cli.resource_helper import require_configured
13
+ from pocket_cli.resources.awscontainer import AwsContainer
14
+
15
+
16
+ @click.group()
17
+ def image():
18
+ pass
19
+
20
+
21
+ def _get_awscontainer(stage: str) -> AwsContainer:
22
+ context = Context.from_toml(stage=stage)
23
+ return AwsContainer(
24
+ require_configured(
25
+ context.awscontainer, "awscontainer is not configured for this stage"
26
+ )
27
+ )
28
+
29
+
30
+ @image.command()
31
+ @click.option("--stage", envvar="POCKET_DEPLOY_STAGE", prompt=True)
32
+ def repo(stage):
33
+ """ECR repository 名を stdout に出力する (ecr_name 上書きを含め toml 準拠)。"""
34
+ ac = _get_awscontainer(stage)
35
+ click.echo(ac.context.ecr_name)
36
+
37
+
38
+ @image.command()
39
+ @click.option("--stage", envvar="POCKET_DEPLOY_STAGE", prompt=True)
40
+ def uri(stage):
41
+ """deploy 済み実イメージの URI ({repo_uri}@{digest}) を stdout に出力する。
42
+
43
+ `:{stage}` タグが現在指している image を digest で固定した URI を返すため、
44
+ 以後の deploy でタグが動いても参照が壊れない。
45
+ """
46
+ ac = _get_awscontainer(stage)
47
+ ecr = ac.ecr
48
+ if not ecr.uri:
49
+ raise click.ClickException(
50
+ "ECR repository '%s' が見つかりません。先に `pocket deploy` を"
51
+ "実行してください。" % ac.context.ecr_name
52
+ )
53
+ digest = ecr.image_detail.image_digest
54
+ if not digest:
55
+ raise click.ClickException(
56
+ "tag ':%s' のイメージが '%s' に見つかりません。deploy 済みか確認して"
57
+ "ください。" % (stage, ac.context.ecr_name)
58
+ )
59
+ click.echo("%s@%s" % (ecr.uri, digest))
@@ -10,6 +10,7 @@ from pocket_cli.cli import (
10
10
  deploy_cli,
11
11
  destroy_cli,
12
12
  dsql_cli,
13
+ image_cli,
13
14
  migrate_cli,
14
15
  neon_cli,
15
16
  permissions_cli,
@@ -91,3 +92,4 @@ resource.add_command(s3_cli.s3)
91
92
  resource.add_command(cloudfront_cli.cloudfront)
92
93
  resource.add_command(cloudfront_keys_cli.cloudfront_keys)
93
94
  resource.add_command(cloudfront_waf_cli.cloudfront_waf)
95
+ resource.add_command(image_cli.image)
@@ -69,12 +69,41 @@ def branch_out(stage, base_stage):
69
69
  "--yes", "-y", is_flag=True, default=False, help="確認プロンプトをスキップ"
70
70
  )
71
71
  def delete(stage, yes):
72
+ """stage の Neon ブランチを削除する。
73
+
74
+ root branch は Neon 仕様で branch 単位の削除ができない (422: cannot delete the
75
+ root branch) ため、project 内に他 branch が無ければ再確認のうえ project ごと
76
+ 削除する。他 branch が同居している場合は巻き添えになるためエラーで中断する。
77
+ """
72
78
  if not yes:
73
79
  click.confirm(
74
80
  "stage '%s' の Neon ブランチを削除しますか?(データは失われます)" % stage,
75
81
  abort=True,
76
82
  )
77
83
  neon = get_neon_resource(stage)
84
+ if neon.branch is None:
85
+ raise click.ClickException(
86
+ "Neon branch '%s' が見つかりません" % neon.context.branch_name
87
+ )
88
+ plan = neon.destroy_plan()
89
+ if plan == "blocked":
90
+ raise click.ClickException(
91
+ "branch '%s' は root branch のため単体削除できません (Neon 仕様)。"
92
+ "project '%s' には他の branch が残っているため project 削除も行いません。"
93
+ "他の branch を先に削除してください。"
94
+ % (neon.context.branch_name, neon.context.project_name)
95
+ )
96
+ if plan == "project":
97
+ if not yes:
98
+ click.confirm(
99
+ "branch '%s' は root branch のため、project '%s' を丸ごと削除します。"
100
+ "よろしいですか?"
101
+ % (neon.context.branch_name, neon.context.project_name),
102
+ abort=True,
103
+ )
104
+ neon.delete_project()
105
+ echo.success("Project '%s' was deleted." % neon.context.project_name)
106
+ return
78
107
  neon.delete_branch()
79
108
  echo.success("Branch was deleted successfully.")
80
109
 
@@ -108,26 +108,25 @@ class Mediator:
108
108
  sc.pocket_store.delete_secret_keys(orphaned)
109
109
 
110
110
  def _generate_secret(self, spec: ManagedSecretSpec):
111
- if spec.type == "auto_database_url":
112
- return self._get_auto_database_url()
113
- elif spec.type == "password":
114
- return self._generate_password(spec.options)
115
- elif spec.type == "neon_database_url":
116
- return self._get_neon_database_url()
117
- elif spec.type == "tidb_database_url":
118
- return self._get_tidb_database_url()
119
- elif spec.type == "rds_database_url":
120
- return self._get_rds_database_url()
121
- elif spec.type == "upstash_redis_url":
122
- return self._get_upstash_redis_url()
123
- elif spec.type == "rsa_pem_base64":
124
- return self._generate_rsa_pem()
125
- elif spec.type == "cloudfront_signing_key":
126
- return self._generate_rsa_pem()
127
- elif spec.type in ("spa_token_secret", "origin_verify_secret"):
128
- return secrets.token_hex(32)
129
- else:
111
+ generators = {
112
+ "auto_database_url": self._get_auto_database_url,
113
+ "password": lambda: self._generate_password(spec.options),
114
+ "neon_database_url": self._get_neon_database_url,
115
+ "tidb_database_url": self._get_tidb_database_url,
116
+ "rds_database_url": self._get_rds_database_url,
117
+ "upstash_redis_url": self._get_upstash_redis_url,
118
+ "rsa_pem_base64": self._generate_rsa_pem,
119
+ "cloudfront_signing_key": self._generate_rsa_pem,
120
+ "spa_token_secret": lambda: secrets.token_hex(32),
121
+ "origin_verify_secret": lambda: secrets.token_hex(32),
122
+ "basic_auth_credential": lambda: self._generate_basic_auth_credential(
123
+ spec.options
124
+ ),
125
+ }
126
+ generator = generators.get(spec.type)
127
+ if generator is None:
130
128
  raise RuntimeError("Unknown secret type: %s" % spec.type)
129
+ return generator()
131
130
 
132
131
  def _generate_rsa_pem(self) -> dict[str, str]:
133
132
  try:
@@ -153,6 +152,32 @@ class Mediator:
153
152
  "pub": base64.b64encode(pem_public_key).decode("utf-8"),
154
153
  }
155
154
 
155
+ def _generate_basic_auth_credential(self, options):
156
+ """Basic 認証の credential "user:pass" を生成する。
157
+
158
+ username は必須 (settings で検証済みだが直接呼び出しに備え再検証)。
159
+ password は options で固定値を指定でき、省略時は length/chars でランダム
160
+ 生成する。固定値は pocket.toml (= git) に入る点に注意 (公開前サイトの
161
+ 隠蔽用の共有credential を想定)。
162
+ """
163
+ username = options.get("username")
164
+ if not isinstance(username, str) or not username or ":" in username:
165
+ raise Exception(
166
+ "basic_auth_credential requires options.username (str, ':' 不可)"
167
+ )
168
+ password = options.get("password")
169
+ if password is None:
170
+ # Basic 認証はブラウザ入力なので記号を避けた英数字既定にする
171
+ password = self._generate_password(
172
+ {
173
+ "length": options.get("length", 16),
174
+ "chars": options.get("chars", "abcdefghijkmnpqrstuvwxyz23456789"),
175
+ }
176
+ )
177
+ elif not isinstance(password, str) or not password:
178
+ raise Exception("basic_auth_credential options.password must be str")
179
+ return "%s:%s" % (username, password)
180
+
156
181
  def _generate_password(self, options):
157
182
  length = options.get("length", 16)
158
183
  if not isinstance(length, int):
@@ -401,7 +401,10 @@ class CloudFrontStack(Stack):
401
401
 
402
402
  @property
403
403
  def _has_token_kvs(self) -> bool:
404
- return any(r.require_token for r in self.context.routes)
404
+ # KVS spa auth の token_secret と basic_auth の期待ヘッダ値を共用する
405
+ return any(r.require_token for r in self.context.routes) or bool(
406
+ self.context.basic_auth
407
+ )
405
408
 
406
409
  @property
407
410
  def export(self):
@@ -477,6 +480,11 @@ class CloudFrontStack(Stack):
477
480
  prelude = self._render_host_redirect_prelude()
478
481
  if not prelude:
479
482
  return code
483
+ return self._inject_after_request_line(code, prelude)
484
+
485
+ @staticmethod
486
+ def _inject_after_request_line(code: str, prelude: str) -> str:
487
+ """`var request = event.request;` の直後に prelude を差し込む。"""
480
488
  out: list[str] = []
481
489
  injected = False
482
490
  for line in code.splitlines():
@@ -486,6 +494,44 @@ class CloudFrontStack(Stack):
486
494
  injected = True
487
495
  return "\n".join(out)
488
496
 
497
+ def _render_basic_auth_prelude(self) -> str:
498
+ """Basic 認証 prelude (4 スペース字下げ)。basic_auth 未設定なら空文字。"""
499
+ if not self.context.basic_auth:
500
+ return ""
501
+ env = Environment(
502
+ loader=PackageLoader("pocket_cli"),
503
+ autoescape=select_autoescape(),
504
+ )
505
+ template = env.get_template("cloudformation/cf_function_basic_auth_prelude.js")
506
+ return template.render().rstrip("\n")
507
+
508
+ def _inject_basic_auth(self, code: str) -> str:
509
+ """生成済み Function コードに Basic 認証 prelude を差し込む
510
+ (basic_auth 有効時のみ)。
511
+
512
+ KVS 参照のため handler を async 化し、kvsHandle を持たない Function には
513
+ 引数なし `cf.kvs()` (その Function に関連付けられた唯一の KVS を返す) の
514
+ 宣言を前置する。呼び出し側はこの Function に KeyValueStoreAssociations を
515
+ 必ず付けること (テンプレート側で basic_auth 時に一括付与)。
516
+ """
517
+ prelude = self._render_basic_auth_prelude()
518
+ if not prelude:
519
+ return code
520
+ if "kvsHandle" not in code:
521
+ code = "import cf from 'cloudfront';\nconst kvsHandle = cf.kvs();\n" + code
522
+ if "async function handler" not in code:
523
+ code = code.replace("function handler(", "async function handler(", 1)
524
+ return self._inject_after_request_line(code, prelude)
525
+
526
+ def _inject_viewer_preludes(self, code: str) -> str:
527
+ """全 viewer-request Function 共通の prelude 合成。
528
+
529
+ basic auth を先に差し込み、その後 host redirect を同じ位置に差し込むことで
530
+ 実行順は host redirect → basic auth になる (非 canonical host はまず 301 し、
531
+ 認証プロンプトは canonical domain でのみ出す)。
532
+ """
533
+ return self._inject_host_redirect(self._inject_basic_auth(code))
534
+
489
535
  @staticmethod
490
536
  def _reindent(code: str, indent: int) -> str:
491
537
  """FunctionCode: | ブロック用に 2 行目以降を indent スペース字下げする。"""
@@ -509,6 +555,22 @@ class CloudFrontStack(Stack):
509
555
  )
510
556
  return self._reindent(code, 8)
511
557
 
558
+ def _generate_basic_auth_function(self) -> str:
559
+ """viewer-request Function を持たない behavior 用の単体 Basic 認証 Function。
560
+
561
+ behavior に付けられる viewer-request Function は 1 つだけのため、
562
+ redirect_from 有効時は host redirect prelude もここに同居させる
563
+ (単体 HostRedirectFunction は basic_auth 時には生成しない)。
564
+ """
565
+ code = (
566
+ "function handler(event) {\n"
567
+ " var request = event.request;\n"
568
+ " return request;\n"
569
+ "}"
570
+ )
571
+ code = self._inject_viewer_preludes(code)
572
+ return self._reindent(code, 8)
573
+
512
574
  def _build_deploy_hash_function_codes(self) -> dict[str, str]:
513
575
  """deploy_hash route 用の hash prefix strip Function コードを生成する"""
514
576
  codes: dict[str, str] = {}
@@ -525,30 +587,38 @@ class CloudFrontStack(Stack):
525
587
  " return request;\n"
526
588
  "}" % deploy_hash
527
589
  )
528
- code = self._inject_host_redirect(code)
590
+ code = self._inject_viewer_preludes(code)
529
591
  codes[route.yaml_key] = self._reindent(code, 8)
530
592
  return codes
531
593
 
594
+ @staticmethod
595
+ def _spa_fallback_uri(route) -> str:
596
+ """SPA fallback の書き換え先 URI を組み立てる。
597
+
598
+ path_pattern の glob (`/admin/*` 等) をそのまま連結するとリテラル URI に
599
+ `*` が残り (`/admin/*/index.html`)、S3 に該当キーが無く全リクエストが 403 に
600
+ なる。prefix 部分だけを使う (`/admin/*` → `/admin/index.html`)。catch-all
601
+ (path_pattern 空) は従来どおり `/index.html`。
602
+ """
603
+ prefix = route.path_pattern.rstrip("*").rstrip("/")
604
+ return prefix + "/" + route.spa_fallback_html
605
+
532
606
  def _generate_spa_fallback_function(self, route) -> str: # type: ignore
533
607
  """SPA URL フォールバック用 CloudFront Function コードを生成する"""
534
- fallback_uri = route.path_pattern + "/" + route.spa_fallback_html
535
- if not route.path_pattern:
536
- fallback_uri = "/" + route.spa_fallback_html
608
+ fallback_uri = self._spa_fallback_uri(route)
537
609
  env = Environment(
538
610
  loader=PackageLoader("pocket_cli"),
539
611
  autoescape=select_autoescape(),
540
612
  )
541
613
  template = env.get_template("cloudformation/cf_function_spa_fallback.js")
542
614
  code = template.render(fallback_uri=fallback_uri)
543
- code = self._inject_host_redirect(code)
615
+ code = self._inject_viewer_preludes(code)
544
616
  # FunctionCode: | の下は8スペース
545
617
  return self._reindent(code, 8)
546
618
 
547
619
  def _generate_spa_auth_function(self, route) -> str: # type: ignore
548
620
  """KVS + HMAC 検証付き async CloudFront Function コードを生成する"""
549
- fallback_uri = route.path_pattern + "/" + route.spa_fallback_html
550
- if not route.path_pattern:
551
- fallback_uri = "/" + route.spa_fallback_html
621
+ fallback_uri = self._spa_fallback_uri(route)
552
622
  env = Environment(
553
623
  loader=PackageLoader("pocket_cli"),
554
624
  autoescape=select_autoescape(),
@@ -558,7 +628,7 @@ class CloudFrontStack(Stack):
558
628
  fallback_uri=fallback_uri,
559
629
  login_path=route.login_path,
560
630
  )
561
- code = self._inject_host_redirect(code)
631
+ code = self._inject_viewer_preludes(code)
562
632
  # Fn::Sub の2パラメータ形式で - | の下は12スペース
563
633
  return self._reindent(code, 12)
564
634
 
@@ -570,7 +640,7 @@ class CloudFrontStack(Stack):
570
640
  )
571
641
  template = env.get_template("cloudformation/cf_function_api_host.js")
572
642
  code = template.render()
573
- code = self._inject_host_redirect(code)
643
+ code = self._inject_viewer_preludes(code)
574
644
  return self._reindent(code, 8)
575
645
 
576
646
  @property
@@ -582,21 +652,28 @@ class CloudFrontStack(Stack):
582
652
  api_host_function_code = ""
583
653
  if self.context.has_lambda_route:
584
654
  api_host_function_code = self._generate_api_host_function()
655
+ # basic_auth 時、素の behavior には単体 BasicAuthFunction を付ける
656
+ # (redirect prelude も同居)。単体 HostRedirectFunction は非 basic_auth 時のみ。
657
+ basic_auth_function_code = ""
658
+ if self.context.basic_auth:
659
+ basic_auth_function_code = self._generate_basic_auth_function()
585
660
  host_redirect_function_code = ""
586
- if self.context.has_redirect_from:
661
+ if self.context.has_redirect_from and not self.context.basic_auth:
587
662
  host_redirect_function_code = self._generate_host_redirect_function()
588
663
 
589
664
  return self._render_yaml(
590
665
  context_data=self.context.model_dump(
591
- exclude={"signing_key", "token_secret"}
666
+ exclude={"signing_key", "token_secret", "basic_auth"}
592
667
  ),
593
668
  signing_key=bool(self.context.signing_key),
669
+ basic_auth=bool(self.context.basic_auth),
594
670
  acm_certificate_arn=acm_certificate_arn,
595
671
  waf_acl_arn=waf_acl_arn,
596
672
  function_codes=function_codes,
597
673
  deploy_hash_function_codes=deploy_hash_function_codes,
598
674
  api_host_function_code=api_host_function_code,
599
675
  host_redirect_function_code=host_redirect_function_code,
676
+ basic_auth_function_code=basic_auth_function_code,
600
677
  has_token_kvs=self._has_token_kvs,
601
678
  origin_verify_secret=self._origin_verify_secret_value,
602
679
  )
@@ -46,6 +46,8 @@ class CloudFront:
46
46
  self.cf_client = boto3.client("cloudfront")
47
47
  self._token_secret_value: str = ""
48
48
  self._origin_verify_secret_value: str = ""
49
+ # Basic 認証の期待 Authorization ヘッダ値 ("Basic <base64(user:pass)>")
50
+ self._basic_auth_expected: str = ""
49
51
 
50
52
  @property
51
53
  def description(self):
@@ -80,6 +82,7 @@ class CloudFront:
80
82
  deploy 済み hash と一致せず、毎回 REQUIRE_UPDATE になる。
81
83
  """
82
84
  self._prepare_token_secret(mediator)
85
+ self._prepare_basic_auth(mediator)
83
86
  self._prepare_origin_verify_secret(mediator)
84
87
 
85
88
  def create(self, mediator: Mediator | None = None):
@@ -144,6 +147,32 @@ class CloudFront:
144
147
  % self.context.token_secret
145
148
  )
146
149
 
150
+ def _prepare_basic_auth(self, mediator: Mediator | None):
151
+ """basic_auth の managed secret ("user:pass") から期待ヘッダ値を組み立てる。
152
+
153
+ 値は CloudFront Function が比較する "Basic <base64(user:pass)>" 形式で、
154
+ テンプレートには入れず deploy 後に KVS へ書き込む (token_secret と同経路)。
155
+ """
156
+ import base64
157
+
158
+ if not self.context.basic_auth:
159
+ return
160
+ if not mediator:
161
+ return
162
+ ac = mediator.context.awscontainer
163
+ if not ac or not ac.secrets:
164
+ return
165
+ secrets = ac.secrets.pocket_store.secrets
166
+ value = secrets.get(self.context.basic_auth)
167
+ if not isinstance(value, str) or ":" not in value:
168
+ echo.warning(
169
+ "basic_auth '%s' の値が 'user:pass' 形式ではありません"
170
+ % self.context.basic_auth
171
+ )
172
+ return
173
+ encoded = base64.b64encode(value.encode()).decode()
174
+ self._basic_auth_expected = "Basic %s" % encoded
175
+
147
176
  def _prepare_origin_verify_secret(self, mediator: Mediator | None):
148
177
  """enable_origin_verify 時に origin verify secret の値を store から読む。
149
178
 
@@ -172,7 +201,13 @@ class CloudFront:
172
201
  )
173
202
 
174
203
  def _write_token_secret_to_kvs(self):
175
- if not self._token_secret_value:
204
+ """KVS token_secret / basic_auth を書き込む (設定されているもののみ)。"""
205
+ entries = {
206
+ "token_secret": self._token_secret_value,
207
+ "basic_auth": self._basic_auth_expected,
208
+ }
209
+ entries = {k: v for k, v in entries.items() if v}
210
+ if not entries:
176
211
  return
177
212
  if not self.stack.output:
178
213
  echo.warning("スタック出力が取得できません。KVS 書き込みをスキップします。")
@@ -184,15 +219,16 @@ class CloudFront:
184
219
  kvs_client = boto3.client(
185
220
  "cloudfront-keyvaluestore", region_name=self.context.region
186
221
  )
187
- desc = kvs_client.describe_key_value_store(KvsARN=kvs_arn)
188
- etag = desc["ETag"]
189
- kvs_client.put_key(
190
- KvsARN=kvs_arn,
191
- Key="token_secret",
192
- Value=self._token_secret_value,
193
- IfMatch=etag,
194
- )
195
- echo.info("KVS にトークンシークレットを書き込みました")
222
+ for key, value in entries.items():
223
+ # put_key ごとに ETag が変わるため毎回 describe で取り直す
224
+ desc = kvs_client.describe_key_value_store(KvsARN=kvs_arn)
225
+ kvs_client.put_key(
226
+ KvsARN=kvs_arn,
227
+ Key=key,
228
+ Value=value,
229
+ IfMatch=desc["ETag"],
230
+ )
231
+ echo.info("KVS に %s を書き込みました" % key)
196
232
 
197
233
  def upload_managed_assets(self):
198
234
  """managed_assets のファイルを S3 に同期する (全件 upload + 不要削除)。"""
@@ -156,6 +156,8 @@ Resources:
156
156
  Properties:
157
157
  FunctionName: "{{ handler.function_name }}"
158
158
  PackageType: Image
159
+ Architectures:
160
+ - "{{ lambda_architecture }}"
159
161
  MemorySize: "{{ handler.memory_size }}"
160
162
  Timeout: "{{ handler.timeout }}"
161
163
  # {% if handler.reserved_concurrency %}
@@ -172,8 +174,16 @@ Resources:
172
174
  Environment:
173
175
  Variables:
174
176
  "POCKET_STAGE": "{{ stage }}"
177
+ # {# tojson で YAML セーフに埋め込む (値内の二重引用符等で ParserError にしない)。
178
+ # container 共通 envs に handler.envs をマージし handler 側を優先する。
179
+ # 重複キーを 2 回出さないよう共通側は handler.envs にあるキーをスキップ #}
175
180
  # {% for env_key, value in envs.items() %}
176
- "{{ env_key }}": "{{ value }}"
181
+ # {% if env_key not in handler.envs %}
182
+ {{ env_key | tojson }}: {{ value | tojson }}
183
+ # {% endif %}
184
+ # {% endfor %}
185
+ # {% for env_key, value in handler.envs.items() %}
186
+ {{ env_key | tojson }}: {{ value | tojson }}
177
187
  # {% endfor %}
178
188
  # {% for env_key, import_name in signing_key_imports.items() %}
179
189
  "{{ env_key }}":
@@ -412,6 +422,11 @@ Resources:
412
422
  Type: AWS::IAM::Role
413
423
  Properties:
414
424
  RoleName: "{{ scheduler.role_name }}"
425
+ # {% if permissions_boundary %}
426
+ # boundary 強制 account (iam:CreateRole が boundary 付きのみ許可) では
427
+ # LambdaRole と同様に boundary が無いと作成できない
428
+ PermissionsBoundary: "{{ permissions_boundary }}"
429
+ # {% endif %}
415
430
  AssumeRolePolicyDocument:
416
431
  Version: "2012-10-17"
417
432
  Statement:
@@ -0,0 +1,11 @@
1
+ var __pocketBaAuth = request.headers.authorization;
2
+ var __pocketBaExpected;
3
+ try { __pocketBaExpected = await kvsHandle.get('basic_auth'); }
4
+ catch (e) { __pocketBaExpected = null; }
5
+ if (!__pocketBaExpected || !__pocketBaAuth || __pocketBaAuth.value !== __pocketBaExpected) {
6
+ return {
7
+ statusCode: 401,
8
+ statusDescription: 'Unauthorized',
9
+ headers: { 'www-authenticate': { value: 'Basic realm="Restricted"' } }
10
+ };
11
+ }
@@ -11,12 +11,15 @@ Resources:
11
11
  SigningBehavior: always
12
12
  SigningProtocol: sigv4
13
13
 
14
+ # {# basic_auth 時に素の behavior へ付ける単体 Function 名 (redirect prelude 同居)。
15
+ # 非 basic_auth 時は従来どおり HostRedirectFunction #}
16
+ # {% set bare_viewer_fn = "BasicAuthFunction" if basic_auth else "HostRedirectFunction" %}
14
17
  # {% if has_token_kvs %}
15
18
  TokenKvs:
16
19
  Type: AWS::CloudFront::KeyValueStore
17
20
  Properties:
18
21
  Name: "{{ slug }}-token-kvs"
19
- Comment: "KVS for SPA token secret"
22
+ Comment: "KVS for SPA token secret / basic auth credential"
20
23
  # {% endif %}
21
24
 
22
25
  # {% for route in routes %}
@@ -40,7 +43,7 @@ Resources:
40
43
  FunctionConfig:
41
44
  Comment: "CloudFront function for {{ route.name }}"
42
45
  Runtime: cloudfront-js-2.0
43
- # {% if route.require_token %}
46
+ # {% if route.require_token or basic_auth %}
44
47
  KeyValueStoreAssociations:
45
48
  - KeyValueStoreARN:
46
49
  Fn::GetAtt: TokenKvs.Arn
@@ -69,6 +72,11 @@ Resources:
69
72
  FunctionConfig:
70
73
  Comment: "Strip deploy hash prefix for {{ route.name }}"
71
74
  Runtime: cloudfront-js-2.0
75
+ # {% if basic_auth %}
76
+ KeyValueStoreAssociations:
77
+ - KeyValueStoreARN:
78
+ Fn::GetAtt: TokenKvs.Arn
79
+ # {% endif %}
72
80
  # {% endif %}
73
81
  # {% endfor %}
74
82
 
@@ -83,13 +91,19 @@ Resources:
83
91
  FunctionConfig:
84
92
  Comment: "Add X-Forwarded-Host for API routes"
85
93
  Runtime: cloudfront-js-2.0
94
+ # {% if basic_auth %}
95
+ KeyValueStoreAssociations:
96
+ - KeyValueStoreARN:
97
+ Fn::GetAtt: TokenKvs.Arn
98
+ # {% endif %}
86
99
  # {% endif %}
87
100
 
88
- # {% if has_redirect_from %}
101
+ # {% if has_redirect_from and not basic_auth %}
89
102
  # 非 canonical host (redirect_from ドメイン) を canonical domain へ 301 する
90
103
  # viewer-request Function。既存 viewer-request Function を持たない behavior
91
104
  # (プレーン S3 route / managed asset) にのみ association する。既存 Function を
92
105
  # 持つ behavior には各 Function コード側に同等の redirect prelude を注入済み。
106
+ # basic_auth 時は BasicAuthFunction に redirect prelude が同居するため作らない。
93
107
  HostRedirectFunction:
94
108
  Type: AWS::CloudFront::Function
95
109
  Properties:
@@ -102,6 +116,26 @@ Resources:
102
116
  Runtime: cloudfront-js-2.0
103
117
  # {% endif %}
104
118
 
119
+ # {% if basic_auth %}
120
+ # distribution 全体の Basic 認証。既存 viewer-request Function を持たない
121
+ # behavior (プレーン S3 route / managed asset) にのみ association する。既存
122
+ # Function を持つ behavior には各 Function コード側に同等の prelude を注入済み。
123
+ # redirect_from 有効時は host redirect prelude もこの Function に同居する。
124
+ BasicAuthFunction:
125
+ Type: AWS::CloudFront::Function
126
+ Properties:
127
+ Name: "{{ slug }}-basic-auth"
128
+ AutoPublish: true
129
+ FunctionCode: |
130
+ {{ basic_auth_function_code }}
131
+ FunctionConfig:
132
+ Comment: "Basic auth gate for {{ slug }}"
133
+ Runtime: cloudfront-js-2.0
134
+ KeyValueStoreAssociations:
135
+ - KeyValueStoreARN:
136
+ Fn::GetAtt: TokenKvs.Arn
137
+ # {% endif %}
138
+
105
139
  CloudFrontDistribution:
106
140
  Type: AWS::CloudFront::Distribution
107
141
  DependsOn:
@@ -120,9 +154,12 @@ Resources:
120
154
  # {% if has_lambda_route %}
121
155
  - ApiHostFunction
122
156
  # {% endif %}
123
- # {% if has_redirect_from %}
157
+ # {% if has_redirect_from and not basic_auth %}
124
158
  - HostRedirectFunction
125
159
  # {% endif %}
160
+ # {% if basic_auth %}
161
+ - BasicAuthFunction
162
+ # {% endif %}
126
163
  Properties:
127
164
  DistributionConfig:
128
165
  # {% if domain %}
@@ -198,11 +235,11 @@ Resources:
198
235
  - EventType: viewer-request
199
236
  FunctionARN:
200
237
  Fn::GetAtt: UrlFallbackFunction{{ default_route.yaml_key }}.FunctionMetadata.FunctionARN
201
- # {% elif has_redirect_from %}
238
+ # {% elif basic_auth or has_redirect_from %}
202
239
  FunctionAssociations:
203
240
  - EventType: viewer-request
204
241
  FunctionARN:
205
- Fn::GetAtt: HostRedirectFunction.FunctionMetadata.FunctionARN
242
+ Fn::GetAtt: {{ bare_viewer_fn }}.FunctionMetadata.FunctionARN
206
243
  # {% endif %}
207
244
  # {% if default_route.versioning %}
208
245
  ResponseHeadersPolicyId:
@@ -232,11 +269,11 @@ Resources:
232
269
  FunctionARN:
233
270
  Fn::GetAtt: DeployHashStripFunction{{ route.yaml_key }}.FunctionMetadata.FunctionARN
234
271
  # {% endif %}
235
- # {% if not route.is_spa and not route.is_deploy_hash and has_redirect_from %}
272
+ # {% if not route.is_spa and not route.is_deploy_hash and (basic_auth or has_redirect_from) %}
236
273
  FunctionAssociations:
237
274
  - EventType: viewer-request
238
275
  FunctionARN:
239
- Fn::GetAtt: HostRedirectFunction.FunctionMetadata.FunctionARN
276
+ Fn::GetAtt: {{ bare_viewer_fn }}.FunctionMetadata.FunctionARN
240
277
  # {% endif %}
241
278
  # {% if route.versioning %}
242
279
  ResponseHeadersPolicyId:
@@ -271,11 +308,11 @@ Resources:
271
308
  TargetOriginId: "{{ resource_prefix }}pocket-managed"
272
309
  ViewerProtocolPolicy: "redirect-to-https"
273
310
  PathPattern: "/{{ filename }}"
274
- # {% if has_redirect_from %}
311
+ # {% if basic_auth or has_redirect_from %}
275
312
  FunctionAssociations:
276
313
  - EventType: viewer-request
277
314
  FunctionARN:
278
- Fn::GetAtt: HostRedirectFunction.FunctionMetadata.FunctionARN
315
+ Fn::GetAtt: {{ bare_viewer_fn }}.FunctionMetadata.FunctionARN
279
316
  # {% endif %}
280
317
  # {% endfor %}
281
318
  # {% endif %}
@@ -1,10 +1,10 @@
1
1
  [project]
2
2
  name = "magic-pocket-cli"
3
- version = "0.18.0"
3
+ version = "0.19.0"
4
4
  description = "CLI and deploy tools for magic-pocket."
5
5
  requires-python = ">=3.10"
6
6
  dependencies = [
7
- "magic-pocket>=0.18.0",
7
+ "magic-pocket>=0.19.0",
8
8
  "click>=8.1.7",
9
9
  "jinja2>=3.1.3",
10
10
  "python-on-whales>=0.68.0",