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.
Files changed (68) hide show
  1. granny/__init__.py +19 -0
  2. granny/analyze/__init__.py +6 -0
  3. granny/analyze/lambdas.py +59 -0
  4. granny/analyze/vpcs.py +57 -0
  5. granny/cdn/__init__.py +9 -0
  6. granny/cdn/bunny.py +231 -0
  7. granny/cli/__init__.py +0 -0
  8. granny/cli/analyze.py +66 -0
  9. granny/cli/cdn.py +210 -0
  10. granny/cli/create.py +94 -0
  11. granny/cli/credentials.py +99 -0
  12. granny/cli/dns.py +290 -0
  13. granny/cli/docker.py +165 -0
  14. granny/cli/edge.py +106 -0
  15. granny/cli/email.py +224 -0
  16. granny/cli/main.py +98 -0
  17. granny/cli/serverless.py +278 -0
  18. granny/cli/storage.py +249 -0
  19. granny/create/__init__.py +4 -0
  20. granny/create/auto_certificate.py +1899 -0
  21. granny/create/cloudfront-security-headers.js +53 -0
  22. granny/create/manage-dns.sh +321 -0
  23. granny/create/manage_mailjet_contacts.py +619 -0
  24. granny/create/registrars.py +363 -0
  25. granny/create/setup_aws_cloudfront.py +2808 -0
  26. granny/create/setup_bunny_edge_script.py +923 -0
  27. granny/create/setup_bunny_storage.py +1719 -0
  28. granny/create/setup_cognito_identity_pool.py +740 -0
  29. granny/create/setup_hetzner_bunny.py +1482 -0
  30. granny/create/setup_mailjet_dns.py +1103 -0
  31. granny/create/setup_private_cdn.py +547 -0
  32. granny/create/setup_s3_website.py +1512 -0
  33. granny/create/setup_scaleway_faas.py +1165 -0
  34. granny/create/setup_workmail.py +1217 -0
  35. granny/create/www-redirect-function.js +17 -0
  36. granny/credentials/__init__.py +15 -0
  37. granny/credentials/secrets.py +403 -0
  38. granny/dns/__init__.py +22 -0
  39. granny/dns/base.py +113 -0
  40. granny/dns/bunny.py +150 -0
  41. granny/dns/cloudflare.py +192 -0
  42. granny/dns/cloudns.py +162 -0
  43. granny/dns/desec.py +152 -0
  44. granny/dns/factory.py +72 -0
  45. granny/dns/hetzner.py +165 -0
  46. granny/dns/manual.py +64 -0
  47. granny/dns/records.py +29 -0
  48. granny/docker/__init__.py +5 -0
  49. granny/docker/build_base.py +204 -0
  50. granny/edge/__init__.py +5 -0
  51. granny/edge/bunny.py +147 -0
  52. granny/email/__init__.py +7 -0
  53. granny/email/mailjet.py +119 -0
  54. granny/email/mailjet_contacts.py +115 -0
  55. granny/email/ses_forwarding.py +281 -0
  56. granny/email/workmail.py +145 -0
  57. granny/report.py +128 -0
  58. granny/serverless/__init__.py +5 -0
  59. granny/serverless/scaleway.py +264 -0
  60. granny/storage/__init__.py +7 -0
  61. granny/storage/aws.py +113 -0
  62. granny/storage/bunny.py +98 -0
  63. granny/storage/hetzner.py +118 -0
  64. granny_devops-0.4.0.dist-info/METADATA +445 -0
  65. granny_devops-0.4.0.dist-info/RECORD +68 -0
  66. granny_devops-0.4.0.dist-info/WHEEL +4 -0
  67. granny_devops-0.4.0.dist-info/entry_points.txt +2 -0
  68. 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,5 @@
1
+ """Serverless / FaaS provider clients — Scaleway Functions."""
2
+
3
+ from granny.serverless.scaleway import ScalewayFunctionsClient
4
+
5
+ __all__ = ["ScalewayFunctionsClient"]
@@ -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)
@@ -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)