granny-devops 0.4.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.
- granny/__init__.py +19 -0
- granny/analyze/__init__.py +6 -0
- granny/analyze/lambdas.py +59 -0
- granny/analyze/vpcs.py +57 -0
- granny/cdn/__init__.py +9 -0
- granny/cdn/bunny.py +231 -0
- granny/cli/__init__.py +0 -0
- granny/cli/analyze.py +66 -0
- granny/cli/cdn.py +210 -0
- granny/cli/create.py +94 -0
- granny/cli/credentials.py +99 -0
- granny/cli/dns.py +290 -0
- granny/cli/docker.py +165 -0
- granny/cli/edge.py +106 -0
- granny/cli/email.py +224 -0
- granny/cli/main.py +98 -0
- granny/cli/serverless.py +278 -0
- granny/cli/storage.py +249 -0
- granny/create/__init__.py +4 -0
- granny/create/auto_certificate.py +1899 -0
- granny/create/cloudfront-security-headers.js +53 -0
- granny/create/manage-dns.sh +321 -0
- granny/create/manage_mailjet_contacts.py +619 -0
- granny/create/registrars.py +363 -0
- granny/create/setup_aws_cloudfront.py +2808 -0
- granny/create/setup_bunny_edge_script.py +923 -0
- granny/create/setup_bunny_storage.py +1719 -0
- granny/create/setup_cognito_identity_pool.py +740 -0
- granny/create/setup_hetzner_bunny.py +1482 -0
- granny/create/setup_mailjet_dns.py +1103 -0
- granny/create/setup_private_cdn.py +547 -0
- granny/create/setup_s3_website.py +1512 -0
- granny/create/setup_scaleway_faas.py +1165 -0
- granny/create/setup_workmail.py +1217 -0
- granny/create/www-redirect-function.js +17 -0
- granny/credentials/__init__.py +15 -0
- granny/credentials/secrets.py +403 -0
- granny/dns/__init__.py +22 -0
- granny/dns/base.py +113 -0
- granny/dns/bunny.py +150 -0
- granny/dns/cloudflare.py +192 -0
- granny/dns/cloudns.py +162 -0
- granny/dns/desec.py +152 -0
- granny/dns/factory.py +72 -0
- granny/dns/hetzner.py +165 -0
- granny/dns/manual.py +64 -0
- granny/dns/records.py +29 -0
- granny/docker/__init__.py +5 -0
- granny/docker/build_base.py +204 -0
- granny/edge/__init__.py +5 -0
- granny/edge/bunny.py +147 -0
- granny/email/__init__.py +7 -0
- granny/email/mailjet.py +119 -0
- granny/email/mailjet_contacts.py +115 -0
- granny/email/ses_forwarding.py +281 -0
- granny/email/workmail.py +145 -0
- granny/report.py +128 -0
- granny/serverless/__init__.py +5 -0
- granny/serverless/scaleway.py +264 -0
- granny/storage/__init__.py +7 -0
- granny/storage/aws.py +113 -0
- granny/storage/bunny.py +98 -0
- granny/storage/hetzner.py +118 -0
- granny_devops-0.4.0.dist-info/METADATA +445 -0
- granny_devops-0.4.0.dist-info/RECORD +68 -0
- granny_devops-0.4.0.dist-info/WHEEL +4 -0
- granny_devops-0.4.0.dist-info/entry_points.txt +2 -0
- granny_devops-0.4.0.dist-info/licenses/LICENSE +21 -0
granny/report.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""SetupReport — shared infrastructure setup status tracking + markdown reports.
|
|
2
|
+
|
|
3
|
+
Used across CDN, storage, edge, serverless, and email setup workflows to
|
|
4
|
+
track component status and produce structured markdown reports. Generalized
|
|
5
|
+
from the per-script SetupReport classes that were duplicated in 5+ files
|
|
6
|
+
under ``tools/create/``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
STATUS_ICONS = {
|
|
16
|
+
"created": "[NEW]",
|
|
17
|
+
"exists": "[OK]",
|
|
18
|
+
"configured": "[OK]",
|
|
19
|
+
"requested": "[OK]",
|
|
20
|
+
"uploaded": "[OK]",
|
|
21
|
+
"passed": "[OK]",
|
|
22
|
+
"deployed": "[OK]",
|
|
23
|
+
"published": "[OK]",
|
|
24
|
+
"skipped": "[SKIP]",
|
|
25
|
+
"failed": "[FAIL]",
|
|
26
|
+
"pending": "[ ]",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class SetupReport:
|
|
32
|
+
"""Infrastructure setup report with component status tracking.
|
|
33
|
+
|
|
34
|
+
Usage::
|
|
35
|
+
|
|
36
|
+
report = SetupReport(
|
|
37
|
+
title="CDN Setup Report: example.com",
|
|
38
|
+
metadata={"Domain": "example.com", "Provider": "Bunny CDN"},
|
|
39
|
+
components=["storage_zone", "pull_zone", "dns", "ssl"],
|
|
40
|
+
)
|
|
41
|
+
report.set_status("storage_zone", "created", id=12345, region="DE")
|
|
42
|
+
report.set_url("cdn", "https://example.b-cdn.net")
|
|
43
|
+
report.set_env("BUNNY_PULL_ZONE_ID", "67890")
|
|
44
|
+
md = report.generate_markdown()
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
title: str
|
|
48
|
+
metadata: dict[str, str] = field(default_factory=dict)
|
|
49
|
+
components: list[str] = field(default_factory=list)
|
|
50
|
+
|
|
51
|
+
def __post_init__(self) -> None:
|
|
52
|
+
self.timestamp: str = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime())
|
|
53
|
+
self._status: dict[str, dict[str, Any]] = {
|
|
54
|
+
c: {"status": "pending", "details": {}} for c in self.components
|
|
55
|
+
}
|
|
56
|
+
self._urls: dict[str, str] = {}
|
|
57
|
+
self._env_vars: dict[str, str] = {}
|
|
58
|
+
|
|
59
|
+
def set_status(self, component: str, status: str, **details: Any) -> None:
|
|
60
|
+
if component not in self._status:
|
|
61
|
+
self._status[component] = {"status": "pending", "details": {}}
|
|
62
|
+
self._status[component]["status"] = status
|
|
63
|
+
self._status[component]["details"].update(details)
|
|
64
|
+
|
|
65
|
+
def set_url(self, name: str, url: str) -> None:
|
|
66
|
+
self._urls[name] = url
|
|
67
|
+
|
|
68
|
+
def set_env(self, key: str, value: str) -> None:
|
|
69
|
+
self._env_vars[key] = value
|
|
70
|
+
|
|
71
|
+
def generate_markdown(self, *, footer: str = "") -> str:
|
|
72
|
+
lines = [
|
|
73
|
+
f"# {self.title}",
|
|
74
|
+
"",
|
|
75
|
+
f"**Generated:** {self.timestamp}",
|
|
76
|
+
]
|
|
77
|
+
for key, value in self.metadata.items():
|
|
78
|
+
lines.append(f"**{key}:** {value}")
|
|
79
|
+
lines.extend(["", "---", "", "## Component Status", ""])
|
|
80
|
+
|
|
81
|
+
for comp_key, comp in self._status.items():
|
|
82
|
+
status = comp["status"]
|
|
83
|
+
icon = STATUS_ICONS.get(status, "[ ]")
|
|
84
|
+
display = comp_key.replace("_", " ").title()
|
|
85
|
+
lines.append(f"### {icon} {display}")
|
|
86
|
+
lines.append(f"**Status:** {status.replace('_', ' ').title()}")
|
|
87
|
+
for dk, dv in comp["details"].items():
|
|
88
|
+
label = dk.replace("_", " ").title()
|
|
89
|
+
if isinstance(dv, list):
|
|
90
|
+
lines.append(f"- **{label}:**")
|
|
91
|
+
for item in dv:
|
|
92
|
+
lines.append(f" - `{item}`")
|
|
93
|
+
else:
|
|
94
|
+
lines.append(f"- **{label}:** `{dv}`")
|
|
95
|
+
lines.append("")
|
|
96
|
+
|
|
97
|
+
if self._urls:
|
|
98
|
+
lines.extend(["## URLs", "", "| Name | URL |", "|------|-----|"])
|
|
99
|
+
for name, url in self._urls.items():
|
|
100
|
+
lines.append(f"| {name.replace('_', ' ').title()} | {url} |")
|
|
101
|
+
lines.append("")
|
|
102
|
+
|
|
103
|
+
if footer:
|
|
104
|
+
lines.extend(["---", "", footer])
|
|
105
|
+
|
|
106
|
+
return "\n".join(lines)
|
|
107
|
+
|
|
108
|
+
def save(self, output_dir: str | Path = ".", prefix: str = "report") -> Path:
|
|
109
|
+
"""Save markdown report to disk. Returns the file path."""
|
|
110
|
+
output_dir = Path(output_dir)
|
|
111
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
ts = time.strftime("%Y%m%d-%H%M%S", time.gmtime())
|
|
113
|
+
path = output_dir / f"{prefix}-{ts}.md"
|
|
114
|
+
path.write_text(self.generate_markdown(), encoding="utf-8")
|
|
115
|
+
return path
|
|
116
|
+
|
|
117
|
+
def export_deploy_env(self, path: str | Path = ".deploy.env") -> None:
|
|
118
|
+
"""Write env vars to a deploy env file (append, don't overwrite)."""
|
|
119
|
+
if not self._env_vars:
|
|
120
|
+
return
|
|
121
|
+
path = Path(path)
|
|
122
|
+
existing = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
123
|
+
with path.open("a", encoding="utf-8") as f:
|
|
124
|
+
if existing and not existing.endswith("\n"):
|
|
125
|
+
f.write("\n")
|
|
126
|
+
for key, value in self._env_vars.items():
|
|
127
|
+
if f"{key}=" not in existing:
|
|
128
|
+
f.write(f"{key}={value}\n")
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Scaleway Functions (FaaS) client — namespace/function/domain/trigger CRUD.
|
|
2
|
+
|
|
3
|
+
Lifted from ``tools/create/setup_scaleway_faas.py:ScalewayFunctionsClient``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
from granny.credentials.secrets import get_secret
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
SCALEWAY_REGIONS = {
|
|
17
|
+
"fr-par": {
|
|
18
|
+
"name": "Paris, France",
|
|
19
|
+
"functions_api": "https://api.scaleway.com/functions/v1beta1/regions/fr-par",
|
|
20
|
+
},
|
|
21
|
+
"nl-ams": {
|
|
22
|
+
"name": "Amsterdam, Netherlands",
|
|
23
|
+
"functions_api": "https://api.scaleway.com/functions/v1beta1/regions/nl-ams",
|
|
24
|
+
},
|
|
25
|
+
"pl-waw": {
|
|
26
|
+
"name": "Warsaw, Poland",
|
|
27
|
+
"functions_api": "https://api.scaleway.com/functions/v1beta1/regions/pl-waw",
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
SCALEWAY_RUNTIMES = [
|
|
32
|
+
"node20", "node22", "python311", "python312", "go121", "rust165",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ScalewayFunctionsClient:
|
|
37
|
+
"""Scaleway Serverless Functions management."""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
*,
|
|
42
|
+
access_key: str | None = None,
|
|
43
|
+
secret_key: str | None = None,
|
|
44
|
+
project_id: str | None = None,
|
|
45
|
+
region: str = "fr-par",
|
|
46
|
+
) -> None:
|
|
47
|
+
self.access_key = access_key or get_secret("SCW_ACCESS_KEY")
|
|
48
|
+
self.secret_key = secret_key or get_secret("SCW_SECRET_KEY")
|
|
49
|
+
self.project_id = project_id or get_secret("SCW_DEFAULT_PROJECT_ID")
|
|
50
|
+
self.region = region
|
|
51
|
+
|
|
52
|
+
if not all([self.access_key, self.secret_key, self.project_id]):
|
|
53
|
+
raise ValueError(
|
|
54
|
+
"Missing Scaleway credentials. Set SCW_ACCESS_KEY, "
|
|
55
|
+
"SCW_SECRET_KEY, SCW_DEFAULT_PROJECT_ID (env or vault)."
|
|
56
|
+
)
|
|
57
|
+
if region not in SCALEWAY_REGIONS:
|
|
58
|
+
raise ValueError(
|
|
59
|
+
f"Unknown region: {region}. Choices: {', '.join(SCALEWAY_REGIONS)}"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
self._api_base = SCALEWAY_REGIONS[region]["functions_api"]
|
|
63
|
+
self.session = requests.Session()
|
|
64
|
+
self.session.headers.update(
|
|
65
|
+
{"X-Auth-Token": self.secret_key, "Content-Type": "application/json"}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def _request(self, method: str, path: str, data: dict | None = None) -> Any:
|
|
69
|
+
url = f"{self._api_base}{path}"
|
|
70
|
+
resp = getattr(self.session, method.lower())(url, json=data, timeout=30)
|
|
71
|
+
if resp.status_code >= 400:
|
|
72
|
+
raise RuntimeError(
|
|
73
|
+
f"Scaleway {method} {path}: {resp.status_code} {resp.text}"
|
|
74
|
+
)
|
|
75
|
+
if resp.status_code == 204 or not resp.text:
|
|
76
|
+
return {}
|
|
77
|
+
return resp.json()
|
|
78
|
+
|
|
79
|
+
# -- Namespaces ------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def list_namespaces(self) -> list[dict]:
|
|
82
|
+
result = self._request("GET", f"/namespaces?project_id={self.project_id}")
|
|
83
|
+
return result.get("namespaces", [])
|
|
84
|
+
|
|
85
|
+
def find_namespace(self, name: str) -> dict | None:
|
|
86
|
+
for ns in self.list_namespaces():
|
|
87
|
+
if ns.get("name") == name:
|
|
88
|
+
return ns
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
def get_namespace(self, namespace_id: str) -> dict:
|
|
92
|
+
return self._request("GET", f"/namespaces/{namespace_id}")
|
|
93
|
+
|
|
94
|
+
def create_namespace(
|
|
95
|
+
self,
|
|
96
|
+
name: str,
|
|
97
|
+
description: str = "",
|
|
98
|
+
*,
|
|
99
|
+
env: dict | None = None,
|
|
100
|
+
secrets: list[dict] | None = None,
|
|
101
|
+
) -> dict:
|
|
102
|
+
data: dict[str, Any] = {
|
|
103
|
+
"name": name,
|
|
104
|
+
"project_id": self.project_id,
|
|
105
|
+
"description": description or f"Function namespace for {name}",
|
|
106
|
+
}
|
|
107
|
+
if env:
|
|
108
|
+
data["environment_variables"] = env
|
|
109
|
+
if secrets:
|
|
110
|
+
data["secret_environment_variables"] = secrets
|
|
111
|
+
result = self._request("POST", "/namespaces", data)
|
|
112
|
+
logger.info("Created namespace %s (ID %s)", name, result.get("id"))
|
|
113
|
+
return result
|
|
114
|
+
|
|
115
|
+
def delete_namespace(self, namespace_id: str) -> None:
|
|
116
|
+
self._request("DELETE", f"/namespaces/{namespace_id}")
|
|
117
|
+
|
|
118
|
+
# -- Functions -------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def list_functions(self, namespace_id: str) -> list[dict]:
|
|
121
|
+
result = self._request("GET", f"/functions?namespace_id={namespace_id}")
|
|
122
|
+
return result.get("functions", [])
|
|
123
|
+
|
|
124
|
+
def find_function(self, namespace_id: str, name: str) -> dict | None:
|
|
125
|
+
for fn in self.list_functions(namespace_id):
|
|
126
|
+
if fn.get("name") == name:
|
|
127
|
+
return fn
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
def create_function(
|
|
131
|
+
self,
|
|
132
|
+
namespace_id: str,
|
|
133
|
+
name: str,
|
|
134
|
+
*,
|
|
135
|
+
runtime: str = "node20",
|
|
136
|
+
handler: str = "handler.handler",
|
|
137
|
+
min_scale: int = 0,
|
|
138
|
+
max_scale: int = 5,
|
|
139
|
+
memory_limit: int = 256,
|
|
140
|
+
timeout: str = "30s",
|
|
141
|
+
privacy: str = "public",
|
|
142
|
+
description: str = "",
|
|
143
|
+
env: dict | None = None,
|
|
144
|
+
secrets: list[dict] | None = None,
|
|
145
|
+
) -> dict:
|
|
146
|
+
data: dict[str, Any] = {
|
|
147
|
+
"name": name,
|
|
148
|
+
"namespace_id": namespace_id,
|
|
149
|
+
"runtime": runtime,
|
|
150
|
+
"handler": handler,
|
|
151
|
+
"min_scale": min_scale,
|
|
152
|
+
"max_scale": max_scale,
|
|
153
|
+
"memory_limit": memory_limit,
|
|
154
|
+
"timeout": timeout,
|
|
155
|
+
"http_option": "enabled",
|
|
156
|
+
"privacy": privacy,
|
|
157
|
+
"description": description or f"Function {name}",
|
|
158
|
+
}
|
|
159
|
+
if env:
|
|
160
|
+
data["environment_variables"] = env
|
|
161
|
+
if secrets:
|
|
162
|
+
data["secret_environment_variables"] = secrets
|
|
163
|
+
result = self._request("POST", "/functions", data)
|
|
164
|
+
logger.info("Created function %s (ID %s)", name, result.get("id"))
|
|
165
|
+
return result
|
|
166
|
+
|
|
167
|
+
def delete_function(self, function_id: str) -> None:
|
|
168
|
+
self._request("DELETE", f"/functions/{function_id}")
|
|
169
|
+
|
|
170
|
+
def deploy_function(self, function_id: str) -> dict:
|
|
171
|
+
return self._request("POST", f"/functions/{function_id}/deploy")
|
|
172
|
+
|
|
173
|
+
def get_upload_url(self, function_id: str, content_length: int) -> str:
|
|
174
|
+
result = self._request(
|
|
175
|
+
"GET", f"/functions/{function_id}/upload-url?content_length={content_length}"
|
|
176
|
+
)
|
|
177
|
+
url = result.get("url")
|
|
178
|
+
if not url:
|
|
179
|
+
raise RuntimeError(f"No upload URL in response: {result}")
|
|
180
|
+
return url
|
|
181
|
+
|
|
182
|
+
def update_function(
|
|
183
|
+
self,
|
|
184
|
+
function_id: str,
|
|
185
|
+
*,
|
|
186
|
+
env: dict | None = None,
|
|
187
|
+
secrets: list[dict] | None = None,
|
|
188
|
+
) -> dict:
|
|
189
|
+
data: dict[str, Any] = {}
|
|
190
|
+
if env is not None:
|
|
191
|
+
data["environment_variables"] = env
|
|
192
|
+
if secrets is not None:
|
|
193
|
+
data["secret_environment_variables"] = secrets
|
|
194
|
+
if not data:
|
|
195
|
+
return {}
|
|
196
|
+
return self._request("PATCH", f"/functions/{function_id}", data)
|
|
197
|
+
|
|
198
|
+
def upload_package(self, function_id: str, zip_path: str) -> None:
|
|
199
|
+
"""Upload a zipped function package via presigned URL."""
|
|
200
|
+
import os
|
|
201
|
+
|
|
202
|
+
size = os.path.getsize(zip_path)
|
|
203
|
+
upload_url = self.get_upload_url(function_id, size)
|
|
204
|
+
with open(zip_path, "rb") as fh:
|
|
205
|
+
resp = requests.put(
|
|
206
|
+
upload_url,
|
|
207
|
+
data=fh,
|
|
208
|
+
headers={
|
|
209
|
+
"Content-Type": "application/octet-stream",
|
|
210
|
+
"Content-Length": str(size),
|
|
211
|
+
},
|
|
212
|
+
timeout=600,
|
|
213
|
+
)
|
|
214
|
+
if resp.status_code not in (200, 204):
|
|
215
|
+
raise RuntimeError(
|
|
216
|
+
f"Upload failed: HTTP {resp.status_code} {resp.text[:500]}"
|
|
217
|
+
)
|
|
218
|
+
logger.info("Uploaded %d bytes to function %s", size, function_id)
|
|
219
|
+
|
|
220
|
+
def deploy_package(
|
|
221
|
+
self,
|
|
222
|
+
function_id: str,
|
|
223
|
+
zip_path: str,
|
|
224
|
+
*,
|
|
225
|
+
env: dict | None = None,
|
|
226
|
+
secrets: list[dict] | None = None,
|
|
227
|
+
) -> dict:
|
|
228
|
+
"""Upload code, sync env vars, and trigger deployment."""
|
|
229
|
+
self.upload_package(function_id, zip_path)
|
|
230
|
+
if env is not None or secrets is not None:
|
|
231
|
+
self.update_function(function_id, env=env, secrets=secrets)
|
|
232
|
+
logger.info("Synced environment variables")
|
|
233
|
+
return self.deploy_function(function_id)
|
|
234
|
+
|
|
235
|
+
# -- Domains ---------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
def list_domains(self, function_id: str) -> list[dict]:
|
|
238
|
+
result = self._request("GET", f"/domains?function_id={function_id}")
|
|
239
|
+
return result.get("domains", [])
|
|
240
|
+
|
|
241
|
+
def create_domain(self, function_id: str, hostname: str) -> dict:
|
|
242
|
+
result = self._request(
|
|
243
|
+
"POST", "/domains", {"function_id": function_id, "hostname": hostname}
|
|
244
|
+
)
|
|
245
|
+
logger.info("Added domain %s to function %s", hostname, function_id)
|
|
246
|
+
return result
|
|
247
|
+
|
|
248
|
+
# -- Triggers --------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
def list_triggers(self, function_id: str) -> list[dict]:
|
|
251
|
+
result = self._request("GET", f"/triggers?function_id={function_id}")
|
|
252
|
+
return result.get("triggers", [])
|
|
253
|
+
|
|
254
|
+
def create_cron_trigger(
|
|
255
|
+
self, function_id: str, name: str, schedule: str, args: dict | None = None
|
|
256
|
+
) -> dict:
|
|
257
|
+
data: dict[str, Any] = {
|
|
258
|
+
"name": name,
|
|
259
|
+
"function_id": function_id,
|
|
260
|
+
"schedule": schedule,
|
|
261
|
+
}
|
|
262
|
+
if args:
|
|
263
|
+
data["args"] = json.dumps(args)
|
|
264
|
+
return self._request("POST", "/triggers", data)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Storage provider clients — Bunny Storage, Hetzner S3, AWS S3."""
|
|
2
|
+
|
|
3
|
+
from granny.storage.bunny import BunnyStorageClient
|
|
4
|
+
from granny.storage.hetzner import HetznerS3Client
|
|
5
|
+
from granny.storage.aws import AWSS3Client
|
|
6
|
+
|
|
7
|
+
__all__ = ["BunnyStorageClient", "HetznerS3Client", "AWSS3Client"]
|
granny/storage/aws.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""AWS S3 client for static website hosting.
|
|
2
|
+
|
|
3
|
+
Extracted from ``tools/create/setup_s3_website.py`` and
|
|
4
|
+
``tools/create/setup_aws_cloudfront.py``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
import boto3
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _website_endpoint(bucket: str, region: str) -> str:
|
|
17
|
+
if region == "us-east-1":
|
|
18
|
+
return f"{bucket}.s3-website-us-east-1.amazonaws.com"
|
|
19
|
+
return f"{bucket}.s3-website.{region}.amazonaws.com"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AWSS3Client:
|
|
23
|
+
"""AWS S3 bucket management for static website hosting."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self, *, aws_profile: str | None = None, region: str | None = None
|
|
27
|
+
) -> None:
|
|
28
|
+
session_kwargs: dict = {}
|
|
29
|
+
if aws_profile:
|
|
30
|
+
session_kwargs["profile_name"] = aws_profile
|
|
31
|
+
if region:
|
|
32
|
+
session_kwargs["region_name"] = region
|
|
33
|
+
session = boto3.Session(**session_kwargs)
|
|
34
|
+
self.region = session.region_name or "us-east-1"
|
|
35
|
+
self.client = session.client("s3")
|
|
36
|
+
|
|
37
|
+
def bucket_exists(self, bucket_name: str) -> bool:
|
|
38
|
+
try:
|
|
39
|
+
self.client.head_bucket(Bucket=bucket_name)
|
|
40
|
+
return True
|
|
41
|
+
except Exception:
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
def create_bucket(self, bucket_name: str) -> None:
|
|
45
|
+
if self.bucket_exists(bucket_name):
|
|
46
|
+
logger.info("Bucket %s already exists", bucket_name)
|
|
47
|
+
return
|
|
48
|
+
kwargs: dict = {"Bucket": bucket_name}
|
|
49
|
+
if self.region != "us-east-1":
|
|
50
|
+
kwargs["CreateBucketConfiguration"] = {
|
|
51
|
+
"LocationConstraint": self.region
|
|
52
|
+
}
|
|
53
|
+
self.client.create_bucket(**kwargs)
|
|
54
|
+
logger.info("Created bucket %s in %s", bucket_name, self.region)
|
|
55
|
+
|
|
56
|
+
def enable_website_hosting(
|
|
57
|
+
self, bucket_name: str, index: str = "index.html", error: str = "error.html"
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Enable static website hosting with public-read policy."""
|
|
60
|
+
# Disable block-public-access
|
|
61
|
+
self.client.put_public_access_block(
|
|
62
|
+
Bucket=bucket_name,
|
|
63
|
+
PublicAccessBlockConfiguration={
|
|
64
|
+
"BlockPublicAcls": False,
|
|
65
|
+
"IgnorePublicAcls": False,
|
|
66
|
+
"BlockPublicPolicy": False,
|
|
67
|
+
"RestrictPublicBuckets": False,
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
time.sleep(5)
|
|
71
|
+
|
|
72
|
+
self.client.put_bucket_website(
|
|
73
|
+
Bucket=bucket_name,
|
|
74
|
+
WebsiteConfiguration={
|
|
75
|
+
"IndexDocument": {"Suffix": index},
|
|
76
|
+
"ErrorDocument": {"Key": error},
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
policy = {
|
|
81
|
+
"Version": "2012-10-17",
|
|
82
|
+
"Statement": [
|
|
83
|
+
{
|
|
84
|
+
"Sid": "PublicReadGetObject",
|
|
85
|
+
"Effect": "Allow",
|
|
86
|
+
"Principal": "*",
|
|
87
|
+
"Action": "s3:GetObject",
|
|
88
|
+
"Resource": f"arn:aws:s3:::{bucket_name}/*",
|
|
89
|
+
}
|
|
90
|
+
],
|
|
91
|
+
}
|
|
92
|
+
self.client.put_bucket_policy(Bucket=bucket_name, Policy=json.dumps(policy))
|
|
93
|
+
logger.info("Website hosting enabled on %s", bucket_name)
|
|
94
|
+
|
|
95
|
+
def upload_file(
|
|
96
|
+
self,
|
|
97
|
+
bucket_name: str,
|
|
98
|
+
key: str,
|
|
99
|
+
content: str | bytes,
|
|
100
|
+
content_type: str = "text/html",
|
|
101
|
+
cache_control: str = "max-age=300",
|
|
102
|
+
) -> None:
|
|
103
|
+
body = content.encode("utf-8") if isinstance(content, str) else content
|
|
104
|
+
self.client.put_object(
|
|
105
|
+
Bucket=bucket_name,
|
|
106
|
+
Key=key,
|
|
107
|
+
Body=body,
|
|
108
|
+
ContentType=content_type,
|
|
109
|
+
CacheControl=cache_control,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def get_website_endpoint(self, bucket_name: str) -> str:
|
|
113
|
+
return _website_endpoint(bucket_name, self.region)
|
granny/storage/bunny.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Bunny Storage zone client — CRUD + file upload.
|
|
2
|
+
|
|
3
|
+
Lifted from ``tools/create/setup_bunny_storage.py:BunnyStorageClient``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from granny.credentials.secrets import get_bunny_api_key
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
API_BASE = "https://api.bunny.net"
|
|
16
|
+
|
|
17
|
+
BUNNY_STORAGE_REGIONS = {
|
|
18
|
+
"DE": {"name": "Frankfurt, Germany", "hostname": "storage.bunnycdn.com"},
|
|
19
|
+
"UK": {"name": "London, UK", "hostname": "uk.storage.bunnycdn.com"},
|
|
20
|
+
"NY": {"name": "New York, US", "hostname": "ny.storage.bunnycdn.com"},
|
|
21
|
+
"LA": {"name": "Los Angeles, US", "hostname": "la.storage.bunnycdn.com"},
|
|
22
|
+
"SG": {"name": "Singapore", "hostname": "sg.storage.bunnycdn.com"},
|
|
23
|
+
"SE": {"name": "Stockholm, Sweden", "hostname": "se.storage.bunnycdn.com"},
|
|
24
|
+
"BR": {"name": "Sao Paulo, Brazil", "hostname": "br.storage.bunnycdn.com"},
|
|
25
|
+
"JH": {"name": "Johannesburg, SA", "hostname": "jh.storage.bunnycdn.com"},
|
|
26
|
+
"SYD": {"name": "Sydney, Australia", "hostname": "syd.storage.bunnycdn.com"},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BunnyStorageClient:
|
|
31
|
+
"""Bunny Storage zone management and file upload."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, api_key: str | None = None, customer: str | None = None) -> None:
|
|
34
|
+
api_key = api_key or get_bunny_api_key(customer)
|
|
35
|
+
if not api_key:
|
|
36
|
+
raise ValueError("BUNNY_API_KEY not set (env or vault).")
|
|
37
|
+
self._api_key = api_key
|
|
38
|
+
self.session = requests.Session()
|
|
39
|
+
self.session.headers.update(
|
|
40
|
+
{"AccessKey": api_key, "Content-Type": "application/json"}
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def _request(self, method: str, path: str, data: dict | None = None) -> Any:
|
|
44
|
+
url = f"{API_BASE}{path}"
|
|
45
|
+
resp = getattr(self.session, method.lower())(url, json=data, timeout=30)
|
|
46
|
+
if resp.status_code >= 400:
|
|
47
|
+
raise RuntimeError(
|
|
48
|
+
f"Bunny Storage {method} {path}: {resp.status_code} {resp.text}"
|
|
49
|
+
)
|
|
50
|
+
if resp.status_code == 204 or not resp.text:
|
|
51
|
+
return {}
|
|
52
|
+
return resp.json()
|
|
53
|
+
|
|
54
|
+
def list_storage_zones(self) -> list[dict]:
|
|
55
|
+
return self._request("GET", "/storagezone")
|
|
56
|
+
|
|
57
|
+
def find_storage_zone(self, name: str) -> dict | None:
|
|
58
|
+
for zone in self.list_storage_zones():
|
|
59
|
+
if zone.get("Name") == name:
|
|
60
|
+
return zone
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
def create_storage_zone(self, name: str, region: str = "DE") -> dict:
|
|
64
|
+
result = self._request(
|
|
65
|
+
"POST",
|
|
66
|
+
"/storagezone",
|
|
67
|
+
{"Name": name, "Region": region, "ZoneTier": 0},
|
|
68
|
+
)
|
|
69
|
+
logger.info("Created storage zone %s (ID %s)", name, result.get("Id"))
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
def delete_storage_zone(self, zone_id: int) -> None:
|
|
73
|
+
self._request("DELETE", f"/storagezone/{zone_id}")
|
|
74
|
+
logger.info("Deleted storage zone %s", zone_id)
|
|
75
|
+
|
|
76
|
+
def upload_file(
|
|
77
|
+
self,
|
|
78
|
+
storage_hostname: str,
|
|
79
|
+
storage_password: str,
|
|
80
|
+
zone_name: str,
|
|
81
|
+
path: str,
|
|
82
|
+
content: bytes,
|
|
83
|
+
content_type: str | None = None,
|
|
84
|
+
cache_control: str | None = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Upload a file to a storage zone via the storage endpoint."""
|
|
87
|
+
url = f"https://{storage_hostname}/{zone_name}/{path}"
|
|
88
|
+
headers: dict[str, str] = {"AccessKey": storage_password}
|
|
89
|
+
if content_type:
|
|
90
|
+
headers["Content-Type"] = content_type
|
|
91
|
+
if cache_control:
|
|
92
|
+
headers["Cache-Control"] = cache_control
|
|
93
|
+
resp = requests.put(url, data=content, headers=headers, timeout=30)
|
|
94
|
+
if resp.status_code not in (200, 201):
|
|
95
|
+
raise RuntimeError(
|
|
96
|
+
f"Upload {path} failed: {resp.status_code} {resp.text}"
|
|
97
|
+
)
|
|
98
|
+
logger.info("Uploaded %s to %s/%s", path, zone_name, path)
|