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.
- magic_pocket_cli-0.2.0.dist-info/METADATA +14 -0
- magic_pocket_cli-0.2.0.dist-info/RECORD +65 -0
- magic_pocket_cli-0.2.0.dist-info/WHEEL +4 -0
- magic_pocket_cli-0.2.0.dist-info/entry_points.txt +2 -0
- pocket_cli/__init__.py +0 -0
- pocket_cli/cli/__init__.py +0 -0
- pocket_cli/cli/aws_auth.py +48 -0
- pocket_cli/cli/awscontainer_cli.py +328 -0
- pocket_cli/cli/cloudfront_cli.py +116 -0
- pocket_cli/cli/cloudfront_keys_cli.py +68 -0
- pocket_cli/cli/cloudfront_waf_cli.py +68 -0
- pocket_cli/cli/deploy_cli.py +274 -0
- pocket_cli/cli/destroy_cli.py +358 -0
- pocket_cli/cli/dsql_cli.py +60 -0
- pocket_cli/cli/main_cli.py +91 -0
- pocket_cli/cli/migrate_cli.py +148 -0
- pocket_cli/cli/neon_cli.py +97 -0
- pocket_cli/cli/permissions_cli.py +46 -0
- pocket_cli/cli/rds_cli.py +63 -0
- pocket_cli/cli/runtime_config_cli.py +185 -0
- pocket_cli/cli/s3_cli.py +69 -0
- pocket_cli/cli/status_cli.py +56 -0
- pocket_cli/cli/tidb_cli.py +73 -0
- pocket_cli/cli/vpc_cli.py +92 -0
- pocket_cli/cli/waf_cli.py +182 -0
- pocket_cli/django_cli.py +412 -0
- pocket_cli/mediator.py +220 -0
- pocket_cli/resources/__init__.py +0 -0
- pocket_cli/resources/aws/__init__.py +0 -0
- pocket_cli/resources/aws/builders/__init__.py +57 -0
- pocket_cli/resources/aws/builders/codebuild.py +363 -0
- pocket_cli/resources/aws/builders/depot.py +84 -0
- pocket_cli/resources/aws/builders/docker.py +34 -0
- pocket_cli/resources/aws/builders/dockerignore.py +44 -0
- pocket_cli/resources/aws/cloudformation.py +790 -0
- pocket_cli/resources/aws/ecr.py +145 -0
- pocket_cli/resources/aws/efs.py +138 -0
- pocket_cli/resources/aws/lambdahandler.py +182 -0
- pocket_cli/resources/aws/s3_utils.py +58 -0
- pocket_cli/resources/aws/state.py +74 -0
- pocket_cli/resources/awscontainer.py +265 -0
- pocket_cli/resources/cloudfront.py +491 -0
- pocket_cli/resources/cloudfront_acm.py +55 -0
- pocket_cli/resources/cloudfront_keys.py +81 -0
- pocket_cli/resources/cloudfront_waf.py +67 -0
- pocket_cli/resources/dsql.py +142 -0
- pocket_cli/resources/neon.py +353 -0
- pocket_cli/resources/rds.py +680 -0
- pocket_cli/resources/s3.py +307 -0
- pocket_cli/resources/tidb.py +298 -0
- pocket_cli/resources/upstash.py +152 -0
- pocket_cli/resources/vpc.py +67 -0
- pocket_cli/templates/cloudformation/awscontainer.yaml +516 -0
- pocket_cli/templates/cloudformation/cf_function_api_host.js +5 -0
- pocket_cli/templates/cloudformation/cf_function_spa_auth.js +28 -0
- pocket_cli/templates/cloudformation/cf_function_spa_fallback.js +8 -0
- pocket_cli/templates/cloudformation/cloudfront.yaml +309 -0
- pocket_cli/templates/cloudformation/cloudfront_acm.yaml +43 -0
- pocket_cli/templates/cloudformation/cloudfront_keys.yaml +32 -0
- pocket_cli/templates/cloudformation/cloudfront_waf.yaml +97 -0
- pocket_cli/templates/cloudformation/vpc.yaml +213 -0
- pocket_cli/templates/init/django-dotenv.env +3 -0
- pocket_cli/templates/init/django-settings.py +140 -0
- pocket_cli/templates/init/pocket.Dockerfile +26 -0
- pocket_cli/templates/init/pocket_simple.toml +31 -0
pocket_cli/django_cli.py
ADDED
|
@@ -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}")
|