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,142 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from functools import cached_property
5
+ from typing import TYPE_CHECKING
6
+
7
+ import boto3
8
+ from botocore.exceptions import ClientError
9
+
10
+ from pocket.resources.base import ResourceStatus
11
+ from pocket.utils import echo
12
+
13
+ if TYPE_CHECKING:
14
+ from pocket.context import DsqlContext
15
+
16
+
17
+ class Dsql:
18
+ context: DsqlContext
19
+
20
+ def __init__(self, context: DsqlContext) -> None:
21
+ self.context = context
22
+ self._client = boto3.client("dsql", region_name=context.region)
23
+
24
+ @cached_property
25
+ def cluster(self) -> dict | None:
26
+ """Name タグで DSQL クラスターを検索"""
27
+ paginator = self._client.get_paginator("list_clusters")
28
+ for page in paginator.paginate():
29
+ for cluster in page["clusters"]:
30
+ identifier = cluster["identifier"]
31
+ try:
32
+ detail = self._client.get_cluster(Identifier=identifier)
33
+ tags = self._client.list_tags_for_resource(
34
+ ResourceArn=detail["arn"]
35
+ )
36
+ if tags.get("tags", {}).get("Name") == self.context.tag_name:
37
+ return detail
38
+ except ClientError:
39
+ continue
40
+ return None
41
+
42
+ @property
43
+ def identifier(self) -> str | None:
44
+ if self.cluster:
45
+ return self.cluster["identifier"]
46
+ return None
47
+
48
+ @property
49
+ def endpoint(self) -> str | None:
50
+ if self.identifier:
51
+ return f"{self.identifier}.dsql.{self.context.region}.on.aws"
52
+ return None
53
+
54
+ @property
55
+ def arn(self) -> str | None:
56
+ if self.cluster:
57
+ return self.cluster["arn"]
58
+ return None
59
+
60
+ @property
61
+ def status(self) -> ResourceStatus:
62
+ if self.cluster is None:
63
+ return "NOEXIST"
64
+ cluster_status = self.cluster["status"]
65
+ if cluster_status in ("CREATING", "UPDATING", "DELETING"):
66
+ return "PROGRESS"
67
+ if cluster_status == "ACTIVE":
68
+ return "COMPLETED"
69
+ return "FAILED"
70
+
71
+ @property
72
+ def description(self):
73
+ return "Create Aurora DSQL cluster: %s" % self.context.tag_name
74
+
75
+ def state_info(self):
76
+ return {
77
+ "dsql": {
78
+ "tag_name": self.context.tag_name,
79
+ "identifier": self.identifier,
80
+ "endpoint": self.endpoint,
81
+ }
82
+ }
83
+
84
+ def deploy_init(self):
85
+ pass
86
+
87
+ def create(self):
88
+ echo.log("Creating DSQL cluster: %s" % self.context.tag_name)
89
+ res = self._client.create_cluster(
90
+ deletionProtectionEnabled=self.context.deletion_protection,
91
+ tags={"Name": self.context.tag_name},
92
+ )
93
+ identifier = res["identifier"]
94
+ echo.log("Cluster ID: %s" % identifier)
95
+ echo.log("Waiting for DSQL cluster to become active...")
96
+ self._wait_active(identifier, timeout=600)
97
+ self.clear_cache()
98
+ echo.success("DSQL cluster is now active.")
99
+ echo.success("Endpoint: %s" % self.endpoint)
100
+
101
+ def delete(self):
102
+ if not self.identifier:
103
+ return
104
+ echo.log("Deleting DSQL cluster: %s" % self.identifier)
105
+ self._client.delete_cluster(Identifier=self.identifier)
106
+ echo.log("Waiting for DSQL cluster deletion...")
107
+ self._wait_deleted(self.identifier, timeout=600)
108
+ echo.success("DSQL cluster was deleted.")
109
+
110
+ def _wait_active(self, identifier: str, timeout: int = 600, interval: int = 5):
111
+ for i in range(timeout // interval):
112
+ try:
113
+ res = self._client.get_cluster(Identifier=identifier)
114
+ if res["status"] == "ACTIVE":
115
+ print("")
116
+ return
117
+ except ClientError:
118
+ pass
119
+ if i == 0:
120
+ print("Waiting for cluster to be active", end="", flush=True)
121
+ print(".", end="", flush=True)
122
+ time.sleep(interval)
123
+ raise TimeoutError("Cluster did not become active within %s seconds" % timeout)
124
+
125
+ def _wait_deleted(self, identifier: str, timeout: int = 600, interval: int = 5):
126
+ for i in range(timeout // interval):
127
+ try:
128
+ self._client.get_cluster(Identifier=identifier)
129
+ except ClientError as e:
130
+ if e.response["Error"]["Code"] == "ResourceNotFoundException":
131
+ print("")
132
+ return
133
+ raise
134
+ if i == 0:
135
+ print("Waiting for cluster deletion", end="", flush=True)
136
+ print(".", end="", flush=True)
137
+ time.sleep(interval)
138
+ raise TimeoutError("Cluster not deleted within %s seconds" % timeout)
139
+
140
+ def clear_cache(self):
141
+ if "cluster" in self.__dict__:
142
+ del self.__dict__["cluster"]
@@ -0,0 +1,353 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import re
7
+ import time
8
+ from functools import cached_property
9
+ from typing import TYPE_CHECKING, Literal
10
+
11
+ import requests
12
+ from pydantic import BaseModel
13
+
14
+ from pocket.resources.base import ResourceStatus
15
+
16
+ if TYPE_CHECKING:
17
+ from pocket.context import NeonContext
18
+
19
+ logging.basicConfig()
20
+ logger = logging.getLogger(__name__)
21
+ logger.setLevel(level=os.getenv("POCKET_LOGGER_LEVEL", "WARNING").upper())
22
+
23
+ ResourceType = Literal["branches", "databases", "endpoints", "roles"]
24
+
25
+
26
+ class NeonResourceIsNotReady(Exception):
27
+ pass
28
+
29
+
30
+ class NeonNotFound(Exception):
31
+ """Neon API が 404 を返したことを示す例外。
32
+
33
+ `branch` / `role` などの個別取得で対象が存在しない場合にこの例外を投げる。
34
+ 呼び出し側は存在しないことを明示的に判定できる。
35
+ """
36
+
37
+ pass
38
+
39
+
40
+ class Project(BaseModel):
41
+ id: str
42
+ name: str
43
+
44
+
45
+ class Branch(BaseModel):
46
+ id: str
47
+ name: str
48
+
49
+
50
+ class Database(BaseModel):
51
+ name: str
52
+ owner_name: str
53
+
54
+
55
+ class Endpoint(BaseModel):
56
+ id: str
57
+ host: str
58
+ autoscaling_limit_min_cu: float
59
+ autoscaling_limit_max_cu: float
60
+ type: Literal["read_write", "read_only"]
61
+
62
+
63
+ class Role(BaseModel):
64
+ name: str
65
+ password: str | None = None
66
+
67
+
68
+ class NeonApi:
69
+ endpoint = "https://console.neon.tech/api/v2/"
70
+
71
+ def __init__(self, key) -> None:
72
+ self.key = key
73
+
74
+ @property
75
+ def header(self):
76
+ return {
77
+ "Accept": "application/json",
78
+ "Authorization": "Bearer %s" % self.key,
79
+ }
80
+
81
+ def get(self, path):
82
+ logger.info("GET %s" % self.endpoint + path)
83
+ res = requests.get(self.endpoint + path, headers=self.header)
84
+ logger.debug(res.status_code)
85
+ logger.debug(json.dumps(res.json(), indent=2))
86
+ if 200 <= res.status_code < 300:
87
+ return res
88
+ if res.status_code == 404:
89
+ raise NeonNotFound("%s: %s" % (res.status_code, res.json()["message"]))
90
+ if res.status_code == 401:
91
+ print("Used API key: %s" % (self.key[:5] + "..." + self.key[-5:]))
92
+ print("API key length: %s" % len(self.key))
93
+ raise Exception("%s: %s" % (res.status_code, res.json()["message"]))
94
+
95
+ def post(self, path, data=None):
96
+ logger.warning("POST %s" % self.endpoint + path)
97
+ logger.debug(json.dumps(data, indent=2))
98
+ res = requests.post(self.endpoint + path, headers=self.header, json=data)
99
+ logger.debug(res.status_code)
100
+ logger.debug(json.dumps(res.json(), indent=2))
101
+ if 200 <= res.status_code < 300:
102
+ time.sleep(2)
103
+ return res
104
+ if res.status_code == 401:
105
+ print("Used API key: %s" % (self.key[:5] + "..." + self.key[-5:]))
106
+ print("API key length: %s" % len(self.key))
107
+ raise Exception("%s: %s" % (res.status_code, res.json()["message"]))
108
+
109
+ def delete(self, path, data=None):
110
+ logger.warning("DELETE %s" % self.endpoint + path)
111
+ logger.debug(json.dumps(data, indent=2))
112
+ res = requests.delete(self.endpoint + path, headers=self.header, json=data)
113
+ logger.debug(res.status_code)
114
+ logger.debug(json.dumps(res.json(), indent=2))
115
+ if 200 <= res.status_code < 300:
116
+ time.sleep(2)
117
+ return res
118
+ raise Exception("%s: %s" % (res.status_code, res.json()["message"]))
119
+
120
+ def projects_url(self):
121
+ return self.endpoint + "projects"
122
+
123
+
124
+ class Neon:
125
+ context: NeonContext
126
+
127
+ def __init__(self, context: NeonContext) -> None:
128
+ self.context = context
129
+
130
+ def get_resource_path(self, resource_type: ResourceType) -> str:
131
+ requirements = {
132
+ "branches": ["project"],
133
+ "databases": ["project", "branch"],
134
+ "endpoints": ["project"],
135
+ "roles": ["project", "branch"],
136
+ }
137
+ path_templates = {
138
+ "branches": "projects/%(project_id)s/branches",
139
+ "databases": "projects/%(project_id)s/branches/%(branch_id)s/databases",
140
+ "endpoints": "projects/%(project_id)s/endpoints",
141
+ "roles": "projects/%(project_id)s/branches/%(branch_id)s/roles",
142
+ }
143
+ path_context = {}
144
+ for requirement in requirements[resource_type]:
145
+ if not getattr(self, requirement):
146
+ raise Exception("%s not found" % requirement)
147
+ path_context[requirement + "_id"] = getattr(self, requirement).id
148
+ return path_templates[resource_type] % path_context
149
+
150
+ def construct_path(
151
+ self, resource_type: ResourceType, resource_id: str | None = None
152
+ ):
153
+ path = self.get_resource_path(resource_type)
154
+ if resource_id:
155
+ path += "/" + resource_id
156
+ return path
157
+
158
+ def get(self, resource_type: ResourceType, resource_id: str | None = None):
159
+ path = self.construct_path(resource_type, resource_id)
160
+ return self.api.get(path)
161
+
162
+ def post(
163
+ self, resource_type: ResourceType, resource_id: str | None = None, data=None
164
+ ):
165
+ path = self.construct_path(resource_type, resource_id)
166
+ return self.api.post(path, data)
167
+
168
+ def delete(
169
+ self, resource_type: ResourceType, resource_id: str | None = None, data=None
170
+ ):
171
+ path = self.construct_path(resource_type, resource_id)
172
+ return self.api.delete(path, data)
173
+
174
+ @property
175
+ def api(self):
176
+ return NeonApi(self.context.api_key)
177
+
178
+ @cached_property
179
+ def role(self) -> Role | None:
180
+ if not self.branch:
181
+ return None
182
+ try:
183
+ res = self.get("roles", self.context.role_name)
184
+ except NeonNotFound:
185
+ return None
186
+ return Role(**res.json()["role"])
187
+
188
+ @cached_property
189
+ def project(self) -> Project:
190
+ """project_nameからNeonプロジェクトを解決する。
191
+
192
+ 組織キー: GET /projects で一覧取得し name で検索。
193
+ プロジェクトスコープキー: エラーから project_id を取得し直接アクセス。
194
+ """
195
+ res = requests.get(self.api.endpoint + "projects", headers=self.api.header)
196
+ if 200 <= res.status_code < 300:
197
+ for p in res.json().get("projects", []):
198
+ if p["name"] == self.context.project_name:
199
+ return Project(**p)
200
+ raise ValueError(f"Neon project '{self.context.project_name}' not found")
201
+
202
+ # プロジェクトスコープキー: エラーから project_id をパース
203
+ message = res.json().get("message", "")
204
+ match = re.search(r'subject_project_id:"([^"]+)"', message)
205
+ if not match:
206
+ raise ValueError(
207
+ f"Failed to resolve Neon project: {res.status_code}: {message}"
208
+ )
209
+ project_id = match.group(1)
210
+ project_res = self.api.get(f"projects/{project_id}")
211
+ project_data = project_res.json()["project"]
212
+ if project_data["name"] != self.context.project_name:
213
+ raise ValueError(
214
+ f"Neon project name mismatch: "
215
+ f"config='{self.context.project_name}', "
216
+ f"actual='{project_data['name']}'"
217
+ )
218
+ return Project(id=project_data["id"], name=project_data["name"])
219
+
220
+ @cached_property
221
+ def branch(self) -> Branch | None:
222
+ if self.project:
223
+ for branch in self.get("branches").json()["branches"]:
224
+ if branch["name"] == self.context.branch_name:
225
+ return Branch(**branch)
226
+
227
+ @cached_property
228
+ def database(self) -> Database | None:
229
+ if self.branch:
230
+ for database in self.get("databases").json()["databases"]:
231
+ if database["name"] == self.context.name:
232
+ return Database(**database)
233
+
234
+ @cached_property
235
+ def endpoint(self) -> Endpoint | None:
236
+ if self.branch:
237
+ for endpoint in self.get("endpoints").json()["endpoints"]:
238
+ if endpoint["branch_id"] == self.branch.id:
239
+ return Endpoint(**endpoint)
240
+
241
+ @property
242
+ def database_url(self):
243
+ if self.role is None or self.endpoint is None:
244
+ raise NeonResourceIsNotReady("Create role and endpoint first")
245
+ if self.role.password is None:
246
+ self.set_role_password()
247
+ return "postgres://%s:%s@%s:5432/%s?sslmode=require" % (
248
+ self.context.role_name,
249
+ self.role.password,
250
+ self.endpoint.host,
251
+ self.context.name,
252
+ )
253
+
254
+ @property
255
+ def status(self) -> ResourceStatus:
256
+ if self.context.skip_check_existing:
257
+ # 存在確認の Neon API call を skip し COMPLETED 固定。deploy ロールに
258
+ # Neon credentials を渡さず deploy を完走させる用途 (settings 参照)。
259
+ return "COMPLETED"
260
+ if self.working:
261
+ return "COMPLETED"
262
+ return "NOEXIST"
263
+
264
+ @property
265
+ def working(self):
266
+ check = [self.branch, self.database, self.endpoint, self.role]
267
+ logger.info(str(check))
268
+ return all(check)
269
+
270
+ @property
271
+ def description(self):
272
+ return "Create Neon branch, database, role, and endpoint"
273
+
274
+ def create_new(self):
275
+ self.create()
276
+ self.reset_database()
277
+
278
+ def state_info(self):
279
+ return {
280
+ "neon": {
281
+ "project_name": self.context.project_name,
282
+ "branch_name": self.context.branch_name,
283
+ }
284
+ }
285
+
286
+ def deploy_init(self):
287
+ pass
288
+
289
+ def create(self):
290
+ self.create_branch()
291
+ self.ensure_role()
292
+ self.ensure_database()
293
+
294
+ def create_branch(self, base_branch: Branch | None = None):
295
+ if self.branch is None:
296
+ del self.branch
297
+ del self.endpoint
298
+ data = {
299
+ "branch": {
300
+ "name": self.context.branch_name,
301
+ },
302
+ "endpoints": [{"type": "read_write"}],
303
+ }
304
+ if base_branch:
305
+ data["branch"]["parent_id"] = base_branch.id
306
+ self.post("branches", data=data)
307
+
308
+ def delete_branch(self):
309
+ if not self.endpoint or not self.branch:
310
+ raise Exception("Branch or endpoint not found. Something is wrong.")
311
+ self.delete("endpoints", self.endpoint.id)
312
+ self.delete("branches", self.branch.id)
313
+
314
+ def ensure_database(self):
315
+ if self.database is None:
316
+ self.create_database()
317
+
318
+ def create_database(self):
319
+ if self.database is None:
320
+ del self.database
321
+ data = {
322
+ "database": {
323
+ "name": self.context.name,
324
+ "owner_name": self.context.role_name,
325
+ }
326
+ }
327
+ self.post("databases", data=data)
328
+
329
+ def reset_database(self):
330
+ if self.database:
331
+ self.delete("databases", self.context.name)
332
+ self.create_database()
333
+
334
+ def create_role(self):
335
+ if self.role is None:
336
+ del self.role
337
+ data = {"role": {"name": self.context.role_name}}
338
+ self.post("roles", data=data)
339
+
340
+ def set_role_password(self):
341
+ if self.role is None:
342
+ raise Exception("Create role first")
343
+ if self.role.password is None:
344
+ self.role.password = self.get(
345
+ "roles", self.role.name + "/reveal_password"
346
+ ).json()["password"]
347
+
348
+ def ensure_role(self):
349
+ if self.role is None:
350
+ del self.role
351
+ data = {"role": {"name": self.context.role_name}}
352
+ self.post("roles", data=data)
353
+ self.set_role_password()