magic-pocket-cli 0.11.0__tar.gz → 0.13.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 (69) hide show
  1. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/PKG-INFO +2 -2
  2. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/cli/deploy_cli.py +6 -1
  3. magic_pocket_cli-0.13.0/pocket_cli/cli/migrate_cli.py +310 -0
  4. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/cli/url_helper.py +10 -5
  5. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/mediator.py +137 -39
  6. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/resources/aws/cloudformation.py +70 -14
  7. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/resources/cloudfront.py +18 -63
  8. magic_pocket_cli-0.13.0/pocket_cli/templates/cloudformation/cf_function_host_redirect_prelude.js +13 -0
  9. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/templates/cloudformation/cloudfront.yaml +45 -24
  10. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/templates/cloudformation/cloudfront_acm.yaml +13 -19
  11. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pyproject.toml +2 -2
  12. magic_pocket_cli-0.11.0/pocket_cli/cli/migrate_cli.py +0 -148
  13. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/.gitignore +0 -0
  14. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/__init__.py +0 -0
  15. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/cli/__init__.py +0 -0
  16. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/cli/aws_auth.py +0 -0
  17. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/cli/awscontainer_cli.py +0 -0
  18. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/cli/cloudfront_cli.py +0 -0
  19. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/cli/cloudfront_keys_cli.py +0 -0
  20. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/cli/cloudfront_waf_cli.py +0 -0
  21. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/cli/destroy_cli.py +0 -0
  22. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/cli/dsql_cli.py +0 -0
  23. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/cli/main_cli.py +0 -0
  24. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/cli/neon_cli.py +0 -0
  25. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/cli/permissions_cli.py +0 -0
  26. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/cli/rds_cli.py +0 -0
  27. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/cli/runtime_config_cli.py +0 -0
  28. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/cli/s3_cli.py +0 -0
  29. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/cli/status_cli.py +0 -0
  30. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/cli/store_url_helper.py +0 -0
  31. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/cli/tidb_cli.py +0 -0
  32. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/cli/upstash_cli.py +0 -0
  33. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/cli/vpc_cli.py +0 -0
  34. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/cli/waf_cli.py +0 -0
  35. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/django_cli.py +0 -0
  36. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/resources/__init__.py +0 -0
  37. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/resources/aws/__init__.py +0 -0
  38. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/resources/aws/builders/__init__.py +0 -0
  39. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/resources/aws/builders/codebuild.py +0 -0
  40. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/resources/aws/builders/depot.py +0 -0
  41. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/resources/aws/builders/docker.py +0 -0
  42. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/resources/aws/builders/dockerignore.py +0 -0
  43. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/resources/aws/ecr.py +0 -0
  44. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/resources/aws/efs.py +0 -0
  45. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/resources/aws/lambdahandler.py +0 -0
  46. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/resources/aws/s3_utils.py +0 -0
  47. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/resources/aws/state.py +0 -0
  48. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/resources/awscontainer.py +0 -0
  49. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/resources/cloudfront_acm.py +0 -0
  50. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/resources/cloudfront_keys.py +0 -0
  51. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/resources/cloudfront_waf.py +0 -0
  52. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/resources/dsql.py +0 -0
  53. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/resources/neon.py +0 -0
  54. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/resources/rds.py +0 -0
  55. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/resources/s3.py +0 -0
  56. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/resources/tidb.py +0 -0
  57. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/resources/upstash.py +0 -0
  58. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/resources/vpc.py +0 -0
  59. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/templates/cloudformation/awscontainer.yaml +0 -0
  60. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/templates/cloudformation/cf_function_api_host.js +0 -0
  61. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/templates/cloudformation/cf_function_spa_auth.js +0 -0
  62. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/templates/cloudformation/cf_function_spa_fallback.js +0 -0
  63. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/templates/cloudformation/cloudfront_keys.yaml +0 -0
  64. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/templates/cloudformation/cloudfront_waf.yaml +0 -0
  65. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/templates/cloudformation/vpc.yaml +0 -0
  66. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/templates/init/django-dotenv.env +0 -0
  67. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/templates/init/django-settings.py +0 -0
  68. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.0}/pocket_cli/templates/init/pocket.Dockerfile +0 -0
  69. {magic_pocket_cli-0.11.0 → magic_pocket_cli-0.13.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.11.0
3
+ Version: 0.13.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.11.0
10
+ Requires-Dist: magic-pocket>=0.13.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
@@ -3,7 +3,7 @@ import webbrowser
3
3
 
4
4
  import click
5
5
 
6
- from pocket.context import Context
6
+ from pocket.context import Context, deploy_hash_report
7
7
  from pocket.utils import echo
8
8
  from pocket_cli.mediator import Mediator
9
9
  from pocket_cli.resources.aws.state import StateStore
@@ -173,6 +173,11 @@ def _deploy_pipeline(context: Context, *, openpath=None, skip_frontend=False):
173
173
  promote 時は context.awscontainer.promote_commit_hash が設定済みで、
174
174
  deploy_init 内の image build が retag に置き換わる以外は deploy と同一。
175
175
  """
176
+ # DEPLOY_HASH の解決結果を deploy 時に 1 回可視化する (env 伝播漏れで
177
+ # 黙って git short hash に落ちる footgun の早期発見用)。
178
+ deploy_hash_message = deploy_hash_report(context)
179
+ if deploy_hash_message:
180
+ echo.info(deploy_hash_message)
176
181
  # CodeBuildがソースアップロードにstate bucketを必要とするため、先に作成
177
182
  state_store = _create_state_store(context)
178
183
  state_store.ensure_bucket()
@@ -0,0 +1,310 @@
1
+ """pocket migrate: バージョン間のデータ/スタック移行をまとめて実行する。
2
+
3
+ サブコマンド:
4
+
5
+ - ``template-hash``: 旧バージョンでデプロイされたスタックに pocket:template_hash
6
+ タグを付与し、yaml_synced の判定をハッシュベースに移行する。
7
+ - ``secret-paths``: stored user secret を旧キー基準パス ({pocket_key}-user/{key})
8
+ から新 type 基準パス ({pocket_key}-user/{type}) へ移設する (0.11→0.12)。
9
+
10
+ サブコマンド無指定 (``pocket migrate``) なら全 migration を冪等に順次実行する。
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+
17
+ import click
18
+ import yaml
19
+
20
+ from pocket import __version__
21
+ from pocket.context import Context
22
+ from pocket.utils import echo
23
+ from pocket_cli.cli.deploy_cli import get_resources
24
+
25
+ # --- template-hash migration -------------------------------------------------
26
+
27
+
28
+ def _get_existing_stacks(context: Context) -> list:
29
+ """デプロイ済みの全スタックを収集する"""
30
+ stacks = []
31
+ for resource in get_resources(context):
32
+ if hasattr(resource, "stack") and resource.stack.exists:
33
+ stacks.append(resource.stack)
34
+ return stacks
35
+
36
+
37
+ def _compute_uploaded_hash(stack) -> str | None:
38
+ """uploaded template からハッシュを計算する"""
39
+ uploaded = stack.uploaded_template
40
+ if uploaded is None:
41
+ return None
42
+ if isinstance(uploaded, str):
43
+ return hashlib.sha256(uploaded.encode()).hexdigest()[:16]
44
+ return hashlib.sha256(
45
+ yaml.dump(dict(uploaded), sort_keys=True).encode()
46
+ ).hexdigest()[:16]
47
+
48
+
49
+ def _backfill_tags(stacks: list) -> None:
50
+ """タグがないスタックに uploaded template のハッシュでタグを付与する"""
51
+ for stack in stacks:
52
+ if stack._deployed_template_hash is not None:
53
+ continue
54
+ h = _compute_uploaded_hash(stack)
55
+ if h is None:
56
+ continue
57
+ echo.log("タグ付与 (uploaded hash): %s (%s)" % (stack.name, h))
58
+ stack.client.update_stack(
59
+ StackName=stack.name,
60
+ UsePreviousTemplate=True,
61
+ Capabilities=stack.capabilities,
62
+ Tags=stack.stack_tags + [{"Key": "pocket:template_hash", "Value": h}],
63
+ )
64
+ stack.wait_status("COMPLETED", timeout=300, interval=5)
65
+ stack.clear_status()
66
+ echo.success("完了: %s" % stack.name)
67
+
68
+
69
+ def _find_stacks_needing_update(stacks: list) -> list:
70
+ """ローカルテンプレートとハッシュが異なるスタックを返す"""
71
+ targets = []
72
+ for stack in stacks:
73
+ if stack._deployed_template_hash == stack._template_hash:
74
+ echo.info("%s: タグは最新です" % stack.name)
75
+ continue
76
+ targets.append(stack)
77
+ return targets
78
+
79
+
80
+ def _apply_local_tags(targets: list) -> None:
81
+ """ローカルテンプレートのハッシュでタグを更新する"""
82
+ for stack in targets:
83
+ echo.log("タグ更新 (local hash): %s" % stack.name)
84
+ stack.client.update_stack(
85
+ StackName=stack.name,
86
+ UsePreviousTemplate=True,
87
+ Capabilities=stack.capabilities,
88
+ Tags=stack._build_tags(),
89
+ )
90
+ stack.wait_status("COMPLETED", timeout=300, interval=5)
91
+ echo.success("完了: %s" % stack.name)
92
+
93
+
94
+ def _check_real_diffs(stacks: list) -> list[str]:
95
+ """非 ASCII 文字化け以外のテンプレート差分があるスタックを返す"""
96
+ needs_deploy = []
97
+ for stack in stacks:
98
+ diff = stack.yaml_diff
99
+ if diff == {}:
100
+ continue
101
+ if set(diff.keys()) == {"values_changed"}:
102
+ all_garbled = all(
103
+ isinstance(c.get("old_value"), str) and "??" in c["old_value"]
104
+ for c in diff["values_changed"].values()
105
+ )
106
+ if all_garbled:
107
+ continue
108
+ needs_deploy.append(stack.name)
109
+ return needs_deploy
110
+
111
+
112
+ def _run_template_hash(stage: str, *, yes: bool) -> None:
113
+ """スタックのテンプレートハッシュタグを一括付与する (冪等)。
114
+
115
+ 未 deploy のテンプレ差分がある場合は SystemExit(1) で中断する
116
+ (前提条件ガード: 先に deploy が必要)。
117
+ """
118
+ context = Context.from_toml(stage=stage)
119
+ stacks = _get_existing_stacks(context)
120
+
121
+ if not stacks:
122
+ echo.warning("template-hash: 対象のスタックがありません。")
123
+ return
124
+
125
+ # 1. タグがないスタックに uploaded template のハッシュで仮タグを付与
126
+ _backfill_tags(stacks)
127
+
128
+ # 2. ローカルとの差分チェック(非 ASCII 文字化け以外の差分があれば中断)
129
+ needs_deploy = _check_real_diffs(stacks)
130
+ if needs_deploy:
131
+ echo.danger("以下のスタックにテンプレートの差分があります:")
132
+ for name in needs_deploy:
133
+ echo.info(" - %s" % name)
134
+ echo.danger(
135
+ "先に pocket deploy --stage=%s で最新バージョンを"
136
+ "デプロイしてから再実行してください。" % stage
137
+ )
138
+ raise SystemExit(1)
139
+
140
+ # 3. ローカルテンプレートのハッシュでタグを更新
141
+ targets = _find_stacks_needing_update(stacks)
142
+ if not targets:
143
+ echo.success("template-hash: 全スタックのタグが最新です。")
144
+ return
145
+
146
+ echo.info("以下のスタックのタグをローカルハッシュに更新します:")
147
+ for stack in targets:
148
+ echo.info(" - %s" % stack.name)
149
+ if not yes:
150
+ click.confirm("実行しますか?", abort=True)
151
+
152
+ _apply_local_tags(targets)
153
+ echo.success("template-hash: 全スタックの移行が完了しました。")
154
+
155
+
156
+ # --- secret-paths migration (0.11 -> 0.12) -----------------------------------
157
+
158
+ _SECRET_PATH_LABELS = {
159
+ "would-migrate": "移設予定 (copy→旧削除)",
160
+ "would-clean": "旧パス削除予定 (移行済)",
161
+ "migrated": "移設完了",
162
+ "cleaned": "旧パス削除 (移行済)",
163
+ "already": "移行不要",
164
+ "missing": "未 provision (store-url で作成してください)",
165
+ "skip-name": "name モード (対象外)",
166
+ }
167
+
168
+
169
+ def _report_secret_path(info: dict) -> None:
170
+ status = info["status"]
171
+ label = _SECRET_PATH_LABELS.get(status, status)
172
+ if status in ("migrated", "cleaned"):
173
+ echo.success(
174
+ "%s: %s (%s → %s)" % (info["key"], label, info["old"], info["new"])
175
+ )
176
+ elif status in ("would-migrate", "would-clean"):
177
+ echo.info("%s: %s (%s → %s)" % (info["key"], label, info["old"], info["new"]))
178
+ elif status == "missing":
179
+ echo.warning("%s: %s (%s)" % (info["key"], label, info.get("new")))
180
+ else:
181
+ echo.info("%s: %s" % (info["key"], label))
182
+
183
+
184
+ def _warn_runtime_bump_required(stage: str) -> None:
185
+ """secret-paths 移設で旧パスが削除されることの事前警告。
186
+
187
+ 移設は copy→旧削除を一括で行うため、この stage に deploy 済みの Lambda runtime
188
+ (magic-pocket[django]) が古いままだと、旧パス (削除済) を参照して DATABASE_URL を
189
+ 解決できず cold start の INIT で落ちる。移設後に runtime を CLI と同版へ bump して
190
+ 再デプロイするまで stage が黙って壊れる footgun を、移設前に明示する
191
+ (template-hash が「先に deploy」で中断するのと同じ思想の事前ガード)。
192
+ """
193
+ echo.warning(
194
+ "⚠ 移設すると旧パスは削除されます (copy→旧削除は一括)。この stage に "
195
+ "deploy 済みの Lambda runtime (magic-pocket[django]) が古いままだと、"
196
+ "旧パスを参照して DATABASE_URL を解決できず cold start の INIT で落ちます。"
197
+ )
198
+ echo.warning(
199
+ " 移設後は速やかに runtime を CLI と同版へ上げて再デプロイしてください:\n"
200
+ " uv add 'magic-pocket[django]>=%s'\n"
201
+ " pocket deploy --stage=%s (Django なら pocket django deploy --stage=%s)"
202
+ % (__version__, stage, stage)
203
+ )
204
+ echo.warning(
205
+ " ※ copy と旧削除が一括のため完全な無停止移行にはなりません。warm instance は "
206
+ "recycle まで生存するので、移設直後に再デプロイすれば影響を最小化できます。"
207
+ )
208
+
209
+
210
+ def _run_secret_paths(stage: str, *, yes: bool, dry_run: bool) -> None:
211
+ """stored user secret を旧キー基準→新 type 基準パスへ移設する (冪等)。"""
212
+ from pocket_cli.mediator import Mediator
213
+
214
+ context = Context.from_toml(stage=stage)
215
+ sc = context.awscontainer.secrets if context.awscontainer else None
216
+ type_specs = (
217
+ [(k, s) for k, s in sc.user.items() if s.type is not None] if sc else []
218
+ )
219
+ if not type_specs:
220
+ echo.info("secret-paths: type-mode の user secret がありません。")
221
+ return
222
+
223
+ mediator = Mediator(context)
224
+
225
+ # まず dry-run で計画を出す (AWS への書込みなし)
226
+ plans = [
227
+ mediator.migrate_user_secret_path(k, s, dry_run=True) for k, s in type_specs
228
+ ]
229
+ for info in plans:
230
+ _report_secret_path(info)
231
+ to_act = [p for p in plans if p["status"] in ("would-migrate", "would-clean")]
232
+ if not to_act:
233
+ echo.success(
234
+ "secret-paths: 移設対象はありません (全て移行済み or 未 provision)。"
235
+ )
236
+ return
237
+ _warn_runtime_bump_required(stage)
238
+ if dry_run:
239
+ echo.info("secret-paths: --dry-run のため実行はしません。")
240
+ return
241
+ if not yes:
242
+ click.confirm("上記の移設を実行しますか?", abort=True)
243
+
244
+ for key, spec in type_specs:
245
+ info = mediator.migrate_user_secret_path(key, spec, dry_run=False)
246
+ _report_secret_path(info)
247
+ echo.success("secret-paths: 移設が完了しました。")
248
+
249
+
250
+ # --- CLI 配線 -----------------------------------------------------------------
251
+
252
+
253
+ @click.group(invoke_without_command=True)
254
+ @click.option("--stage", envvar="POCKET_DEPLOY_STAGE", default=None)
255
+ @click.option(
256
+ "--yes", "-y", is_flag=True, default=False, help="確認プロンプトをスキップ"
257
+ )
258
+ @click.pass_context
259
+ def migrate(ctx: click.Context, stage: str | None, yes: bool):
260
+ """バージョン間の移行をまとめて実行する。
261
+
262
+ サブコマンド無指定なら全 migration (secret-paths → template-hash) を
263
+ 冪等に順次実行する。
264
+ """
265
+ if ctx.invoked_subcommand is not None:
266
+ return
267
+ # bare 実行: 全 migration を冪等実行
268
+ from pocket_cli.cli.aws_auth import check_aws_credentials
269
+
270
+ check_aws_credentials()
271
+ resolved_stage: str = stage or click.prompt("Stage")
272
+ # secret-paths を先に (高速・ガードで中断しない)、template-hash を後に。
273
+ _run_secret_paths(resolved_stage, yes=yes, dry_run=False)
274
+ try:
275
+ _run_template_hash(resolved_stage, yes=yes)
276
+ except SystemExit:
277
+ echo.warning(
278
+ "template-hash はテンプレ差分のため中断しました "
279
+ "(secret-paths は完了済み)。pocket deploy --stage=%s の後に "
280
+ "pocket migrate を再実行してください。" % resolved_stage
281
+ )
282
+
283
+
284
+ @migrate.command(name="template-hash")
285
+ @click.option("--stage", envvar="POCKET_DEPLOY_STAGE", prompt=True)
286
+ @click.option(
287
+ "--yes", "-y", is_flag=True, default=False, help="確認プロンプトをスキップ"
288
+ )
289
+ def template_hash(stage: str, yes: bool):
290
+ """スタックのテンプレートハッシュタグを一括付与する。"""
291
+ from pocket_cli.cli.aws_auth import check_aws_credentials
292
+
293
+ check_aws_credentials()
294
+ _run_template_hash(stage, yes=yes)
295
+
296
+
297
+ @migrate.command(name="secret-paths")
298
+ @click.option("--stage", envvar="POCKET_DEPLOY_STAGE", prompt=True)
299
+ @click.option(
300
+ "--yes", "-y", is_flag=True, default=False, help="確認プロンプトをスキップ"
301
+ )
302
+ @click.option(
303
+ "--dry-run", is_flag=True, default=False, help="移設内容を表示のみ (書込みなし)"
304
+ )
305
+ def secret_paths(stage: str, yes: bool, dry_run: bool):
306
+ """stored user secret を旧キー基準→新 type 基準パスへ移設する (0.11→0.12)。"""
307
+ from pocket_cli.cli.aws_auth import check_aws_credentials
308
+
309
+ check_aws_credentials()
310
+ _run_secret_paths(stage, yes=yes, dry_run=dry_run)
@@ -63,19 +63,24 @@ def run_get_url(
63
63
 
64
64
 
65
65
  def _read_stored_url(context: Context, secret_type: str) -> str | None:
66
- """``type=<secret_type>`` の stored user secret 1 件だけ特定して値を読む。
66
+ """``type=<secret_type>`` の stored URL を読む。未 provision なら None。
67
67
 
68
- 宣言が 0 件なら None (→ live fallback)、複数なら曖昧として ClickException。
68
+ consumer user secret 宣言が在れば ``spec.store`` override を尊重して読む。
69
+ 宣言が無くても (dual-declaration で DATABASE_URL が別 backend を指す等)、
70
+ type 基準の正準パスを直接構築して読む — つまり「その backend の stored URL」を
71
+ consumer 宣言の有無と無関係に引ける。両方無ければ None (→ live fallback)。
69
72
  """
70
73
  sc = context.awscontainer.secrets if context.awscontainer else None
71
74
  if sc is None:
72
75
  return None
76
+ mediator = Mediator(context)
73
77
  specs = [spec for spec in sc.user.values() if spec.type == secret_type]
74
- if not specs:
75
- return None
76
78
  if len(specs) > 1:
79
+ # 通常は settings の check_user_type_unique で弾かれる (防御的)。
77
80
  raise click.ClickException(
78
81
  "type=%s の stored user secret が複数宣言されているため曖昧です。"
79
82
  % secret_type
80
83
  )
81
- return Mediator(context).read_user_secret(specs[0])
84
+ if specs:
85
+ return mediator.read_user_secret(specs[0])
86
+ return mediator.read_stored_url_by_type(secret_type)
@@ -144,52 +144,92 @@ class Mediator:
144
144
  store = spec.store or sc.store
145
145
  return self._stored_secret_exists(spec.name, store, sc.region)
146
146
 
147
- def store_user_secret(self, spec, value: str) -> None:
148
- """stored mode user secret の正準名 (spec.name) に単一値を書き込む。
147
+ def _require_secrets(self):
148
+ """awscontainer.secrets (SecretsContext) を返す。未設定なら raise。"""
149
+ ac = self.context.awscontainer
150
+ if ac is None or ac.secrets is None:
151
+ raise RuntimeError("awscontainer secrets is not configured")
152
+ return ac.secrets
149
153
 
150
- `pocket <db> store-url` から使う。pocket_store (managed 集約) ではなく
151
- user secret の導出名 ({pocket_key}-user/{KEY}) に直接 put する。
152
- 読み側 _stored_secret_exists と対称。
153
- """
154
+ def _put_stored_value(self, name: str, store: str, value: str, region: str) -> None:
155
+ """正準名 (name) に単一値を書き込む (ssm=SecureString / sm=create|put)。"""
154
156
  import boto3
155
157
  from botocore.exceptions import ClientError
156
158
 
157
- if (
158
- self.context.awscontainer is None
159
- or self.context.awscontainer.secrets is None
160
- ):
161
- raise RuntimeError("awscontainer secrets is not configured")
162
- sc = self.context.awscontainer.secrets
163
- if spec.name is None:
164
- raise RuntimeError("user secret name is not resolved")
165
- store = spec.store or sc.store
166
159
  if store == "ssm":
167
- boto3.client("ssm", region_name=sc.region).put_parameter(
168
- Name=spec.name, Value=value, Type="SecureString", Overwrite=True
160
+ boto3.client("ssm", region_name=region).put_parameter(
161
+ Name=name, Value=value, Type="SecureString", Overwrite=True
169
162
  )
170
163
  return
171
- client = boto3.client("secretsmanager", region_name=sc.region)
164
+ client = boto3.client("secretsmanager", region_name=region)
172
165
  try:
173
166
  client.create_secret(
174
- Name=spec.name,
167
+ Name=name,
175
168
  SecretString=value,
176
- Tags=[{"Key": "Name", "Value": spec.name}],
169
+ Tags=[{"Key": "Name", "Value": name}],
177
170
  )
178
171
  except ClientError as e:
179
172
  if e.response["Error"]["Code"] == "ResourceExistsException":
180
- client.put_secret_value(SecretId=spec.name, SecretString=value)
173
+ client.put_secret_value(SecretId=name, SecretString=value)
181
174
  else:
182
175
  raise
183
176
 
177
+ def _read_stored_value(self, name: str, store: str, region: str) -> str | None:
178
+ """正準名 (name) の値を読む。未 provision (NotFound) なら None。"""
179
+ import boto3
180
+ from botocore.exceptions import ClientError
181
+
182
+ try:
183
+ if store == "ssm":
184
+ res = boto3.client("ssm", region_name=region).get_parameter(
185
+ Name=name, WithDecryption=True
186
+ )
187
+ return res["Parameter"]["Value"]
188
+ res = boto3.client("secretsmanager", region_name=region).get_secret_value(
189
+ SecretId=name
190
+ )
191
+ return res["SecretString"]
192
+ except ClientError as e:
193
+ code = e.response.get("Error", {}).get("Code", "")
194
+ if code in ("ParameterNotFound", "ResourceNotFoundException"):
195
+ return None
196
+ raise
197
+
198
+ def _delete_stored_value(self, name: str, store: str, region: str) -> None:
199
+ """正準名 (name) を削除する (migrate の旧パス cleanup 用)。"""
200
+ import boto3
201
+
202
+ if store == "ssm":
203
+ boto3.client("ssm", region_name=region).delete_parameter(Name=name)
204
+ else:
205
+ boto3.client("secretsmanager", region_name=region).delete_secret(
206
+ SecretId=name
207
+ )
208
+
209
+ def store_user_secret(self, spec, value: str) -> None:
210
+ """stored mode user secret の正準名 (spec.name) に単一値を書き込む。
211
+
212
+ `pocket <db> store-url` から使う。pocket_store (managed 集約) ではなく
213
+ user secret の type 基準導出名 ({pocket_key}-user/{type}) に直接 put する。
214
+ 読み側 _stored_secret_exists と対称。
215
+ """
216
+ if (
217
+ self.context.awscontainer is None
218
+ or self.context.awscontainer.secrets is None
219
+ ):
220
+ raise RuntimeError("awscontainer secrets is not configured")
221
+ sc = self.context.awscontainer.secrets
222
+ if spec.name is None:
223
+ raise RuntimeError("user secret name is not resolved")
224
+ store = spec.store or sc.store
225
+ self._put_stored_value(spec.name, store, value, sc.region)
226
+
184
227
  def read_user_secret(self, spec) -> str | None:
185
228
  """stored mode user secret (spec.name) の値を読む。未 provision なら None。
186
229
 
187
230
  store_user_secret / _stored_secret_exists と対称の読み取り。
188
231
  `pocket <db> url` の stored-first 解決に使う。管理 API は叩かない。
189
232
  """
190
- import boto3
191
- from botocore.exceptions import ClientError
192
-
193
233
  if (
194
234
  self.context.awscontainer is None
195
235
  or self.context.awscontainer.secrets is None
@@ -198,21 +238,79 @@ class Mediator:
198
238
  return None
199
239
  sc = self.context.awscontainer.secrets
200
240
  store = spec.store or sc.store
201
- try:
202
- if store == "ssm":
203
- res = boto3.client("ssm", region_name=sc.region).get_parameter(
204
- Name=spec.name, WithDecryption=True
205
- )
206
- return res["Parameter"]["Value"]
207
- res = boto3.client(
208
- "secretsmanager", region_name=sc.region
209
- ).get_secret_value(SecretId=spec.name)
210
- return res["SecretString"]
211
- except ClientError as e:
212
- code = e.response.get("Error", {}).get("Code", "")
213
- if code in ("ParameterNotFound", "ResourceNotFoundException"):
214
- return None
215
- raise
241
+ return self._read_stored_value(spec.name, store, sc.region)
242
+
243
+ def read_stored_url_by_type(self, secret_type: str) -> str | None:
244
+ """type 基準の正準パスを (consumer 宣言なしで) 構築して stored URL を読む。
245
+
246
+ dual-declaration で consumer (DATABASE_URL) が別 backend を指していても、
247
+ 「その backend の stored URL」を type から直接引ける。store は secrets.store
248
+ 既定 (per-type override は宣言でしか表現できないため)。
249
+ """
250
+ if (
251
+ self.context.awscontainer is None
252
+ or self.context.awscontainer.secrets is None
253
+ ):
254
+ return None
255
+ sc = self.context.awscontainer.secrets
256
+ name = sc.stored_url_name(secret_type)
257
+ return self._read_stored_value(name, sc.store, sc.region)
258
+
259
+ def migrate_user_secret_path(
260
+ self, key: str, spec, *, dry_run: bool = False
261
+ ) -> dict:
262
+ """0.11→0.12: user secret を旧キー基準パス→新 type 基準パスへ移設する。
263
+
264
+ 旧 /{pocket_key}-user/{key} の値を新 /{pocket_key}-user/{type} へ copy し、
265
+ verify 後に旧を delete する。冪等 (新在+旧在なら旧のみ delete して cleanup を
266
+ 完了)。戻り値の status: ``migrated`` / ``cleaned`` / ``already`` / ``missing``
267
+ / ``skip-name`` (dry_run では ``would-migrate`` / ``would-clean``)。
268
+ """
269
+ from pocket.context import user_secret_path
270
+
271
+ sc = self._require_secrets()
272
+ if spec.type is None: # name モードは移設対象外
273
+ return {"status": "skip-name", "key": key}
274
+ store = spec.store or sc.store
275
+ old_name = user_secret_path(sc.pocket_key, key, store)
276
+ new_name = sc.stored_url_name(spec.type, store)
277
+ info = {
278
+ "status": None,
279
+ "key": key,
280
+ "type": spec.type,
281
+ "old": old_name,
282
+ "new": new_name,
283
+ }
284
+ if old_name == new_name: # 既に type == key (レア) なら移設不要
285
+ info["status"] = "already"
286
+ return info
287
+ region = sc.region
288
+ new_exists = self._stored_secret_exists(new_name, store, region)
289
+ old_exists = self._stored_secret_exists(old_name, store, region)
290
+ if new_exists:
291
+ if old_exists:
292
+ if not dry_run:
293
+ self._delete_stored_value(old_name, store, region)
294
+ info["status"] = "would-clean" if dry_run else "cleaned"
295
+ else:
296
+ info["status"] = "already"
297
+ return info
298
+ if not old_exists:
299
+ info["status"] = "missing"
300
+ return info
301
+ if dry_run:
302
+ info["status"] = "would-migrate"
303
+ return info
304
+ value = self._read_stored_value(old_name, store, region)
305
+ if value is None:
306
+ info["status"] = "missing"
307
+ return info
308
+ self._put_stored_value(new_name, store, value, region)
309
+ if self._read_stored_value(new_name, store, region) != value:
310
+ raise RuntimeError("copy verify failed: %s" % new_name)
311
+ self._delete_stored_value(old_name, store, region)
312
+ info["status"] = "migrated"
313
+ return info
216
314
 
217
315
  def _cleanup_orphaned_secrets(self):
218
316
  """SSM/SM にあるが managed 定義にないシークレットを削除する"""