magic-pocket-cli 0.2.0__py3-none-any.whl

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 (65) hide show
  1. magic_pocket_cli-0.2.0.dist-info/METADATA +14 -0
  2. magic_pocket_cli-0.2.0.dist-info/RECORD +65 -0
  3. magic_pocket_cli-0.2.0.dist-info/WHEEL +4 -0
  4. magic_pocket_cli-0.2.0.dist-info/entry_points.txt +2 -0
  5. pocket_cli/__init__.py +0 -0
  6. pocket_cli/cli/__init__.py +0 -0
  7. pocket_cli/cli/aws_auth.py +48 -0
  8. pocket_cli/cli/awscontainer_cli.py +328 -0
  9. pocket_cli/cli/cloudfront_cli.py +116 -0
  10. pocket_cli/cli/cloudfront_keys_cli.py +68 -0
  11. pocket_cli/cli/cloudfront_waf_cli.py +68 -0
  12. pocket_cli/cli/deploy_cli.py +274 -0
  13. pocket_cli/cli/destroy_cli.py +358 -0
  14. pocket_cli/cli/dsql_cli.py +60 -0
  15. pocket_cli/cli/main_cli.py +91 -0
  16. pocket_cli/cli/migrate_cli.py +148 -0
  17. pocket_cli/cli/neon_cli.py +97 -0
  18. pocket_cli/cli/permissions_cli.py +46 -0
  19. pocket_cli/cli/rds_cli.py +63 -0
  20. pocket_cli/cli/runtime_config_cli.py +185 -0
  21. pocket_cli/cli/s3_cli.py +69 -0
  22. pocket_cli/cli/status_cli.py +56 -0
  23. pocket_cli/cli/tidb_cli.py +73 -0
  24. pocket_cli/cli/vpc_cli.py +92 -0
  25. pocket_cli/cli/waf_cli.py +182 -0
  26. pocket_cli/django_cli.py +412 -0
  27. pocket_cli/mediator.py +220 -0
  28. pocket_cli/resources/__init__.py +0 -0
  29. pocket_cli/resources/aws/__init__.py +0 -0
  30. pocket_cli/resources/aws/builders/__init__.py +57 -0
  31. pocket_cli/resources/aws/builders/codebuild.py +363 -0
  32. pocket_cli/resources/aws/builders/depot.py +84 -0
  33. pocket_cli/resources/aws/builders/docker.py +34 -0
  34. pocket_cli/resources/aws/builders/dockerignore.py +44 -0
  35. pocket_cli/resources/aws/cloudformation.py +790 -0
  36. pocket_cli/resources/aws/ecr.py +145 -0
  37. pocket_cli/resources/aws/efs.py +138 -0
  38. pocket_cli/resources/aws/lambdahandler.py +182 -0
  39. pocket_cli/resources/aws/s3_utils.py +58 -0
  40. pocket_cli/resources/aws/state.py +74 -0
  41. pocket_cli/resources/awscontainer.py +265 -0
  42. pocket_cli/resources/cloudfront.py +491 -0
  43. pocket_cli/resources/cloudfront_acm.py +55 -0
  44. pocket_cli/resources/cloudfront_keys.py +81 -0
  45. pocket_cli/resources/cloudfront_waf.py +67 -0
  46. pocket_cli/resources/dsql.py +142 -0
  47. pocket_cli/resources/neon.py +353 -0
  48. pocket_cli/resources/rds.py +680 -0
  49. pocket_cli/resources/s3.py +307 -0
  50. pocket_cli/resources/tidb.py +298 -0
  51. pocket_cli/resources/upstash.py +152 -0
  52. pocket_cli/resources/vpc.py +67 -0
  53. pocket_cli/templates/cloudformation/awscontainer.yaml +516 -0
  54. pocket_cli/templates/cloudformation/cf_function_api_host.js +5 -0
  55. pocket_cli/templates/cloudformation/cf_function_spa_auth.js +28 -0
  56. pocket_cli/templates/cloudformation/cf_function_spa_fallback.js +8 -0
  57. pocket_cli/templates/cloudformation/cloudfront.yaml +309 -0
  58. pocket_cli/templates/cloudformation/cloudfront_acm.yaml +43 -0
  59. pocket_cli/templates/cloudformation/cloudfront_keys.yaml +32 -0
  60. pocket_cli/templates/cloudformation/cloudfront_waf.yaml +97 -0
  61. pocket_cli/templates/cloudformation/vpc.yaml +213 -0
  62. pocket_cli/templates/init/django-dotenv.env +3 -0
  63. pocket_cli/templates/init/django-settings.py +140 -0
  64. pocket_cli/templates/init/pocket.Dockerfile +26 -0
  65. pocket_cli/templates/init/pocket_simple.toml +31 -0
