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
|
@@ -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())
|