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
@@ -0,0 +1,923 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Bunny Edge Scripting Setup Script
4
+
5
+ Creates and deploys serverless edge scripts on Bunny.net's Edge Scripting
6
+ platform. Standalone scripts get an auto-created pull zone; middleware scripts
7
+ attach to an existing pull zone.
8
+
9
+ Bunny Edge Scripts run on Deno/V8 with standard Web APIs (fetch, Request,
10
+ Response). TypeScript is supported natively.
11
+
12
+ FEATURES:
13
+ ---------
14
+ - Creates standalone or middleware edge scripts
15
+ - Deploys code from a local file
16
+ - Configures environment variables and secrets
17
+ - Adds custom hostnames to the linked pull zone
18
+ - Publishes releases with optional notes
19
+ - Supports manual or automated DNS configuration
20
+
21
+ Environment Variables:
22
+ BUNNY_API_KEY: Bunny.net API key (from https://panel.bunny.net/account)
23
+
24
+ Usage Examples:
25
+ # Create a standalone edge script with a linked pull zone
26
+ granny create bunny-edge-script --name my-api --code handler.ts
27
+
28
+ # Create and bind a custom domain (manual DNS)
29
+ granny create bunny-edge-script --name my-api --code handler.ts \\
30
+ --hostname api.example.com --domain example.com --dns-provider manual
31
+
32
+ # Set environment variables and secrets
33
+ granny create bunny-edge-script --name my-api --code handler.ts \\
34
+ --env NODE_ENV=production --env LOG_LEVEL=info \\
35
+ --secret API_KEY=sk-xxx --secret DB_PASSWORD=hunter2
36
+
37
+ # Create a middleware script on an existing pull zone
38
+ granny create bunny-edge-script --name my-middleware --code mw.ts \\
39
+ --type middleware --pull-zone-id 12345
40
+ """
41
+
42
+ import os
43
+ import re
44
+ import time
45
+ import logging
46
+ import argparse
47
+ import requests
48
+ from typing import Optional
49
+ from abc import ABC, abstractmethod
50
+
51
+ from dotenv import load_dotenv
52
+ load_dotenv()
53
+ try:
54
+ from granny.credentials import load_secrets_into_env
55
+ load_secrets_into_env()
56
+ except Exception:
57
+ pass
58
+
59
+
60
+ # =============================================================================
61
+ # Bunny Edge Scripting Configuration
62
+ # =============================================================================
63
+
64
+ BUNNY_API_BASE = "https://api.bunny.net"
65
+ EDGE_SCRIPTING_API = "/compute/script"
66
+
67
+ SCRIPT_TYPES = {
68
+ 'standalone': 0, # Independent HTTP handler, gets its own pull zone
69
+ 'middleware': 1, # Intercepts req/res on an existing pull zone
70
+ }
71
+
72
+
73
+ # =============================================================================
74
+ # DNS Provider Abstraction (same pattern as other setup scripts)
75
+ # =============================================================================
76
+
77
+ class DNSProvider(ABC):
78
+ """Abstract base class for DNS providers."""
79
+
80
+ @abstractmethod
81
+ def get_zone_id(self, zone_name: str) -> str:
82
+ pass
83
+
84
+ @abstractmethod
85
+ def create_cname_record(self, zone_id: str, name: str, target: str,
86
+ zone_name: str, ttl: int = 300) -> None:
87
+ pass
88
+
89
+
90
+ class ManualDNSProvider(DNSProvider):
91
+ """No-op DNS provider that prints instructions for the operator.
92
+
93
+ Use when DNS is managed at a provider without API support (e.g.
94
+ easyname.eu). The script creates the edge script and pull zone as
95
+ usual, but instead of touching DNS it prints the exact record to add.
96
+ """
97
+
98
+ def __init__(self):
99
+ self._pending_records: list[str] = []
100
+
101
+ def get_zone_id(self, zone_name: str) -> str:
102
+ return "manual"
103
+
104
+ def create_cname_record(self, zone_id: str, name: str, target: str,
105
+ zone_name: str, ttl: int = 300) -> None:
106
+ fqdn = f"{name}.{zone_name}" if name and name != '@' else zone_name
107
+ record = f"CNAME {fqdn}. -> {target.rstrip('.')}. (TTL {ttl})"
108
+ self._pending_records.append(record)
109
+ logging.warning("=" * 70)
110
+ logging.warning("MANUAL DNS ACTION REQUIRED")
111
+ logging.warning("Add the following record at your DNS provider:")
112
+ logging.warning(" %s", record)
113
+ logging.warning("SSL will auto-issue once the record propagates.")
114
+ logging.warning("=" * 70)
115
+
116
+
117
+ class CloudflareDNSProvider(DNSProvider):
118
+ """Cloudflare DNS API adapter."""
119
+
120
+ def __init__(self):
121
+ try:
122
+ from cloudflare import Cloudflare
123
+ except ImportError:
124
+ raise ImportError("Cloudflare library not found. Install: pip install cloudflare")
125
+ token = os.environ.get("CLOUDFLARE_API_TOKEN")
126
+ if not token:
127
+ raise ValueError("CLOUDFLARE_API_TOKEN environment variable not set.")
128
+ self.client = Cloudflare(api_token=token)
129
+
130
+ def get_zone_id(self, zone_name: str) -> str:
131
+ zones = self.client.zones.list(name=zone_name)
132
+ if not zones.result:
133
+ raise Exception(f"Zone not found in Cloudflare: {zone_name}")
134
+ return zones.result[0].id
135
+
136
+ def create_cname_record(self, zone_id: str, name: str, target: str,
137
+ zone_name: str, ttl: int = 300) -> None:
138
+ full_domain_name = f"{name}.{zone_name}" if name != zone_name else zone_name
139
+ logging.info(f"Creating/updating CNAME: '{full_domain_name}' -> '{target}'...")
140
+
141
+ try:
142
+ existing_records = self.client.dns.records.list(zone_id=zone_id, name=full_domain_name)
143
+ dns_record = {'name': name, 'type': 'CNAME', 'content': target, 'proxied': False}
144
+
145
+ cname_exists = False
146
+ if existing_records.result:
147
+ for record in existing_records.result:
148
+ if record.type == 'CNAME':
149
+ cname_exists = True
150
+ if record.content != target:
151
+ self.client.dns.records.update(zone_id=zone_id, dns_record_id=record.id, **dns_record)
152
+ logging.info("CNAME record updated.")
153
+ else:
154
+ logging.info("CNAME record already correct.")
155
+ elif record.type in ['A', 'AAAA']:
156
+ self.client.dns.records.delete(zone_id=zone_id, dns_record_id=record.id)
157
+
158
+ if not cname_exists:
159
+ self.client.dns.records.create(zone_id=zone_id, **dns_record)
160
+ logging.info("CNAME record created.")
161
+ except Exception as e:
162
+ logging.error(f"Error creating CNAME record: {e}")
163
+ raise
164
+
165
+
166
+ class BunnyDNSProvider(DNSProvider):
167
+ """Bunny.net DNS API adapter."""
168
+
169
+ API_BASE = "https://api.bunny.net"
170
+ RECORD_TYPE_A = 0
171
+ RECORD_TYPE_AAAA = 1
172
+ RECORD_TYPE_CNAME = 2
173
+
174
+ def __init__(self):
175
+ self.api_key = os.environ.get("BUNNY_API_KEY")
176
+ if not self.api_key:
177
+ raise ValueError("BUNNY_API_KEY environment variable not set.")
178
+ self.session = requests.Session()
179
+ self.session.headers.update({
180
+ "AccessKey": self.api_key,
181
+ "Content-Type": "application/json"
182
+ })
183
+
184
+ def _api_request(self, method: str, endpoint: str, data: dict = None) -> dict:
185
+ url = f"{self.API_BASE}{endpoint}"
186
+ kwargs = {"timeout": 30}
187
+ if data is not None:
188
+ kwargs["json"] = data
189
+
190
+ response = getattr(self.session, method.lower())(url, **kwargs)
191
+
192
+ if response.status_code >= 400:
193
+ raise Exception(f"Bunny API error: {response.status_code} - {response.text}")
194
+
195
+ if response.text and response.status_code not in [204]:
196
+ return response.json()
197
+ return {}
198
+
199
+ def get_zone_id(self, zone_name: str) -> str:
200
+ result = self._api_request("GET", "/dnszone")
201
+ items = result.get("Items", [])
202
+ for zone in items:
203
+ if zone.get("Domain") == zone_name:
204
+ return str(zone["Id"])
205
+ raise Exception(f"Zone not found in Bunny DNS: {zone_name}")
206
+
207
+ def create_cname_record(self, zone_id: str, name: str, target: str,
208
+ zone_name: str, ttl: int = 300) -> None:
209
+ logging.info(f"Creating/updating CNAME: '{name}.{zone_name}' -> '{target}'...")
210
+
211
+ zone_data = self._api_request("GET", f"/dnszone/{zone_id}")
212
+ records = zone_data.get("Records", [])
213
+
214
+ # Remove conflicting A/AAAA records
215
+ for record in records:
216
+ if record.get("Name") == name and record.get("Type") in [self.RECORD_TYPE_A, self.RECORD_TYPE_AAAA]:
217
+ logging.info(f"Removing conflicting record (type={record['Type']}, id={record['Id']})...")
218
+ self._api_request("DELETE", f"/dnszone/{zone_id}/records/{record['Id']}")
219
+
220
+ # Check existing CNAME
221
+ existing_cname = None
222
+ for record in records:
223
+ if record.get("Name") == name and record.get("Type") == self.RECORD_TYPE_CNAME:
224
+ existing_cname = record
225
+ break
226
+
227
+ if existing_cname:
228
+ if existing_cname.get("Value") == target:
229
+ logging.info("CNAME already correct.")
230
+ else:
231
+ self._api_request("POST", f"/dnszone/{zone_id}/records/{existing_cname['Id']}",
232
+ {"Type": self.RECORD_TYPE_CNAME, "Name": name, "Value": target, "Ttl": ttl})
233
+ logging.info("CNAME updated.")
234
+ else:
235
+ self._api_request("PUT", f"/dnszone/{zone_id}/records",
236
+ {"Type": self.RECORD_TYPE_CNAME, "Name": name, "Value": target, "Ttl": ttl})
237
+ logging.info("CNAME created.")
238
+
239
+
240
+ def get_dns_provider(provider_name: str) -> DNSProvider:
241
+ """Factory function to get the appropriate DNS provider."""
242
+ providers = {
243
+ 'manual': ManualDNSProvider,
244
+ 'cloudflare': CloudflareDNSProvider,
245
+ 'bunny': BunnyDNSProvider,
246
+ }
247
+ if provider_name not in providers:
248
+ raise ValueError(f"Unknown DNS provider: {provider_name}")
249
+ return providers[provider_name]()
250
+
251
+
252
+ # =============================================================================
253
+ # Bunny Edge Scripting Client
254
+ # =============================================================================
255
+
256
+ class BunnyEdgeScriptClient:
257
+ """Client for Bunny.net Edge Scripting API.
258
+
259
+ API reference: https://docs.bunny.net/docs/edge-scripting-overview
260
+ Endpoints live under /api/v1/edgescripting.
261
+ """
262
+
263
+ def __init__(self, api_key: str = None):
264
+ self.api_key = api_key or os.environ.get("BUNNY_API_KEY")
265
+ if not self.api_key:
266
+ raise ValueError("BUNNY_API_KEY environment variable not set.")
267
+
268
+ self.session = requests.Session()
269
+ self.session.headers.update({
270
+ "AccessKey": self.api_key,
271
+ "Content-Type": "application/json",
272
+ "Accept": "application/json",
273
+ })
274
+
275
+ def _api(self, method: str, path: str, data=None) -> dict:
276
+ """Make an API request. path is relative to BUNNY_API_BASE."""
277
+ url = f"{BUNNY_API_BASE}{path}"
278
+ kwargs: dict = {"timeout": 60}
279
+ if data is not None:
280
+ kwargs["json"] = data
281
+
282
+ response = getattr(self.session, method.lower())(url, **kwargs)
283
+
284
+ if response.status_code >= 400:
285
+ raise Exception(
286
+ f"Bunny API {method} {path} -> {response.status_code}: "
287
+ f"{response.text[:500]}"
288
+ )
289
+
290
+ if response.text and response.status_code not in [204]:
291
+ return response.json()
292
+ return {}
293
+
294
+ # -- Pull Zone helpers (for custom hostnames + SSL) -----------------------
295
+
296
+ def get_pull_zone(self, pull_zone_id: int) -> dict:
297
+ return self._api("GET", f"/pullzone/{pull_zone_id}")
298
+
299
+ def add_hostname(self, pull_zone_id: int, hostname: str) -> None:
300
+ """Add a custom hostname to a pull zone."""
301
+ logging.info(f"Adding hostname '{hostname}' to pull zone {pull_zone_id}")
302
+ self._api("POST", f"/pullzone/{pull_zone_id}/addHostname",
303
+ {"Hostname": hostname})
304
+
305
+ def load_free_certificate(self, hostname: str) -> int:
306
+ """Request Let's Encrypt certificate. Returns HTTP status code."""
307
+ url = f"{BUNNY_API_BASE}/pullzone/loadFreeCertificate?hostname={hostname}"
308
+ r = self.session.get(url, timeout=30)
309
+ return r.status_code
310
+
311
+ # -- Edge Script CRUD -----------------------------------------------------
312
+
313
+ def list_scripts(self, search: str = None) -> list[dict]:
314
+ """List all edge scripts, optionally filtered by name."""
315
+ params = ""
316
+ if search:
317
+ params = f"?search={requests.utils.quote(search)}"
318
+ result = self._api("GET", f"{EDGE_SCRIPTING_API}{params}")
319
+ # API returns paginated list; items are in the root list or under a key
320
+ if isinstance(result, list):
321
+ return result
322
+ return result.get("Items", result.get("items", []))
323
+
324
+ def find_script_by_name(self, name: str) -> Optional[dict]:
325
+ """Find an edge script by exact name match."""
326
+ scripts = self.list_scripts(search=name)
327
+ for s in scripts:
328
+ if s.get("Name") == name:
329
+ return s
330
+ return None
331
+
332
+ def get_script(self, script_id: int) -> dict:
333
+ return self._api("GET", f"{EDGE_SCRIPTING_API}/{script_id}")
334
+
335
+ def create_script(self, name: str, code: str = "",
336
+ script_type: int = 0,
337
+ create_linked_pull_zone: bool = True,
338
+ linked_pull_zone_name: str = None,
339
+ integration_id: int = None) -> dict:
340
+ """Create a new edge script.
341
+
342
+ script_type: 0 = standalone, 1 = middleware
343
+ """
344
+ payload: dict = {
345
+ "Name": name,
346
+ "ScriptType": script_type,
347
+ }
348
+
349
+ if code:
350
+ payload["Code"] = code
351
+
352
+ if script_type == 0:
353
+ # Standalone: create a linked pull zone
354
+ payload["CreateLinkedPullZone"] = create_linked_pull_zone
355
+ if linked_pull_zone_name:
356
+ payload["LinkedPullZoneName"] = linked_pull_zone_name
357
+
358
+ if integration_id is not None:
359
+ payload["IntegrationId"] = integration_id
360
+
361
+ logging.info(f"Creating edge script: {name} (type={'standalone' if script_type == 0 else 'middleware'})")
362
+ result = self._api("POST", EDGE_SCRIPTING_API, payload)
363
+ logging.info(f"Edge script created. ID: {result.get('Id')}")
364
+ return result
365
+
366
+ def update_script(self, script_id: int, **kwargs) -> dict:
367
+ """Update script metadata (Name, ScriptType)."""
368
+ return self._api("PUT", f"{EDGE_SCRIPTING_API}/{script_id}", kwargs)
369
+
370
+ def delete_script(self, script_id: int, delete_linked_pull_zones: bool = False) -> None:
371
+ params = f"?deleteLinkedPullZones={'true' if delete_linked_pull_zones else 'false'}"
372
+ self._api("DELETE", f"{EDGE_SCRIPTING_API}/{script_id}{params}")
373
+ logging.info(f"Edge script {script_id} deleted.")
374
+
375
+ # -- Code deployment ------------------------------------------------------
376
+
377
+ def get_code(self, script_id: int) -> str:
378
+ result = self._api("GET", f"{EDGE_SCRIPTING_API}/{script_id}/code")
379
+ return result.get("Code", "")
380
+
381
+ def deploy_code(self, script_id: int, code: str) -> dict:
382
+ """Push code to the edge script. Creates a new release."""
383
+ logging.info(f"Deploying code to edge script {script_id} ({len(code)} bytes)")
384
+ return self._api("POST", f"{EDGE_SCRIPTING_API}/{script_id}/code",
385
+ {"Code": code})
386
+
387
+ # -- Releases -------------------------------------------------------------
388
+
389
+ def list_releases(self, script_id: int) -> list[dict]:
390
+ result = self._api("GET", f"{EDGE_SCRIPTING_API}/{script_id}/releases")
391
+ if isinstance(result, list):
392
+ return result
393
+ return result.get("Items", result.get("items", []))
394
+
395
+ def publish(self, script_id: int) -> None:
396
+ """Publish the current code. Creates a release and makes it live."""
397
+ logging.info(f"Publishing edge script {script_id}...")
398
+ # Bunny requires Content-Type even for empty POST bodies
399
+ self._api("POST", f"{EDGE_SCRIPTING_API}/{script_id}/publish", {})
400
+
401
+ # -- Environment variables (includes secrets) ----------------------------
402
+ #
403
+ # Bunny Edge Scripting uses a single variables endpoint. There is no
404
+ # separate secrets API — all config is stored as variables and accessed
405
+ # via process.env / Deno.env in the script.
406
+
407
+ def list_variables(self, script_id: int) -> list[dict]:
408
+ """List variables. Also available as EdgeScriptVariables on GET script."""
409
+ detail = self.get_script(script_id)
410
+ return detail.get("EdgeScriptVariables", [])
411
+
412
+ def upsert_variable(self, script_id: int, name: str, default_value: str,
413
+ required: bool = True) -> dict:
414
+ """Create or update an environment variable (PUT = upsert)."""
415
+ return self._api("PUT", f"{EDGE_SCRIPTING_API}/{script_id}/variables",
416
+ {"Name": name, "DefaultValue": default_value,
417
+ "Required": required})
418
+
419
+ def delete_variable(self, script_id: int, var_id: int) -> None:
420
+ self._api("DELETE", f"{EDGE_SCRIPTING_API}/{script_id}/variables/{var_id}")
421
+
422
+
423
+ # =============================================================================
424
+ # Setup Report
425
+ # =============================================================================
426
+
427
+ class SetupReport:
428
+ """Track and report edge script setup status."""
429
+
430
+ def __init__(self, name: str, domain: str = None):
431
+ self.timestamp = time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())
432
+ self.name = name
433
+ self.domain = domain
434
+ self.components = {
435
+ 'script': {'status': 'pending', 'details': {}},
436
+ 'code': {'status': 'pending', 'details': {}},
437
+ 'variables': {'status': 'pending', 'details': {}},
438
+ 'secrets': {'status': 'pending', 'details': {}},
439
+ 'pull_zone': {'status': 'pending', 'details': {}},
440
+ 'hostname': {'status': 'pending', 'details': {}},
441
+ 'dns': {'status': 'pending', 'details': {}},
442
+ 'ssl': {'status': 'pending', 'details': {}},
443
+ }
444
+ self.urls = {}
445
+
446
+ def set_component(self, name: str, status: str, **details):
447
+ if name in self.components:
448
+ self.components[name]['status'] = status
449
+ self.components[name]['details'].update(details)
450
+
451
+ def set_url(self, name: str, url: str):
452
+ self.urls[name] = url
453
+
454
+ def generate_markdown(self) -> str:
455
+ lines = [
456
+ f"# Bunny Edge Script Setup Report: {self.name}",
457
+ "",
458
+ f"**Generated:** {self.timestamp}",
459
+ "**Script:** setup_bunny_edge_script.py",
460
+ "**Infrastructure:** Bunny.net Edge Scripting",
461
+ "",
462
+ "---",
463
+ "",
464
+ "## Configuration Summary",
465
+ "",
466
+ "| Setting | Value |",
467
+ "|---------|-------|",
468
+ f"| Name | `{self.name}` |",
469
+ ]
470
+
471
+ if self.domain:
472
+ lines.append(f"| Custom Domain | `{self.domain}` |")
473
+
474
+ lines.extend(["", "## Component Status", ""])
475
+
476
+ status_icons = {
477
+ 'created': '[NEW]', 'exists': '[OK]', 'configured': '[OK]',
478
+ 'deployed': '[OK]', 'published': '[OK]',
479
+ 'skipped': '[SKIP]', 'failed': '[FAIL]', 'pending': '[ ]',
480
+ }
481
+
482
+ component_names = {
483
+ 'script': 'Edge Script',
484
+ 'code': 'Code Deployment',
485
+ 'variables': 'Environment Variables',
486
+ 'secrets': 'Secrets',
487
+ 'pull_zone': 'Linked Pull Zone',
488
+ 'hostname': 'Custom Hostname',
489
+ 'dns': 'DNS Configuration',
490
+ 'ssl': 'SSL Certificate',
491
+ }
492
+
493
+ for comp_key, comp_name in component_names.items():
494
+ comp = self.components[comp_key]
495
+ status = comp['status']
496
+ icon = status_icons.get(status, '[ ]')
497
+ lines.append(f"### {icon} {comp_name}")
498
+ lines.append("")
499
+ lines.append(f"**Status:** {status.replace('_', ' ').title()}")
500
+
501
+ for key, value in comp['details'].items():
502
+ display_key = key.replace('_', ' ').title()
503
+ if isinstance(value, list):
504
+ lines.append(f"- **{display_key}:**")
505
+ for item in value:
506
+ lines.append(f" - `{item}`")
507
+ else:
508
+ lines.append(f"- **{display_key}:** `{value}`")
509
+ lines.append("")
510
+
511
+ if self.urls:
512
+ lines.extend([
513
+ "## URLs and Endpoints",
514
+ "",
515
+ "| Name | URL |",
516
+ "|------|-----|",
517
+ ])
518
+ for name, url in self.urls.items():
519
+ lines.append(f"| {name.replace('_', ' ').title()} | {url} |")
520
+ lines.append("")
521
+
522
+ lines.extend([
523
+ "## Deployment Commands",
524
+ "",
525
+ "```bash",
526
+ "# Re-deploy code",
527
+ f"granny create bunny-edge-script --name {self.name} --code handler.ts",
528
+ "```",
529
+ "",
530
+ "---",
531
+ "",
532
+ "*Generated by setup_bunny_edge_script.py*",
533
+ ])
534
+
535
+ return '\n'.join(lines)
536
+
537
+ def save_report(self, output_dir: str = '.') -> str:
538
+ os.makedirs(output_dir, exist_ok=True)
539
+ timestamp = time.strftime('%Y%m%d-%H%M%S', time.gmtime())
540
+ filename = f"bunny-edge-script-{self.name}-{timestamp}.md"
541
+ filepath = os.path.join(output_dir, filename)
542
+
543
+ with open(filepath, 'w', encoding='utf-8') as f:
544
+ f.write(self.generate_markdown())
545
+
546
+ logging.info(f"Setup report saved to: {filepath}")
547
+ return filepath
548
+
549
+ def write_deploy_env(self, env_file: str = '.deploy.env') -> bool:
550
+ """Write edge script configuration to .deploy.env file."""
551
+ script_id = self.components['script']['details'].get('script_id')
552
+ pull_zone_id = self.components['pull_zone']['details'].get('pull_zone_id')
553
+
554
+ if not script_id:
555
+ logging.warning("No script ID to write to deploy env")
556
+ return False
557
+
558
+ existing_content = ""
559
+ existing_vars: dict[str, bool] = {}
560
+ if os.path.exists(env_file):
561
+ with open(env_file, 'r') as f:
562
+ existing_content = f.read()
563
+ for line in existing_content.split('\n'):
564
+ if '=' in line and not line.strip().startswith('#'):
565
+ key = line.split('=')[0].strip()
566
+ existing_vars[key] = True
567
+
568
+ new_vars = {
569
+ 'BUNNY_EDGE_SCRIPT_ID': str(script_id),
570
+ }
571
+ if pull_zone_id:
572
+ new_vars['BUNNY_EDGE_PULL_ZONE_ID'] = str(pull_zone_id)
573
+
574
+ lines_to_add = []
575
+ for key, value in new_vars.items():
576
+ if key in existing_vars:
577
+ pattern = rf'^{key}=.*$'
578
+ existing_content = re.sub(pattern, f'{key}={value}',
579
+ existing_content, flags=re.MULTILINE)
580
+ logging.info(f"Updated {key} in {env_file}")
581
+ else:
582
+ lines_to_add.append(f"{key}={value}")
583
+
584
+ with open(env_file, 'w') as f:
585
+ if existing_content.strip():
586
+ f.write(existing_content)
587
+ if not existing_content.endswith('\n'):
588
+ f.write('\n')
589
+
590
+ if lines_to_add:
591
+ if existing_content.strip():
592
+ f.write('\n# Bunny Edge Scripting (auto-generated)\n')
593
+ for line in lines_to_add:
594
+ f.write(f"{line}\n")
595
+ key = line.split('=')[0]
596
+ logging.info(f"Added {key} to {env_file}")
597
+
598
+ logging.info(f"[OK] Deploy env written to: {env_file}")
599
+ return True
600
+
601
+
602
+ # =============================================================================
603
+ # Argument Parsing
604
+ # =============================================================================
605
+
606
+ def parse_arguments():
607
+ parser = argparse.ArgumentParser(
608
+ description='Setup Bunny.net Edge Scripts',
609
+ formatter_class=argparse.RawDescriptionHelpFormatter,
610
+ epilog="""
611
+ Examples:
612
+ # Standalone script with linked pull zone
613
+ granny create bunny-edge-script --name my-api --code handler.ts
614
+
615
+ # With custom domain (manual DNS)
616
+ granny create bunny-edge-script --name my-api --code handler.ts \\
617
+ --hostname api.example.com --domain example.com --dns-provider manual
618
+
619
+ # Middleware on existing pull zone
620
+ granny create bunny-edge-script --name my-mw --code mw.ts \\
621
+ --type middleware --pull-zone-id 12345
622
+
623
+ # Set env vars and secrets
624
+ granny create bunny-edge-script --name my-api --code handler.ts \\
625
+ --env NODE_ENV=production --secret API_KEY=sk-xxx
626
+
627
+ Environment Variables:
628
+ BUNNY_API_KEY: Bunny.net API key (required)
629
+ CLOUDFLARE_API_TOKEN: For Cloudflare DNS (if --dns-provider cloudflare)
630
+ """
631
+ )
632
+
633
+ parser.add_argument('--name', required=True,
634
+ help='Edge script name')
635
+ parser.add_argument('--code',
636
+ help='Path to the script file to deploy (TypeScript or JavaScript)')
637
+ parser.add_argument('--type', choices=list(SCRIPT_TYPES.keys()),
638
+ default='standalone',
639
+ help='Script type (default: standalone)')
640
+ parser.add_argument('--pull-zone-id', type=int,
641
+ help='Existing pull zone ID (required for middleware, '
642
+ 'optional for standalone to skip auto-creation)')
643
+ parser.add_argument('--pull-zone-name',
644
+ help='Name for the auto-created pull zone '
645
+ '(standalone only, default: same as --name)')
646
+
647
+ # Custom domain
648
+ parser.add_argument('--hostname',
649
+ help='Custom hostname to bind (e.g. api.example.com)')
650
+ parser.add_argument('--domain',
651
+ help='Root domain for DNS zone lookup (e.g. example.com). '
652
+ 'Required with --hostname when using automated DNS.')
653
+ parser.add_argument('--dns-provider',
654
+ choices=['manual', 'cloudflare', 'bunny'],
655
+ default='manual',
656
+ help='DNS provider (default: manual)')
657
+
658
+ # Environment
659
+ parser.add_argument('--env', action='append', metavar='KEY=VALUE',
660
+ help='Environment variable (can be repeated)')
661
+ parser.add_argument('--secret', action='append', metavar='KEY=VALUE',
662
+ help='Secret (can be repeated, write-only)')
663
+
664
+ # Publish
665
+ parser.add_argument('--publish', action='store_true',
666
+ help='Publish the release after deploying code')
667
+ parser.add_argument('--publish-note', default='',
668
+ help='Release note (used with --publish)')
669
+
670
+ # Output
671
+ parser.add_argument('--report-dir', default='.',
672
+ help='Directory to save the setup report')
673
+ parser.add_argument('--deploy-env', default='.deploy.env',
674
+ help='Path to .deploy.env file (default: .deploy.env)')
675
+ parser.add_argument('--no-deploy-env', action='store_true',
676
+ help='Skip writing to .deploy.env file')
677
+
678
+ return parser.parse_args()
679
+
680
+
681
+ def parse_key_value_list(items: list[str] | None) -> dict[str, str]:
682
+ """Parse KEY=VALUE arguments into a dict."""
683
+ if not items:
684
+ return {}
685
+ result = {}
686
+ for item in items:
687
+ if '=' in item:
688
+ key, value = item.split('=', 1)
689
+ result[key.strip()] = value
690
+ return result
691
+
692
+
693
+ # =============================================================================
694
+ # Main Setup
695
+ # =============================================================================
696
+
697
+ def main():
698
+ args = parse_arguments()
699
+
700
+ logging.basicConfig(level=logging.INFO,
701
+ format='%(asctime)s - %(levelname)s - %(message)s')
702
+
703
+ report = SetupReport(name=args.name, domain=args.hostname)
704
+
705
+ script_type_int = SCRIPT_TYPES[args.type]
706
+
707
+ logging.info("=" * 50)
708
+ logging.info("BUNNY EDGE SCRIPTING SETUP")
709
+ logging.info("=" * 50)
710
+ logging.info(f"Name: {args.name}")
711
+ logging.info(f"Type: {args.type}")
712
+ if args.code:
713
+ logging.info(f"Code: {args.code}")
714
+ if args.hostname:
715
+ logging.info(f"Hostname: {args.hostname}")
716
+ logging.info(f"DNS Provider: {args.dns_provider}")
717
+ logging.info("=" * 50)
718
+
719
+ try:
720
+ client = BunnyEdgeScriptClient()
721
+
722
+ # ---- Step 1: Create or find edge script ----------------------------
723
+ logging.info("\n--- Step 1: Edge Script ---")
724
+
725
+ existing = client.find_script_by_name(args.name)
726
+ if existing:
727
+ script = existing
728
+ script_id = script["Id"]
729
+ logging.info(f"Edge script '{args.name}' already exists. ID: {script_id}")
730
+ report.set_component('script', 'exists', script_id=script_id)
731
+ else:
732
+ pull_zone_name = args.pull_zone_name or args.name
733
+ script = client.create_script(
734
+ name=args.name,
735
+ script_type=script_type_int,
736
+ create_linked_pull_zone=(args.type == 'standalone' and args.pull_zone_id is None),
737
+ linked_pull_zone_name=pull_zone_name,
738
+ )
739
+ script_id = script["Id"]
740
+ report.set_component('script', 'created', script_id=script_id)
741
+
742
+ # Find the linked pull zone ID and default hostname
743
+ pull_zone_id = args.pull_zone_id
744
+ cdn_hostname = ""
745
+ default_hostname = ""
746
+
747
+ # Re-fetch full details (create response already has them, but
748
+ # if we found an existing script we need them too)
749
+ script_detail = client.get_script(script_id)
750
+ default_hostname = (script_detail.get("DefaultHostname") or "").replace("https://", "")
751
+ system_hostname = (script_detail.get("SystemHostname") or "").replace("https://", "")
752
+
753
+ if pull_zone_id is None:
754
+ linked_pz = script_detail.get("LinkedPullZones", [])
755
+ if linked_pz:
756
+ pull_zone_id = linked_pz[0].get("Id")
757
+
758
+ cdn_hostname = system_hostname or default_hostname
759
+
760
+ if pull_zone_id:
761
+ logging.info(f"Linked pull zone: {pull_zone_id}")
762
+ logging.info(f"Default hostname: {default_hostname}")
763
+ logging.info(f"System hostname: {system_hostname}")
764
+ report.set_component('pull_zone', 'configured',
765
+ pull_zone_id=pull_zone_id,
766
+ default_hostname=default_hostname,
767
+ system_hostname=system_hostname)
768
+ report.set_url('default_url', f"https://{default_hostname}")
769
+ else:
770
+ logging.warning("No linked pull zone found.")
771
+ report.set_component('pull_zone', 'skipped')
772
+
773
+ # ---- Step 2: Deploy code -------------------------------------------
774
+ if args.code:
775
+ logging.info("\n--- Step 2: Code Deployment ---")
776
+
777
+ code_path = os.path.abspath(args.code)
778
+ if not os.path.isfile(code_path):
779
+ raise FileNotFoundError(f"Code file not found: {code_path}")
780
+
781
+ with open(code_path, 'r', encoding='utf-8') as f:
782
+ code = f.read()
783
+
784
+ client.deploy_code(script_id, code)
785
+ report.set_component('code', 'deployed',
786
+ file=args.code, size_bytes=len(code))
787
+ logging.info(f"Code deployed ({len(code)} bytes)")
788
+
789
+ # Publish if requested
790
+ if args.publish:
791
+ client.publish(script_id)
792
+ report.set_component('code', 'published', file=args.code)
793
+ logging.info("Release published.")
794
+ else:
795
+ logging.info("\n--- Step 2: Code Deployment (skipped, no --code) ---")
796
+ report.set_component('code', 'skipped')
797
+
798
+ # ---- Step 3: Environment variables ---------------------------------
799
+ env_vars = parse_key_value_list(args.env)
800
+ if env_vars:
801
+ logging.info("\n--- Step 3: Environment Variables ---")
802
+ var_names = []
803
+ for key, value in env_vars.items():
804
+ client.upsert_variable(script_id, key, value, required=True)
805
+ logging.info(f" {key} = {value}")
806
+ var_names.append(key)
807
+ report.set_component('variables', 'configured', variables=var_names)
808
+ else:
809
+ report.set_component('variables', 'skipped')
810
+
811
+ # ---- Step 4: Secrets (stored as variables — no separate API) --------
812
+ secret_vars = parse_key_value_list(args.secret)
813
+ if secret_vars:
814
+ logging.info("\n--- Step 4: Secrets (as variables) ---")
815
+ secret_names = []
816
+ for key, value in secret_vars.items():
817
+ client.upsert_variable(script_id, key, value, required=True)
818
+ logging.info(f" {key} = ****")
819
+ secret_names.append(key)
820
+ report.set_component('secrets', 'configured', secrets=secret_names)
821
+ logging.warning("Note: Bunny Edge Scripting stores secrets as "
822
+ "plain variables. Values are visible in the dashboard.")
823
+ else:
824
+ report.set_component('secrets', 'skipped')
825
+
826
+ # ---- Step 5: Custom hostname ---------------------------------------
827
+ if args.hostname and pull_zone_id:
828
+ logging.info(f"\n--- Step 5: Custom Hostname ({args.hostname}) ---")
829
+
830
+ # Check if hostname already exists on the pull zone
831
+ pz = client.get_pull_zone(pull_zone_id)
832
+ existing_hostnames = [h.get("Value") for h in pz.get("Hostnames", [])]
833
+
834
+ if args.hostname in existing_hostnames:
835
+ logging.info(f"Hostname '{args.hostname}' already bound.")
836
+ report.set_component('hostname', 'exists', hostname=args.hostname)
837
+ else:
838
+ client.add_hostname(pull_zone_id, args.hostname)
839
+ report.set_component('hostname', 'configured',
840
+ hostname=args.hostname)
841
+ report.set_url('custom_url', f"https://{args.hostname}")
842
+
843
+ # ---- Step 6: DNS -----------------------------------------------
844
+ logging.info(f"\n--- Step 6: DNS ({args.dns_provider}) ---")
845
+
846
+ # The CNAME target is the pull zone's default b-cdn.net hostname
847
+ cname_target = cdn_hostname if cdn_hostname else f"{args.name}.b-cdn.net"
848
+
849
+ # Extract subdomain from hostname
850
+ if args.domain:
851
+ subdomain = args.hostname.replace(f".{args.domain}", "")
852
+ else:
853
+ # Guess: hostname = sub.domain.tld -> subdomain = sub
854
+ parts = args.hostname.split('.')
855
+ subdomain = parts[0] if len(parts) > 2 else args.hostname
856
+ args.domain = '.'.join(parts[1:]) if len(parts) > 2 else args.hostname
857
+
858
+ dns = get_dns_provider(args.dns_provider)
859
+ zone_id = dns.get_zone_id(args.domain)
860
+ dns.create_cname_record(zone_id, subdomain, cname_target, args.domain)
861
+ report.set_component('dns', 'configured',
862
+ provider=args.dns_provider,
863
+ subdomain=subdomain,
864
+ target=cname_target)
865
+
866
+ # ---- Step 7: SSL -----------------------------------------------
867
+ logging.info("\n--- Step 7: SSL Certificate ---")
868
+ if args.dns_provider == 'manual':
869
+ logging.info("SSL will auto-issue once the CNAME record propagates.")
870
+ report.set_component('ssl', 'skipped',
871
+ note='Add CNAME first, then request cert')
872
+ else:
873
+ logging.info("Waiting 15 seconds for DNS propagation...")
874
+ time.sleep(15)
875
+ status = client.load_free_certificate(args.hostname)
876
+ if status == 200:
877
+ logging.info("SSL certificate issued.")
878
+ report.set_component('ssl', 'configured', hostname=args.hostname)
879
+ else:
880
+ logging.warning(f"SSL cert request returned {status}. "
881
+ "Retry after DNS propagation.")
882
+ report.set_component('ssl', 'pending',
883
+ note=f'HTTP {status} — retry after DNS propagates')
884
+ else:
885
+ if args.hostname and not pull_zone_id:
886
+ logging.warning("Cannot add hostname: no pull zone available.")
887
+ report.set_component('hostname', 'skipped')
888
+ report.set_component('dns', 'skipped')
889
+ report.set_component('ssl', 'skipped')
890
+
891
+ # ---- Summary -------------------------------------------------------
892
+ logging.info("")
893
+ logging.info("=" * 50)
894
+ logging.info("EDGE SCRIPT SETUP COMPLETE")
895
+ logging.info("=" * 50)
896
+ logging.info(f"Script Name: {args.name}")
897
+ logging.info(f"Script ID: {script_id}")
898
+ if pull_zone_id:
899
+ logging.info(f"Pull Zone: {pull_zone_id}")
900
+ if default_hostname:
901
+ logging.info(f"Default URL: https://{default_hostname}")
902
+ if args.hostname:
903
+ logging.info(f"Custom URL: https://{args.hostname}")
904
+ logging.info("=" * 50)
905
+
906
+ # Save report
907
+ report.save_report(args.report_dir)
908
+
909
+ # Write .deploy.env
910
+ if not args.no_deploy_env:
911
+ report.write_deploy_env(args.deploy_env)
912
+
913
+ return 0
914
+
915
+ except Exception as e:
916
+ logging.error(f"Setup failed: {e}")
917
+ import traceback
918
+ traceback.print_exc()
919
+ return 1
920
+
921
+
922
+ if __name__ == '__main__':
923
+ raise SystemExit(main())