@@ -0,0 +1,412 @@
1
+ import importlib.util
2
+ import json
3
+ import os
4
+ import shutil
5
+ import warnings
6
+ import webbrowser
7
+ from pathlib import Path
8
+ from subprocess import run
9
+
10
+ import click
11
+ from django.core.management.utils import get_random_secret_key
12
+ from jinja2 import Environment, PackageLoader, select_autoescape
13
+
14
+ from pocket.context import Context
15
+ from pocket.django import django_installed
16
+ from pocket.django.utils import get_storages
17
+ from pocket.utils import echo
18
+ from pocket_cli.resources.awscontainer import AwsContainer
19
+
20
+
21
+ @click.group()
22
+ def django():
23
+ pass
24
+
25
+
26
+ @django.command()
27
+ def init():
28
+ jinja2_env = Environment(
29
+ loader=PackageLoader("pocket_cli"),
30
+ autoescape=select_autoescape(),
31
+ keep_trailing_newline=True,
32
+ )
33
+ project_name = Path(".").resolve().name
34
+ with open("pocket.toml", "w") as f:
35
+ f.write(jinja2_env.get_template(name="init/pocket_simple.toml").render())
36
+ echo.success("Update: pocket.toml")
37
+ with open("pocket.Dockerfile", "w") as f:
38
+ f.write(jinja2_env.get_template(name="init/pocket.Dockerfile").render())
39
+ echo.success("Update: pocket.Dockerfile")
40
+ if importlib.util.find_spec("environ") is not None:
41
+ with open(f"{project_name}/settings.py", "w") as f:
42
+ echo.success("Update: settings.py")
43
+ f.write(
44
+ jinja2_env.get_template(name="init/django-settings.py").render(
45
+ project_name=project_name
46
+ )
47
+ )
48
+ _update_dotenv(jinja2_env)
49
+ else:
50
+ echo.warning("django-environ is not installed")
51
+ echo.warning("`settings.py` and `.env` file will be updated with it.")
52
+
53
+
54
+ def _update_dotenv(jinja2_env):
55
+ dotenv_path = Path(".env")
56
+ dotenv_content = jinja2_env.get_template(name="init/django-dotenv.env").render(
57
+ secret_key=get_random_secret_key()
58
+ )
59
+ echo.info("You may need to update .env file")
60
+ if click.confirm("Do you want to check the content?", default=True):
61
+ echo.info(dotenv_content)
62
+ if not click.confirm("Do you want to create .env file?"):
63
+ return
64
+ if dotenv_path.exists():
65
+ echo.warning(".env file already exists")
66
+ if not click.confirm("Do you want to overwrite .env file?"):
67
+ echo.warning("ensure you have correct values in .env file")
68
+ echo.info("sample .env file:")
69
+ echo.info(dotenv_content)
70
+ return
71
+ elif click.confirm("Log the deleting .env?", default=True):
72
+ echo.info(dotenv_path.read_text())
73
+ echo.danger("The contnet above was deleted")
74
+ with open(dotenv_path, "w") as f:
75
+ f.write(dotenv_content)
76
+ echo.success("Update: .env")
77
+
78
+
79
+ @django.command()
80
+ @click.option("--stage", envvar="POCKET_DEPLOY_STAGE", prompt=True)
81
+ @click.option("--openpath")
82
+ @click.option(
83
+ "--yes", "-y", is_flag=True, default=False, help="確認プロンプトをスキップ"
84
+ )
85
+ @click.option(
86
+ "--skip-check-existing",
87
+ is_flag=True,
88
+ default=False,
89
+ help="neon/tidb/upstash の存在確認 API を skip し COMPLETED 扱いで deploy",
90
+ )
91
+ def deploy(stage: str, openpath, yes, skip_check_existing):
92
+ from pocket_cli.cli.deploy_cli import deploy as pocket_deploy
93
+
94
+ # pocket deploy を実行(インフラ + SPA フロントエンド)
95
+ ctx = click.Context(pocket_deploy)
96
+ ctx.invoke(
97
+ pocket_deploy,
98
+ stage=stage,
99
+ openpath=None,
100
+ skip_frontend=False,
101
+ skip_check_existing=skip_check_existing,
102
+ )
103
+ _django_post_deploy(stage, yes=yes, openpath=openpath)
104
+
105
+
106
+ def _django_post_deploy(stage: str, *, yes: bool, openpath):
107
+ """deploy / promote 共通の Django 固有後処理 (collectstatic + migrate + URL)。"""
108
+ context = Context.from_toml(stage=stage)
109
+ if yes or click.confirm("deploystatic?", default=True):
110
+ collectstatic_locally(stage)
111
+ upload_collected_staticfiles(stage)
112
+ handler = _get_management_command_handler(context)
113
+ if yes or click.confirm("migrate?", default=True):
114
+ res = handler.invoke(json.dumps({"command": "migrate", "args": []}))
115
+ handler.show_logs(res)
116
+ from pocket_cli.cli.deploy_cli import _get_deploy_url
117
+
118
+ url = _get_deploy_url(context)
119
+ if url:
120
+ echo.success(f"url: {url}")
121
+ if openpath:
122
+ webbrowser.open(url + "/" + openpath)
123
+
124
+
125
+ @django.command()
126
+ @click.option("--stage", envvar="POCKET_DEPLOY_STAGE", prompt=True)
127
+ @click.option("--commit-hash", required=True, help="昇格する image の git commit hash")
128
+ @click.option("--openpath")
129
+ @click.option(
130
+ "--yes", "-y", is_flag=True, default=False, help="確認プロンプトをスキップ"
131
+ )
132
+ @click.option(
133
+ "--skip-check-existing",
134
+ is_flag=True,
135
+ default=False,
136
+ help="neon/tidb/upstash の存在確認 API を skip し COMPLETED 扱いで deploy",
137
+ )
138
+ def promote(stage: str, commit_hash, openpath, yes, skip_check_existing):
139
+ """build 済みの :<commit-hash> image へ stage を向けて deploy する (再ビルドなし)。
140
+
141
+ `pocket django build` で push した image に :<stage> タグを移し、インフラ/Lambda
142
+ を更新する。image build は行わない (build once の昇格)。静的アセットは deploy 時
143
+ と同様にローカルでビルドして upload する。
144
+ """
145
+ from pocket_cli.cli.deploy_cli import promote as pocket_promote
146
+
147
+ ctx = click.Context(pocket_promote)
148
+ ctx.invoke(
149
+ pocket_promote,
150
+ stage=stage,
151
+ commit_hash=commit_hash,
152
+ openpath=None,
153
+ skip_frontend=False,
154
+ skip_check_existing=skip_check_existing,
155
+ )
156
+ _django_post_deploy(stage, yes=yes, openpath=openpath)
157
+
158
+
159
+ @django.command()
160
+ @click.option("--stage", envvar="POCKET_DEPLOY_STAGE", prompt=True)
161
+ @click.option(
162
+ "--allow-dirty",
163
+ is_flag=True,
164
+ default=False,
165
+ help="working tree が dirty でも build する (ローカル検証用)",
166
+ )
167
+ def build(stage: str, allow_dirty: bool):
168
+ """現在の作業ツリーを build し、git commit hash をタグにして ECR へ push する。
169
+
170
+ deploy はしない (build once)。`pocket django promote --commit-hash <sha>` で
171
+ このイメージへ昇格する。タグは COMMIT_HASH 環境変数があればそれを、なければ
172
+ `git rev-parse HEAD` を使う。
173
+
174
+ commit hash = image 内容 の同一性が前提のため、working tree が dirty の場合は
175
+ エラーになる (--allow-dirty で回避可能)。
176
+ """
177
+ from pocket.context import get_commit_hash, is_working_tree_dirty
178
+ from pocket_cli.cli.aws_auth import check_aws_credentials
179
+ from pocket_cli.cli.deploy_cli import build_image
180
+
181
+ check_aws_credentials()
182
+ if not allow_dirty and is_working_tree_dirty():
183
+ raise click.ClickException(
184
+ "working tree に未コミットの変更があります。build once では"
185
+ " commit hash と image 内容の一致が前提のため、commit してから"
186
+ " build してください (--allow-dirty で回避できますが、その image の"
187
+ " 昇格は推奨しません)。"
188
+ )
189
+ try:
190
+ tag = get_commit_hash()
191
+ except RuntimeError as e:
192
+ raise click.ClickException(str(e))
193
+ context = Context.from_toml(stage=stage)
194
+ target = build_image(context, tag=tag)
195
+ echo.success("built and pushed: %s" % target)
196
+
197
+
198
+ def _get_management_command_handler(context: Context, key: str | None = None):
199
+ if not context.awscontainer:
200
+ raise Exception("awscontainer is not configured for this stage")
201
+ ac = AwsContainer(context.awscontainer)
202
+ if key:
203
+ warnings.warn(
204
+ "Do not use key to get management command handler",
205
+ DeprecationWarning,
206
+ stacklevel=2,
207
+ )
208
+ return ac.handlers[key]
209
+ target_command = "pocket.django.lambda_handlers.management_command_handler"
210
+ for key, handler_context in context.awscontainer.handlers.items():
211
+ if handler_context.command == target_command:
212
+ return ac.handlers[key]
213
+ print("management command handler not found")
214
+ raise Exception("Add management command handler for this stage")
215
+
216
+
217
+ class DeploystaticConfigError(Exception):
218
+ pass
219
+
220
+
221
+ def get_deploystatic_local_storage(stage: str):
222
+ stage_storages = get_storages(stage=stage)
223
+ if "staticfiles" not in stage_storages:
224
+ raise Exception("staticfiles storage not found in the stage %s" % stage)
225
+ storage = stage_storages["staticfiles"]
226
+ _SFS = "django.contrib.staticfiles.storage.StaticFilesStorage"
227
+ _MSFS = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"
228
+ if storage["BACKEND"] in [_SFS, _MSFS]:
229
+ # deploy_hash モード等で既にローカル backend が返されている
230
+ local_backend = storage["BACKEND"]
231
+ elif storage["BACKEND"] in [
232
+ "storages.backends.s3boto3.S3StaticStorage",
233
+ "pocket.django.storages.CloudFrontS3StaticStorage",
234
+ ]:
235
+ local_backend = _SFS
236
+ elif storage["BACKEND"] in [
237
+ "storages.backends.s3boto3.S3ManifestStaticStorage",
238
+ "pocket.django.storages.CloudFrontS3ManifestStaticStorage",
239
+ ]:
240
+ local_backend = _MSFS
241
+ else:
242
+ raise DeploystaticConfigError(
243
+ "BACKEND %s is not supported" % storage["BACKEND"]
244
+ )
245
+ local_build_static_root = Path.cwd() / "pocket_cache" / "static_build" / stage
246
+ return {
247
+ "BACKEND": local_backend,
248
+ "OPTIONS": {"location": str(local_build_static_root)},
249
+ }
250
+
251
+
252
+ def can_deploystatic(stage: str):
253
+ try:
254
+ get_deploystatic_local_storage(stage)
255
+ return True
256
+ except DeploystaticConfigError:
257
+ return False
258
+
259
+
260
+ def set_staticfiles_override_env(storage):
261
+ if os.environ.get("POCKET_STATICFILES_BACKEND_OVERRIDE"):
262
+ raise Exception("POCKET_STATICFILES_BACKEND_OVERRIDE is already set")
263
+ if os.environ.get("POCKET_STATICFILES_LOCATION_OVERRIDE"):
264
+ raise Exception("POCKET_STATICFILES_LOCATION_OVERRIDE is already set")
265
+ os.environ["POCKET_STATICFILES_BACKEND_OVERRIDE"] = storage["BACKEND"]
266
+ os.environ["POCKET_STATICFILES_LOCATION_OVERRIDE"] = storage["OPTIONS"]["location"]
267
+
268
+
269
+ def clear_staticfiles_override_env():
270
+ os.environ.pop("POCKET_STATICFILES_BACKEND_OVERRIDE", None)
271
+ os.environ.pop("POCKET_STATICFILES_LOCATION_OVERRIDE", None)
272
+
273
+
274
+ def upload_collected_staticfiles(stage: str):
275
+ from pocket.django.utils import get_static_storage_s3_options
276
+
277
+ s3_options = get_static_storage_s3_options(stage=stage)
278
+ local_storage = get_deploystatic_local_storage(stage)
279
+ s3_bucket_name = s3_options["bucket_name"]
280
+ s3_location = s3_options["location"]
281
+ echo.info("Bucket: %s" % s3_bucket_name)
282
+ echo.info("Location: %s" % s3_location)
283
+ echo.info("Uploading static files...")
284
+ run(
285
+ "aws s3 sync %s s3://%s/%s/ --delete"
286
+ % (local_storage["OPTIONS"]["location"], s3_bucket_name, s3_location),
287
+ shell=True,
288
+ check=True,
289
+ )
290
+
291
+
292
+ def _build_python_command(args: list[str]) -> list[str]:
293
+ """プロジェクトのPythonでコマンドを実行するためのコマンドリストを構築する。
294
+
295
+ uv があれば uv run python を使い、
296
+ なければ python3 / python にフォールバック。
297
+ """
298
+ if shutil.which("uv"):
299
+ return ["uv", "run", "python", *args]
300
+ for name in ("python3", "python"):
301
+ if shutil.which(name):
302
+ return [name, *args]
303
+ raise FileNotFoundError(
304
+ "pythonが見つかりません。uvまたはpythonをインストールしてください。"
305
+ )
306
+
307
+
308
+ def _get_project_dir(stage: str) -> str | None:
309
+ """pocket.toml の awscontainer.django.project_dir を取得する"""
310
+ context = Context.from_toml(stage=stage)
311
+ if context.awscontainer and context.awscontainer.django:
312
+ return context.awscontainer.django.project_dir
313
+ return None
314
+
315
+
316
+ def collectstatic_locally(stage: str):
317
+ local_storage = get_deploystatic_local_storage(stage)
318
+ echo.info("collectstatic to %s..." % local_storage["OPTIONS"]["location"])
319
+ set_staticfiles_override_env(local_storage)
320
+ cmd = _build_python_command(["manage.py", "collectstatic", "--noinput"])
321
+ project_dir = _get_project_dir(stage)
322
+ run(cmd, check=True, cwd=project_dir)
323
+ clear_staticfiles_override_env()
324
+
325
+
326
+ @django.command()
327
+ @click.option("--stage", envvar="POCKET_DEPLOY_STAGE", prompt=True)
328
+ @click.option("--skip-collectstatic", is_flag=True, default=False)
329
+ def deploystatic(stage: str, skip_collectstatic: bool):
330
+ if not skip_collectstatic:
331
+ collectstatic_locally(stage)
332
+ upload_collected_staticfiles(stage)
333
+
334
+
335
+ @django.command(
336
+ context_settings={
337
+ "ignore_unknown_options": True,
338
+ },
339
+ )
340
+ @click.option("--stage", envvar="POCKET_DEPLOY_STAGE", prompt=True)
341
+ @click.argument("command")
342
+ @click.argument("args", nargs=-1)
343
+ @click.option("--handler")
344
+ @click.option("--timeout-seconds", type=int)
345
+ def manage(stage, command, args, handler, timeout_seconds):
346
+ if not django_installed:
347
+ raise Exception("django is not installed")
348
+ context = Context.from_toml(stage=stage)
349
+ handler = _get_management_command_handler(context, key=handler)
350
+ res = handler.invoke(json.dumps({"command": command, "args": args}))
351
+ if timeout_seconds:
352
+ handler.show_logs(res, timeout_seconds)
353
+ else:
354
+ handler.show_logs(res)
355
+
356
+
357
+ @django.command()
358
+ @click.option("--stage", envvar="POCKET_DEPLOY_STAGE", prompt=True)
359
+ @click.option(
360
+ "--yes", "-y", is_flag=True, default=False, help="確認プロンプトをスキップ"
361
+ )
362
+ def resetdb(stage: str, yes: bool):
363
+ """データベースの public スキーマをリセットする
364
+
365
+ Lambda 経由で DROP SCHEMA public CASCADE; CREATE SCHEMA public; を実行し、
366
+ 全テーブルを削除してマイグレーションをやり直せる状態にする。
367
+ """
368
+ echo.danger("stage '%s' のデータベースをリセットします。" % stage)
369
+ echo.danger("全テーブルとデータが削除されます。")
370
+ if not yes:
371
+ click.confirm("本当に実行しますか?", abort=True)
372
+
373
+ context = Context.from_toml(stage=stage)
374
+ handler = _get_management_command_handler(context)
375
+ res = handler.invoke(json.dumps({"command": "pocket_resetdb", "args": []}))
376
+ handler.show_logs(res)
377
+ echo.success("データベースをリセットしました。")
378
+ echo.info("pocket django manage --stage %s migrate を実行してください。" % stage)
379
+
380
+
381
+ @django.group()
382
+ def storage():
383
+ pass
384
+
385
+
386
+ def _check_upload_backends(from_storage, to_storage):
387
+ if from_storage["BACKEND"] != "django.core.files.storage.FileSystemStorage":
388
+ raise Exception("Upload from only support FileSystemStorage")
389
+ if to_storage["BACKEND"] != "storages.backends.s3boto3.S3Boto3Storage":
390
+ raise Exception("Upload to only support S3Boto3Storage")
391
+
392
+
393
+ @storage.command()
394
+ @click.option("--stage", envvar="POCKET_DEPLOY_STAGE", prompt=True)
395
+ @click.option("--delete", is_flag=True, default=False)
396
+ @click.option("--dryrun", is_flag=True, default=False)
397
+ @click.argument("storage")
398
+ def upload(storage, stage, delete, dryrun):
399
+ from_storage = get_storages()[storage]
400
+ to_storage = get_storages(stage=stage)[storage]
401
+ _check_upload_backends(from_storage, to_storage)
402
+ from_location = from_storage["OPTIONS"]["location"]
403
+ to_backet_name = to_storage["OPTIONS"]["bucket_name"]
404
+ to_location = to_storage["OPTIONS"]["location"]
405
+ cmd = "aws s3 sync %s s3://%s/%s" % (from_location, to_backet_name, to_location)
406
+ cmd += ' --exclude ".*" --exclude "*/.*"'
407
+ if delete:
408
+ cmd += " --delete"
409
+ if dryrun:
410
+ cmd += " --dryrun"
411
+ print(cmd)
412
+ run(cmd, shell=True, check=True)
pocket_cli/mediator.py ADDED
@@ -0,0 +1,220 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import secrets
5
+ from typing import TYPE_CHECKING, Literal
6
+
7
+ from pocket.utils import echo
8
+ from pocket_cli.resources.neon import Neon, NeonResourceIsNotReady
9
+ from pocket_cli.resources.tidb import TiDb, TiDbResourceIsNotReady
10
+ from pocket_cli.resources.upstash import Upstash, UpstashResourceIsNotReady
11
+
12
+ if TYPE_CHECKING:
13
+ from pocket.context import Context
14
+ from pocket.settings import ManagedSecretSpec
15
+
16
+
17
+ class Mediator:
18
+ """Do some tasks that requires access to mulple resources."""
19
+
20
+ ErrorLevel = Literal["ignore", "warning", "raise"]
21
+
22
+ def __init__(self, context: Context) -> None:
23
+ self.context = context
24
+
25
+ def _conditional_error(self, level: ErrorLevel, msg: str):
26
+ if level == "ignore":
27
+ return
28
+ elif level == "warning":
29
+ echo.warning(msg)
30
+ return
31
+ else:
32
+ raise Exception(msg)
33
+
34
+ def create_pocket_managed_secrets(
35
+ self, exists: ErrorLevel = "warning", failed: ErrorLevel = "raise"
36
+ ):
37
+ if self.context.awscontainer is None:
38
+ return
39
+ if (sc := self.context.awscontainer.secrets) is None:
40
+ return
41
+ generated: dict[str, str | dict[str, str]] = {}
42
+ for key, managed_secret in sc.managed.items():
43
+ if key not in sc.pocket_store.secrets:
44
+ value = self._generate_secret(managed_secret)
45
+ if value is None:
46
+ msg = "Secret generation for %s is failed." % key
47
+ self._conditional_error(failed, msg)
48
+ else:
49
+ generated[key] = value
50
+ else:
51
+ msg = (
52
+ "%s is already created. "
53
+ "Use rotate-pocket-managed if you want to refresh the secrets" % key
54
+ )
55
+ self._conditional_error(exists, msg)
56
+ if generated:
57
+ new_pocket_secrets = sc.pocket_store.secrets.copy() | generated
58
+ sc.pocket_store.update_secrets(new_pocket_secrets)
59
+
60
+ def ensure_pocket_managed_secrets(self):
61
+ self.create_pocket_managed_secrets(exists="ignore")
62
+ self._cleanup_orphaned_secrets()
63
+ if self.context.awscontainer and self.context.awscontainer.secrets:
64
+ sc = self.context.awscontainer.secrets
65
+ if hasattr(sc, "pocket_store"):
66
+ del sc.pocket_store
67
+ if hasattr(sc, "allowed_sm_resources"):
68
+ del sc.allowed_sm_resources
69
+ if hasattr(sc, "allowed_ssm_resources"):
70
+ del sc.allowed_ssm_resources
71
+
72
+ def _cleanup_orphaned_secrets(self):
73
+ """SSM/SM にあるが managed 定義にないシークレットを削除する"""
74
+ if self.context.awscontainer is None:
75
+ return
76
+ if (sc := self.context.awscontainer.secrets) is None:
77
+ return
78
+ stored_keys = set(sc.pocket_store.secrets.keys())
79
+ managed_keys = set(sc.managed.keys())
80
+ orphaned = stored_keys - managed_keys
81
+ if not orphaned:
82
+ return
83
+ echo.warning(
84
+ "managed 定義にないシークレットを削除します: %s"
85
+ % ", ".join(sorted(orphaned))
86
+ )
87
+ # managed に含まれるキーだけ残して再書き込み
88
+ cleaned = {
89
+ k: v for k, v in sc.pocket_store.secrets.items() if k in managed_keys
90
+ }
91
+ sc.pocket_store.delete_secrets()
92
+ if cleaned:
93
+ sc.pocket_store.update_secrets(cleaned)
94
+
95
+ def _generate_secret(self, spec: ManagedSecretSpec):
96
+ if spec.type == "auto_database_url":
97
+ return self._get_auto_database_url()
98
+ elif spec.type == "password":
99
+ return self._generate_password(spec.options)
100
+ elif spec.type == "neon_database_url":
101
+ return self._get_neon_database_url()
102
+ elif spec.type == "tidb_database_url":
103
+ return self._get_tidb_database_url()
104
+ elif spec.type == "rds_database_url":
105
+ return self._get_rds_database_url()
106
+ elif spec.type == "upstash_redis_url":
107
+ return self._get_upstash_redis_url()
108
+ elif spec.type == "rsa_pem_base64":
109
+ return self._generate_rsa_pem()
110
+ elif spec.type == "cloudfront_signing_key":
111
+ return self._generate_rsa_pem()
112
+ elif spec.type == "spa_token_secret":
113
+ return secrets.token_hex(32)
114
+ else:
115
+ raise RuntimeError("Unknown secret type: %s" % spec.type)
116
+
117
+ def _generate_rsa_pem(self) -> dict[str, str]:
118
+ try:
119
+ from cryptography.hazmat.primitives import serialization
120
+ from cryptography.hazmat.primitives.asymmetric import rsa
121
+ except ModuleNotFoundError:
122
+ echo.warning("cryptography is not installed.")
123
+ echo.warning("Please install cryptography to generate RSA key pair.")
124
+ echo.warning("rye add cryptography")
125
+ raise
126
+ private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
127
+ pem_private_key = private_key.private_bytes(
128
+ encoding=serialization.Encoding.PEM,
129
+ format=serialization.PrivateFormat.TraditionalOpenSSL,
130
+ encryption_algorithm=serialization.NoEncryption(),
131
+ )
132
+ pem_public_key = private_key.public_key().public_bytes(
133
+ encoding=serialization.Encoding.PEM,
134
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
135
+ )
136
+ return {
137
+ "pem": base64.b64encode(pem_private_key).decode("utf-8"),
138
+ "pub": base64.b64encode(pem_public_key).decode("utf-8"),
139
+ }
140
+
141
+ def _generate_password(self, options):
142
+ length = options.get("length", 16)
143
+ if not isinstance(length, int):
144
+ raise Exception("length must be integer")
145
+ chars = options.get(
146
+ # default is compatible with Django's SECRET_KEY
147
+ "chars",
148
+ "abcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*(-_=+)",
149
+ )
150
+ if not isinstance(chars, str):
151
+ raise Exception("chars must be string")
152
+ return "".join(secrets.choice(chars) for _ in range(length))
153
+
154
+ def _get_neon_database_url(self):
155
+ if not self.context.neon:
156
+ raise Exception("neon is not configured. Please set neon in pocket.toml")
157
+ try:
158
+ return Neon(self.context.neon).database_url
159
+ except NeonResourceIsNotReady:
160
+ echo.warning("neon database is not ready")
161
+ return None
162
+
163
+ def _get_upstash_redis_url(self):
164
+ if not self.context.upstash:
165
+ raise RuntimeError(
166
+ "upstash is not configured. Please set upstash in pocket.toml"
167
+ )
168
+ try:
169
+ return Upstash(self.context.upstash).redis_url
170
+ except UpstashResourceIsNotReady:
171
+ echo.warning("upstash redis is not ready")
172
+ return None
173
+
174
+ def _get_auto_database_url(self):
175
+ """pocket.toml の DB 設定を自動検出して DATABASE_URL を生成する"""
176
+ dbs = []
177
+ if self.context.neon:
178
+ dbs.append("neon")
179
+ if self.context.tidb:
180
+ dbs.append("tidb")
181
+ if self.context.rds:
182
+ dbs.append("rds")
183
+ if len(dbs) == 0:
184
+ raise RuntimeError(
185
+ "auto_database_url: DB が設定されていません。"
186
+ "[neon], [tidb], [rds] のいずれかを pocket.toml に追加してください。"
187
+ )
188
+ if len(dbs) > 1:
189
+ raise RuntimeError(
190
+ "auto_database_url: 複数の DB が設定されています: %s。"
191
+ "neon_database_url, tidb_database_url, rds_database_url "
192
+ "のいずれかを明示的に指定してください。" % ", ".join(dbs)
193
+ )
194
+ db = dbs[0]
195
+ if db == "neon":
196
+ return self._get_neon_database_url()
197
+ if db == "tidb":
198
+ return self._get_tidb_database_url()
199
+ # rds: runtime の _set_rds_database_url に委譲
200
+ return self._get_rds_database_url()
201
+
202
+ def _get_rds_database_url(self):
203
+ """RDS の DATABASE_URL は runtime で動的構築されるため、
204
+
205
+ deploy 時には marker 値を返す。
206
+ runtime の _set_rds_database_url が POCKET_RDS_SECRET_ARN から
207
+ 実際の DATABASE_URL を上書きする。
208
+ """
209
+ if not self.context.rds:
210
+ raise RuntimeError("rds is not configured. Please set rds in pocket.toml")
211
+ return "__rds_runtime__"
212
+
213
+ def _get_tidb_database_url(self):
214
+ if not self.context.tidb:
215
+ raise RuntimeError("tidb is not configured. Please set tidb in pocket.toml")
216
+ try:
217
+ return TiDb(self.context.tidb).database_url
218
+ except TiDbResourceIsNotReady:
219
+ echo.warning("tidb database is not ready")
220
+ return None
File without changes
File without changes
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Protocol
4
+
5
+ if TYPE_CHECKING:
6
+ from pocket.context import BuildContext
7
+
8
+
9
+ class Builder(Protocol):
10
+ def build_and_push(
11
+ self,
12
+ *,
13
+ target: str,
14
+ dockerfile_path: str,
15
+ platform: str,
16
+ ) -> None: ...
17
+
18
+ def delete(self) -> None:
19
+ """ビルドバックエンドのリソースを削除(不要なら何もしない)"""
20
+ ...
21
+
22
+
23
+ def create_builder(
24
+ build_context: BuildContext,
25
+ *,
26
+ region: str,
27
+ resource_prefix: str,
28
+ state_bucket: str,
29
+ permissions_boundary: str | None = None,
30
+ ) -> Builder:
31
+ backend = build_context.backend
32
+
33
+ if backend == "docker":
34
+ from pocket_cli.resources.aws.builders.docker import DockerBuilder
35
+
36
+ return DockerBuilder(region=region)
37
+
38
+ if backend == "codebuild":
39
+ from pocket_cli.resources.aws.builders.codebuild import CodeBuildBuilder
40
+
41
+ return CodeBuildBuilder(
42
+ region=region,
43
+ resource_prefix=resource_prefix,
44
+ state_bucket=state_bucket,
45
+ compute_type=build_context.compute_type,
46
+ permissions_boundary=permissions_boundary,
47
+ )
48
+
49
+ if backend == "depot":
50
+ from pocket_cli.resources.aws.builders.depot import DepotBuilder
51
+
52
+ return DepotBuilder(
53
+ region=region,
54
+ project_id=build_context.depot_project_id,
55
+ )
56
+
57
+ raise ValueError(f"不明なビルドバックエンド: {backend}")