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,1165 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Scaleway Functions (FaaS) Setup Script
|
|
4
|
+
|
|
5
|
+
This script sets up serverless functions on Scaleway's Function-as-a-Service platform.
|
|
6
|
+
Scaleway Functions is a great alternative to AWS Lambda with simpler pricing and
|
|
7
|
+
EU-based infrastructure.
|
|
8
|
+
|
|
9
|
+
FEATURES:
|
|
10
|
+
---------
|
|
11
|
+
- Creates Scaleway Functions namespaces
|
|
12
|
+
- Deploys functions from local directories or Docker images
|
|
13
|
+
- Configures custom domains with SSL
|
|
14
|
+
- Sets up environment variables and secrets
|
|
15
|
+
- Supports multiple regions (Paris, Amsterdam, Warsaw)
|
|
16
|
+
|
|
17
|
+
Environment Variables:
|
|
18
|
+
SCW_ACCESS_KEY: Scaleway access key
|
|
19
|
+
SCW_SECRET_KEY: Scaleway secret key
|
|
20
|
+
SCW_DEFAULT_ORGANIZATION_ID: Scaleway organization ID
|
|
21
|
+
SCW_DEFAULT_PROJECT_ID: Scaleway project ID
|
|
22
|
+
|
|
23
|
+
Usage Examples:
|
|
24
|
+
# Create a new function namespace
|
|
25
|
+
python setup_scaleway_faas.py --domain api.m3rp.ai --name m3rp-backend
|
|
26
|
+
|
|
27
|
+
# Deploy with custom domain
|
|
28
|
+
python setup_scaleway_faas.py --domain api.m3rp.ai --name m3rp-backend --setup-domain
|
|
29
|
+
|
|
30
|
+
# Specify region
|
|
31
|
+
python setup_scaleway_faas.py --domain api.m3rp.ai --name m3rp-backend --region nl-ams
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
import os
|
|
35
|
+
import json
|
|
36
|
+
import time
|
|
37
|
+
import logging
|
|
38
|
+
import argparse
|
|
39
|
+
import requests
|
|
40
|
+
from typing import Optional, List
|
|
41
|
+
from abc import ABC, abstractmethod
|
|
42
|
+
|
|
43
|
+
from dotenv import load_dotenv
|
|
44
|
+
load_dotenv()
|
|
45
|
+
try:
|
|
46
|
+
from granny.credentials import load_secrets_into_env
|
|
47
|
+
load_secrets_into_env()
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# =============================================================================
|
|
53
|
+
# Scaleway Configuration
|
|
54
|
+
# =============================================================================
|
|
55
|
+
|
|
56
|
+
SCALEWAY_API_BASE = "https://api.scaleway.com"
|
|
57
|
+
|
|
58
|
+
SCALEWAY_REGIONS = {
|
|
59
|
+
'fr-par': {'name': 'Paris, France', 'functions_api': 'https://api.scaleway.com/functions/v1beta1/regions/fr-par'},
|
|
60
|
+
'nl-ams': {'name': 'Amsterdam, Netherlands', 'functions_api': 'https://api.scaleway.com/functions/v1beta1/regions/nl-ams'},
|
|
61
|
+
'pl-waw': {'name': 'Warsaw, Poland', 'functions_api': 'https://api.scaleway.com/functions/v1beta1/regions/pl-waw'},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
SCALEWAY_RUNTIMES = {
|
|
65
|
+
'node20': 'node20',
|
|
66
|
+
'node22': 'node22',
|
|
67
|
+
'python310': 'python310',
|
|
68
|
+
'python311': 'python311',
|
|
69
|
+
'python312': 'python312',
|
|
70
|
+
'go121': 'go121',
|
|
71
|
+
'rust165': 'rust165',
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# =============================================================================
|
|
76
|
+
# DNS Provider Abstraction (same as other setup scripts)
|
|
77
|
+
# =============================================================================
|
|
78
|
+
|
|
79
|
+
class DNSProvider(ABC):
|
|
80
|
+
"""Abstract base class for DNS providers."""
|
|
81
|
+
|
|
82
|
+
@abstractmethod
|
|
83
|
+
def get_zone_id(self, zone_name: str) -> str:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
@abstractmethod
|
|
87
|
+
def create_cname_record(self, zone_id: str, name: str, target: str,
|
|
88
|
+
zone_name: str, ttl: int = 300) -> None:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class CloudflareDNSProvider(DNSProvider):
|
|
93
|
+
"""Cloudflare DNS API adapter."""
|
|
94
|
+
|
|
95
|
+
def __init__(self):
|
|
96
|
+
try:
|
|
97
|
+
from cloudflare import Cloudflare
|
|
98
|
+
except ImportError:
|
|
99
|
+
raise ImportError("Cloudflare library not found. Install: pip install cloudflare")
|
|
100
|
+
token = os.environ.get("CLOUDFLARE_API_TOKEN")
|
|
101
|
+
if not token:
|
|
102
|
+
raise ValueError("CLOUDFLARE_API_TOKEN environment variable not set.")
|
|
103
|
+
self.client = Cloudflare(api_token=token)
|
|
104
|
+
|
|
105
|
+
def get_zone_id(self, zone_name: str) -> str:
|
|
106
|
+
zones = self.client.zones.list(name=zone_name)
|
|
107
|
+
if not zones.result:
|
|
108
|
+
raise Exception(f"Zone not found in Cloudflare: {zone_name}")
|
|
109
|
+
return zones.result[0].id
|
|
110
|
+
|
|
111
|
+
def create_cname_record(self, zone_id: str, name: str, target: str,
|
|
112
|
+
zone_name: str, ttl: int = 300) -> None:
|
|
113
|
+
full_domain_name = f"{name}.{zone_name}" if name != zone_name else zone_name
|
|
114
|
+
logging.info(f"Creating/updating CNAME: '{full_domain_name}' -> '{target}'...")
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
existing_records = self.client.dns.records.list(zone_id=zone_id, name=full_domain_name)
|
|
118
|
+
dns_record = {'name': name, 'type': 'CNAME', 'content': target, 'proxied': False}
|
|
119
|
+
|
|
120
|
+
cname_exists = False
|
|
121
|
+
if existing_records.result:
|
|
122
|
+
for record in existing_records.result:
|
|
123
|
+
if record.type == 'CNAME':
|
|
124
|
+
cname_exists = True
|
|
125
|
+
if record.content != target:
|
|
126
|
+
self.client.dns.records.update(zone_id=zone_id, dns_record_id=record.id, **dns_record)
|
|
127
|
+
logging.info("CNAME record updated.")
|
|
128
|
+
else:
|
|
129
|
+
logging.info("CNAME record already correct.")
|
|
130
|
+
elif record.type in ['A', 'AAAA']:
|
|
131
|
+
self.client.dns.records.delete(zone_id=zone_id, dns_record_id=record.id)
|
|
132
|
+
|
|
133
|
+
if not cname_exists:
|
|
134
|
+
self.client.dns.records.create(zone_id=zone_id, **dns_record)
|
|
135
|
+
logging.info("CNAME record created.")
|
|
136
|
+
except Exception as e:
|
|
137
|
+
logging.error(f"Error creating CNAME record: {e}")
|
|
138
|
+
raise
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class DeSECDNSProvider(DNSProvider):
|
|
142
|
+
"""deSEC DNS API adapter."""
|
|
143
|
+
|
|
144
|
+
API_BASE = "https://desec.io/api/v1"
|
|
145
|
+
MIN_TTL = 3600
|
|
146
|
+
|
|
147
|
+
def __init__(self):
|
|
148
|
+
token = os.environ.get("DESEC_API_TOKEN")
|
|
149
|
+
if not token:
|
|
150
|
+
raise ValueError("DESEC_API_TOKEN environment variable not set.")
|
|
151
|
+
self.token = token
|
|
152
|
+
self.session = requests.Session()
|
|
153
|
+
self.session.headers.update({
|
|
154
|
+
"Authorization": f"Token {token}",
|
|
155
|
+
"Content-Type": "application/json"
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
def _api_request(self, method: str, endpoint: str, data: dict = None) -> dict:
|
|
159
|
+
url = f"{self.API_BASE}{endpoint}"
|
|
160
|
+
response = getattr(self.session, method.lower())(url, json=data)
|
|
161
|
+
|
|
162
|
+
if response.status_code == 429:
|
|
163
|
+
retry_after = int(response.headers.get("Retry-After", 5))
|
|
164
|
+
logging.warning(f"Rate limited. Waiting {retry_after}s...")
|
|
165
|
+
time.sleep(retry_after)
|
|
166
|
+
return self._api_request(method, endpoint, data)
|
|
167
|
+
|
|
168
|
+
if response.status_code >= 400:
|
|
169
|
+
raise Exception(f"API error: {response.status_code} - {response.text}")
|
|
170
|
+
|
|
171
|
+
return response.json() if response.text and response.status_code != 204 else {}
|
|
172
|
+
|
|
173
|
+
def get_zone_id(self, zone_name: str) -> str:
|
|
174
|
+
try:
|
|
175
|
+
self._api_request("GET", f"/domains/{zone_name}/")
|
|
176
|
+
return zone_name
|
|
177
|
+
except Exception as exc:
|
|
178
|
+
raise Exception(f"Zone not found in deSEC: {zone_name}") from exc
|
|
179
|
+
|
|
180
|
+
def create_cname_record(self, zone_id: str, name: str, target: str,
|
|
181
|
+
zone_name: str, ttl: int = 300) -> None:
|
|
182
|
+
logging.info(f"Creating/updating CNAME: '{name}.{zone_name}' -> '{target}'...")
|
|
183
|
+
target_with_dot = target if target.endswith('.') else f"{target}."
|
|
184
|
+
|
|
185
|
+
# Delete conflicting records
|
|
186
|
+
try:
|
|
187
|
+
self._api_request("DELETE", f"/domains/{zone_id}/rrsets/{name}/A/")
|
|
188
|
+
except Exception:
|
|
189
|
+
pass
|
|
190
|
+
try:
|
|
191
|
+
self._api_request("DELETE", f"/domains/{zone_id}/rrsets/{name}/AAAA/")
|
|
192
|
+
except Exception:
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
data = {
|
|
196
|
+
"subname": name,
|
|
197
|
+
"type": "CNAME",
|
|
198
|
+
"records": [target_with_dot],
|
|
199
|
+
"ttl": max(ttl, self.MIN_TTL)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
self._api_request("PATCH", f"/domains/{zone_id}/rrsets/{name}/CNAME/", {"records": [target_with_dot], "ttl": max(ttl, self.MIN_TTL)})
|
|
204
|
+
except Exception:
|
|
205
|
+
self._api_request("POST", f"/domains/{zone_id}/rrsets/", data)
|
|
206
|
+
|
|
207
|
+
logging.info("CNAME record created/updated.")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class ClouDNSDNSProvider(DNSProvider):
|
|
211
|
+
"""ClouDNS DNS API adapter.
|
|
212
|
+
|
|
213
|
+
ClouDNS is a European DNS provider with affordable DDoS protection.
|
|
214
|
+
|
|
215
|
+
Environment Variables:
|
|
216
|
+
CLOUDNS_AUTH_ID: ClouDNS API auth-id
|
|
217
|
+
CLOUDNS_AUTH_PASSWORD: ClouDNS API auth-password
|
|
218
|
+
"""
|
|
219
|
+
API_BASE = "https://api.cloudns.net"
|
|
220
|
+
MIN_TTL = 60
|
|
221
|
+
|
|
222
|
+
def __init__(self):
|
|
223
|
+
self.auth_id = os.environ.get("CLOUDNS_AUTH_ID")
|
|
224
|
+
self.auth_password = os.environ.get("CLOUDNS_AUTH_PASSWORD")
|
|
225
|
+
self.sub_auth_id = os.environ.get("CLOUDNS_SUB_AUTH_ID")
|
|
226
|
+
self.sub_auth_user = os.environ.get("CLOUDNS_SUB_AUTH_USER")
|
|
227
|
+
|
|
228
|
+
if not self.auth_password:
|
|
229
|
+
raise ValueError("CLOUDNS_AUTH_PASSWORD environment variable not set.")
|
|
230
|
+
|
|
231
|
+
if not any([self.auth_id, self.sub_auth_id, self.sub_auth_user]):
|
|
232
|
+
raise ValueError(
|
|
233
|
+
"ClouDNS authentication required. Set CLOUDNS_AUTH_ID, "
|
|
234
|
+
"CLOUDNS_SUB_AUTH_ID, or CLOUDNS_SUB_AUTH_USER"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
self.session = requests.Session()
|
|
238
|
+
|
|
239
|
+
def _get_auth_params(self) -> dict:
|
|
240
|
+
params = {"auth-password": self.auth_password}
|
|
241
|
+
if self.auth_id:
|
|
242
|
+
params["auth-id"] = self.auth_id
|
|
243
|
+
elif self.sub_auth_id:
|
|
244
|
+
params["sub-auth-id"] = self.sub_auth_id
|
|
245
|
+
elif self.sub_auth_user:
|
|
246
|
+
params["sub-auth-user"] = self.sub_auth_user
|
|
247
|
+
return params
|
|
248
|
+
|
|
249
|
+
def _api_request(self, endpoint: str, params: dict = None) -> dict:
|
|
250
|
+
url = f"{self.API_BASE}{endpoint}"
|
|
251
|
+
request_params = self._get_auth_params()
|
|
252
|
+
if params:
|
|
253
|
+
request_params.update(params)
|
|
254
|
+
|
|
255
|
+
response = self.session.post(url, data=request_params, timeout=30)
|
|
256
|
+
result = response.json()
|
|
257
|
+
|
|
258
|
+
if isinstance(result, dict) and result.get("status") == "Failed":
|
|
259
|
+
raise Exception(f"ClouDNS API error: {result.get('statusDescription')}")
|
|
260
|
+
|
|
261
|
+
return result
|
|
262
|
+
|
|
263
|
+
def get_zone_id(self, zone_name: str) -> str:
|
|
264
|
+
result = self._api_request("/dns/list-zones.json", {
|
|
265
|
+
"page": 1,
|
|
266
|
+
"rows-per-page": 100,
|
|
267
|
+
"search": zone_name
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
if isinstance(result, dict):
|
|
271
|
+
for domain in result.keys():
|
|
272
|
+
if domain == zone_name:
|
|
273
|
+
return zone_name
|
|
274
|
+
|
|
275
|
+
raise Exception(f"Zone not found in ClouDNS: {zone_name}")
|
|
276
|
+
|
|
277
|
+
def _find_record(self, zone_name: str, record_name: str, record_type: str = "CNAME") -> dict:
|
|
278
|
+
result = self._api_request("/dns/records.json", {
|
|
279
|
+
"domain-name": zone_name,
|
|
280
|
+
"host": record_name,
|
|
281
|
+
"type": record_type
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
if isinstance(result, dict):
|
|
285
|
+
for record_id, record_info in result.items():
|
|
286
|
+
if record_info.get("host") == record_name and record_info.get("type") == record_type:
|
|
287
|
+
return {"id": record_id, **record_info}
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
def create_cname_record(self, zone_id: str, name: str, target: str,
|
|
291
|
+
zone_name: str, ttl: int = 300) -> None:
|
|
292
|
+
logging.info(f"Creating/updating CNAME: '{name}.{zone_name}' -> '{target}'...")
|
|
293
|
+
|
|
294
|
+
# Delete existing record if found
|
|
295
|
+
existing = self._find_record(zone_name, name, "CNAME")
|
|
296
|
+
if existing:
|
|
297
|
+
self._api_request("/dns/delete-record.json", {
|
|
298
|
+
"domain-name": zone_name,
|
|
299
|
+
"record-id": existing["id"]
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
# Also delete conflicting A/AAAA records
|
|
303
|
+
for rtype in ["A", "AAAA"]:
|
|
304
|
+
existing = self._find_record(zone_name, name, rtype)
|
|
305
|
+
if existing:
|
|
306
|
+
try:
|
|
307
|
+
self._api_request("/dns/delete-record.json", {
|
|
308
|
+
"domain-name": zone_name,
|
|
309
|
+
"record-id": existing["id"]
|
|
310
|
+
})
|
|
311
|
+
except Exception:
|
|
312
|
+
pass
|
|
313
|
+
|
|
314
|
+
# Create new CNAME
|
|
315
|
+
result = self._api_request("/dns/add-record.json", {
|
|
316
|
+
"domain-name": zone_name,
|
|
317
|
+
"record-type": "CNAME",
|
|
318
|
+
"host": name,
|
|
319
|
+
"record": target,
|
|
320
|
+
"ttl": max(ttl, self.MIN_TTL)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
if result.get("status") == "Success":
|
|
324
|
+
logging.info("CNAME record created/updated.")
|
|
325
|
+
else:
|
|
326
|
+
raise Exception(f"Failed to create CNAME: {result}")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class BunnyDNSProvider(DNSProvider):
|
|
331
|
+
"""Bunny.net DNS API adapter.
|
|
332
|
+
|
|
333
|
+
Bunny.net provides DNS hosting alongside their CDN.
|
|
334
|
+
Uses the Bunny.net REST API for DNS record management.
|
|
335
|
+
|
|
336
|
+
Environment Variables:
|
|
337
|
+
BUNNY_API_KEY: Bunny.net API key (from https://panel.bunny.net/account)
|
|
338
|
+
"""
|
|
339
|
+
API_BASE = "https://api.bunny.net"
|
|
340
|
+
# Bunny DNS record types: 0=A, 1=AAAA, 2=CNAME, 3=TXT, 4=MX, 5=Redirect, 7=SRV, 8=CAA
|
|
341
|
+
RECORD_TYPE_A = 0
|
|
342
|
+
RECORD_TYPE_AAAA = 1
|
|
343
|
+
RECORD_TYPE_CNAME = 2
|
|
344
|
+
|
|
345
|
+
def __init__(self):
|
|
346
|
+
self.api_key = os.environ.get("BUNNY_API_KEY")
|
|
347
|
+
if not self.api_key:
|
|
348
|
+
raise ValueError("BUNNY_API_KEY environment variable not set.")
|
|
349
|
+
self.session = requests.Session()
|
|
350
|
+
self.session.headers.update({
|
|
351
|
+
"AccessKey": self.api_key,
|
|
352
|
+
"Content-Type": "application/json"
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
def _api_request(self, method: str, endpoint: str, data: dict = None) -> dict:
|
|
356
|
+
url = f"{self.API_BASE}{endpoint}"
|
|
357
|
+
kwargs = {"timeout": 30}
|
|
358
|
+
if data is not None:
|
|
359
|
+
kwargs["json"] = data
|
|
360
|
+
|
|
361
|
+
response = getattr(self.session, method.lower())(url, **kwargs)
|
|
362
|
+
|
|
363
|
+
if response.status_code >= 400:
|
|
364
|
+
raise Exception(f"Bunny API error: {response.status_code} - {response.text}")
|
|
365
|
+
|
|
366
|
+
if response.text and response.status_code not in [204]:
|
|
367
|
+
return response.json()
|
|
368
|
+
return {}
|
|
369
|
+
|
|
370
|
+
def get_zone_id(self, zone_name: str) -> str:
|
|
371
|
+
result = self._api_request("GET", "/dnszone")
|
|
372
|
+
items = result.get("Items", [])
|
|
373
|
+
for zone in items:
|
|
374
|
+
if zone.get("Domain") == zone_name:
|
|
375
|
+
return str(zone["Id"])
|
|
376
|
+
raise Exception(f"Zone not found in Bunny DNS: {zone_name}")
|
|
377
|
+
|
|
378
|
+
def create_cname_record(self, zone_id: str, name: str, target: str,
|
|
379
|
+
zone_name: str, ttl: int = 300) -> None:
|
|
380
|
+
logging.info(f"Creating/updating CNAME: '{name}.{zone_name}' -> '{target}'...")
|
|
381
|
+
|
|
382
|
+
# Get zone data with all records
|
|
383
|
+
zone_data = self._api_request("GET", f"/dnszone/{zone_id}")
|
|
384
|
+
records = zone_data.get("Records", [])
|
|
385
|
+
|
|
386
|
+
# Remove conflicting A/AAAA records
|
|
387
|
+
for record in records:
|
|
388
|
+
if record.get("Name") == name and record.get("Type") in [self.RECORD_TYPE_A, self.RECORD_TYPE_AAAA]:
|
|
389
|
+
logging.info(f"Removing conflicting record (type={record['Type']}, id={record['Id']})...")
|
|
390
|
+
self._api_request("DELETE", f"/dnszone/{zone_id}/records/{record['Id']}")
|
|
391
|
+
|
|
392
|
+
# Check existing CNAME
|
|
393
|
+
existing_cname = None
|
|
394
|
+
for record in records:
|
|
395
|
+
if record.get("Name") == name and record.get("Type") == self.RECORD_TYPE_CNAME:
|
|
396
|
+
existing_cname = record
|
|
397
|
+
break
|
|
398
|
+
|
|
399
|
+
if existing_cname:
|
|
400
|
+
if existing_cname.get("Value") == target:
|
|
401
|
+
logging.info("CNAME record already correct.")
|
|
402
|
+
return
|
|
403
|
+
# Update existing CNAME
|
|
404
|
+
self._api_request("POST", f"/dnszone/{zone_id}/records/{existing_cname['Id']}", {
|
|
405
|
+
"Type": self.RECORD_TYPE_CNAME,
|
|
406
|
+
"Name": name,
|
|
407
|
+
"Value": target,
|
|
408
|
+
"Ttl": ttl
|
|
409
|
+
})
|
|
410
|
+
logging.info("CNAME record updated.")
|
|
411
|
+
else:
|
|
412
|
+
# Create new CNAME
|
|
413
|
+
self._api_request("PUT", f"/dnszone/{zone_id}/records", {
|
|
414
|
+
"Type": self.RECORD_TYPE_CNAME,
|
|
415
|
+
"Name": name,
|
|
416
|
+
"Value": target,
|
|
417
|
+
"Ttl": ttl
|
|
418
|
+
})
|
|
419
|
+
logging.info("CNAME record created.")
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def get_dns_provider(provider_name: str) -> DNSProvider:
|
|
423
|
+
"""Factory function to get the appropriate DNS provider."""
|
|
424
|
+
providers = {
|
|
425
|
+
'cloudflare': CloudflareDNSProvider,
|
|
426
|
+
'desec': DeSECDNSProvider,
|
|
427
|
+
'cloudns': ClouDNSDNSProvider,
|
|
428
|
+
'bunny': BunnyDNSProvider,
|
|
429
|
+
}
|
|
430
|
+
if provider_name not in providers:
|
|
431
|
+
raise ValueError(f"Unknown DNS provider: {provider_name}")
|
|
432
|
+
return providers[provider_name]()
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
# =============================================================================
|
|
436
|
+
# Scaleway Functions Client
|
|
437
|
+
# =============================================================================
|
|
438
|
+
|
|
439
|
+
class ScalewayFunctionsClient:
|
|
440
|
+
"""Client for Scaleway Functions API."""
|
|
441
|
+
|
|
442
|
+
def __init__(self, access_key: str = None, secret_key: str = None,
|
|
443
|
+
project_id: str = None, region: str = 'fr-par'):
|
|
444
|
+
self.access_key = access_key or os.environ.get("SCW_ACCESS_KEY")
|
|
445
|
+
self.secret_key = secret_key or os.environ.get("SCW_SECRET_KEY")
|
|
446
|
+
self.project_id = project_id or os.environ.get("SCW_DEFAULT_PROJECT_ID")
|
|
447
|
+
self.region = region
|
|
448
|
+
|
|
449
|
+
if not all([self.access_key, self.secret_key, self.project_id]):
|
|
450
|
+
raise ValueError(
|
|
451
|
+
"Missing Scaleway credentials. Set SCW_ACCESS_KEY, SCW_SECRET_KEY, "
|
|
452
|
+
"and SCW_DEFAULT_PROJECT_ID environment variables."
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
self.api_base = SCALEWAY_REGIONS[region]['functions_api']
|
|
456
|
+
self.session = requests.Session()
|
|
457
|
+
self.session.headers.update({
|
|
458
|
+
"X-Auth-Token": self.secret_key,
|
|
459
|
+
"Content-Type": "application/json"
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
def _api_request(self, method: str, endpoint: str, data: dict = None) -> dict:
|
|
463
|
+
"""Make an API request to Scaleway Functions API."""
|
|
464
|
+
url = f"{self.api_base}{endpoint}"
|
|
465
|
+
|
|
466
|
+
try:
|
|
467
|
+
if method.upper() == "GET":
|
|
468
|
+
response = self.session.get(url, timeout=30)
|
|
469
|
+
elif method.upper() == "POST":
|
|
470
|
+
response = self.session.post(url, json=data, timeout=30)
|
|
471
|
+
elif method.upper() == "PATCH":
|
|
472
|
+
response = self.session.patch(url, json=data, timeout=30)
|
|
473
|
+
elif method.upper() == "DELETE":
|
|
474
|
+
response = self.session.delete(url, timeout=30)
|
|
475
|
+
else:
|
|
476
|
+
raise ValueError(f"Unsupported HTTP method: {method}")
|
|
477
|
+
|
|
478
|
+
if response.status_code >= 400:
|
|
479
|
+
raise Exception(f"Scaleway API error: {response.status_code} - {response.text}")
|
|
480
|
+
|
|
481
|
+
return response.json() if response.text and response.status_code not in [204] else {}
|
|
482
|
+
|
|
483
|
+
except requests.RequestException as e:
|
|
484
|
+
raise Exception(f"Network error: {e}")
|
|
485
|
+
|
|
486
|
+
# -------------------------------------------------------------------------
|
|
487
|
+
# Namespace Operations
|
|
488
|
+
# -------------------------------------------------------------------------
|
|
489
|
+
|
|
490
|
+
def list_namespaces(self) -> List[dict]:
|
|
491
|
+
"""List all function namespaces in the project."""
|
|
492
|
+
result = self._api_request("GET", f"/namespaces?project_id={self.project_id}")
|
|
493
|
+
return result.get('namespaces', [])
|
|
494
|
+
|
|
495
|
+
def get_namespace(self, namespace_id: str) -> dict:
|
|
496
|
+
"""Get a specific namespace by ID."""
|
|
497
|
+
return self._api_request("GET", f"/namespaces/{namespace_id}")
|
|
498
|
+
|
|
499
|
+
def find_namespace_by_name(self, name: str) -> Optional[dict]:
|
|
500
|
+
"""Find a namespace by name."""
|
|
501
|
+
namespaces = self.list_namespaces()
|
|
502
|
+
for ns in namespaces:
|
|
503
|
+
if ns.get('name') == name:
|
|
504
|
+
return ns
|
|
505
|
+
return None
|
|
506
|
+
|
|
507
|
+
def create_namespace(self, name: str, description: str = "",
|
|
508
|
+
environment_variables: dict = None,
|
|
509
|
+
secret_environment_variables: List[dict] = None) -> dict:
|
|
510
|
+
"""Create a new function namespace."""
|
|
511
|
+
logging.info(f"Creating Scaleway Functions namespace: {name}")
|
|
512
|
+
|
|
513
|
+
data = {
|
|
514
|
+
"name": name,
|
|
515
|
+
"project_id": self.project_id,
|
|
516
|
+
"description": description or f"Function namespace for {name}",
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if environment_variables:
|
|
520
|
+
data["environment_variables"] = environment_variables
|
|
521
|
+
|
|
522
|
+
if secret_environment_variables:
|
|
523
|
+
data["secret_environment_variables"] = secret_environment_variables
|
|
524
|
+
|
|
525
|
+
result = self._api_request("POST", "/namespaces", data)
|
|
526
|
+
logging.info(f"Namespace created. ID: {result.get('id')}")
|
|
527
|
+
return result
|
|
528
|
+
|
|
529
|
+
def update_namespace(self, namespace_id: str,
|
|
530
|
+
environment_variables: dict = None,
|
|
531
|
+
secret_environment_variables: List[dict] = None) -> dict:
|
|
532
|
+
"""Update a namespace's environment variables."""
|
|
533
|
+
data = {}
|
|
534
|
+
if environment_variables is not None:
|
|
535
|
+
data["environment_variables"] = environment_variables
|
|
536
|
+
if secret_environment_variables is not None:
|
|
537
|
+
data["secret_environment_variables"] = secret_environment_variables
|
|
538
|
+
|
|
539
|
+
return self._api_request("PATCH", f"/namespaces/{namespace_id}", data)
|
|
540
|
+
|
|
541
|
+
def delete_namespace(self, namespace_id: str) -> None:
|
|
542
|
+
"""Delete a namespace."""
|
|
543
|
+
self._api_request("DELETE", f"/namespaces/{namespace_id}")
|
|
544
|
+
|
|
545
|
+
# -------------------------------------------------------------------------
|
|
546
|
+
# Function Operations
|
|
547
|
+
# -------------------------------------------------------------------------
|
|
548
|
+
|
|
549
|
+
def list_functions(self, namespace_id: str) -> List[dict]:
|
|
550
|
+
"""List all functions in a namespace."""
|
|
551
|
+
result = self._api_request("GET", f"/functions?namespace_id={namespace_id}")
|
|
552
|
+
return result.get('functions', [])
|
|
553
|
+
|
|
554
|
+
def get_function(self, function_id: str) -> dict:
|
|
555
|
+
"""Get a specific function by ID."""
|
|
556
|
+
return self._api_request("GET", f"/functions/{function_id}")
|
|
557
|
+
|
|
558
|
+
def find_function_by_name(self, namespace_id: str, name: str) -> Optional[dict]:
|
|
559
|
+
"""Find a function by name within a namespace."""
|
|
560
|
+
functions = self.list_functions(namespace_id)
|
|
561
|
+
for fn in functions:
|
|
562
|
+
if fn.get('name') == name:
|
|
563
|
+
return fn
|
|
564
|
+
return None
|
|
565
|
+
|
|
566
|
+
def create_function(self, namespace_id: str, name: str,
|
|
567
|
+
runtime: str = 'node20',
|
|
568
|
+
handler: str = 'handler.handler',
|
|
569
|
+
min_scale: int = 0,
|
|
570
|
+
max_scale: int = 5,
|
|
571
|
+
memory_limit: int = 256,
|
|
572
|
+
timeout: str = '30s',
|
|
573
|
+
http_option: str = 'enabled',
|
|
574
|
+
privacy: str = 'public',
|
|
575
|
+
description: str = "",
|
|
576
|
+
environment_variables: dict = None,
|
|
577
|
+
secret_environment_variables: List[dict] = None) -> dict:
|
|
578
|
+
"""Create a new function."""
|
|
579
|
+
logging.info(f"Creating function: {name}")
|
|
580
|
+
|
|
581
|
+
data = {
|
|
582
|
+
"name": name,
|
|
583
|
+
"namespace_id": namespace_id,
|
|
584
|
+
"runtime": runtime,
|
|
585
|
+
"handler": handler,
|
|
586
|
+
"min_scale": min_scale,
|
|
587
|
+
"max_scale": max_scale,
|
|
588
|
+
"memory_limit": memory_limit,
|
|
589
|
+
"timeout": timeout,
|
|
590
|
+
"http_option": http_option,
|
|
591
|
+
"privacy": privacy,
|
|
592
|
+
"description": description or f"Function {name}",
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if environment_variables:
|
|
596
|
+
data["environment_variables"] = environment_variables
|
|
597
|
+
|
|
598
|
+
if secret_environment_variables:
|
|
599
|
+
data["secret_environment_variables"] = secret_environment_variables
|
|
600
|
+
|
|
601
|
+
result = self._api_request("POST", "/functions", data)
|
|
602
|
+
logging.info(f"Function created. ID: {result.get('id')}")
|
|
603
|
+
return result
|
|
604
|
+
|
|
605
|
+
def update_function(self, function_id: str, **kwargs) -> dict:
|
|
606
|
+
"""Update a function's configuration."""
|
|
607
|
+
return self._api_request("PATCH", f"/functions/{function_id}", kwargs)
|
|
608
|
+
|
|
609
|
+
def delete_function(self, function_id: str) -> None:
|
|
610
|
+
"""Delete a function."""
|
|
611
|
+
self._api_request("DELETE", f"/functions/{function_id}")
|
|
612
|
+
|
|
613
|
+
def get_function_upload_url(self, function_id: str, content_length: int) -> dict:
|
|
614
|
+
"""Get a presigned URL for uploading function code."""
|
|
615
|
+
return self._api_request("GET",
|
|
616
|
+
f"/functions/{function_id}/upload-url?content_length={content_length}")
|
|
617
|
+
|
|
618
|
+
def deploy_function(self, function_id: str) -> dict:
|
|
619
|
+
"""Deploy a function after uploading code."""
|
|
620
|
+
return self._api_request("POST", f"/functions/{function_id}/deploy")
|
|
621
|
+
|
|
622
|
+
# -------------------------------------------------------------------------
|
|
623
|
+
# Domain Operations
|
|
624
|
+
# -------------------------------------------------------------------------
|
|
625
|
+
|
|
626
|
+
def list_domains(self, function_id: str) -> List[dict]:
|
|
627
|
+
"""List custom domains for a function."""
|
|
628
|
+
result = self._api_request("GET", f"/domains?function_id={function_id}")
|
|
629
|
+
return result.get('domains', [])
|
|
630
|
+
|
|
631
|
+
def create_domain(self, function_id: str, hostname: str) -> dict:
|
|
632
|
+
"""Add a custom domain to a function."""
|
|
633
|
+
logging.info(f"Adding custom domain: {hostname}")
|
|
634
|
+
|
|
635
|
+
data = {
|
|
636
|
+
"function_id": function_id,
|
|
637
|
+
"hostname": hostname,
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
result = self._api_request("POST", "/domains", data)
|
|
641
|
+
logging.info(f"Domain added. ID: {result.get('id')}")
|
|
642
|
+
return result
|
|
643
|
+
|
|
644
|
+
def delete_domain(self, domain_id: str) -> None:
|
|
645
|
+
"""Remove a custom domain."""
|
|
646
|
+
self._api_request("DELETE", f"/domains/{domain_id}")
|
|
647
|
+
|
|
648
|
+
def get_domain_dns_record(self, domain_id: str) -> dict:
|
|
649
|
+
"""Get the DNS record needed for a custom domain."""
|
|
650
|
+
domain = self._api_request("GET", f"/domains/{domain_id}")
|
|
651
|
+
return {
|
|
652
|
+
"hostname": domain.get("hostname"),
|
|
653
|
+
"url": domain.get("url"),
|
|
654
|
+
"status": domain.get("status"),
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
# -------------------------------------------------------------------------
|
|
658
|
+
# Trigger Operations
|
|
659
|
+
# -------------------------------------------------------------------------
|
|
660
|
+
|
|
661
|
+
def list_triggers(self, function_id: str) -> List[dict]:
|
|
662
|
+
"""List triggers for a function."""
|
|
663
|
+
result = self._api_request("GET", f"/triggers?function_id={function_id}")
|
|
664
|
+
return result.get('triggers', [])
|
|
665
|
+
|
|
666
|
+
def create_cron_trigger(self, function_id: str, name: str,
|
|
667
|
+
schedule: str, args: dict = None) -> dict:
|
|
668
|
+
"""Create a cron trigger for a function."""
|
|
669
|
+
data = {
|
|
670
|
+
"name": name,
|
|
671
|
+
"function_id": function_id,
|
|
672
|
+
"schedule": schedule,
|
|
673
|
+
}
|
|
674
|
+
if args:
|
|
675
|
+
data["args"] = json.dumps(args)
|
|
676
|
+
|
|
677
|
+
return self._api_request("POST", "/triggers", data)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
# =============================================================================
|
|
681
|
+
# Setup Report
|
|
682
|
+
# =============================================================================
|
|
683
|
+
|
|
684
|
+
class SetupReport:
|
|
685
|
+
"""Track and report FaaS setup status."""
|
|
686
|
+
|
|
687
|
+
def __init__(self, name: str, region: str, domain: str = None):
|
|
688
|
+
self.timestamp = time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())
|
|
689
|
+
self.name = name
|
|
690
|
+
self.region = region
|
|
691
|
+
self.region_name = SCALEWAY_REGIONS.get(region, {}).get('name', region)
|
|
692
|
+
self.domain = domain
|
|
693
|
+
self.components = {
|
|
694
|
+
'namespace': {'status': 'pending', 'details': {}},
|
|
695
|
+
'function': {'status': 'pending', 'details': {}},
|
|
696
|
+
'domain': {'status': 'pending', 'details': {}},
|
|
697
|
+
'dns': {'status': 'pending', 'details': {}},
|
|
698
|
+
'ssl': {'status': 'pending', 'details': {}},
|
|
699
|
+
}
|
|
700
|
+
self.urls = {}
|
|
701
|
+
|
|
702
|
+
def set_component(self, name: str, status: str, **details):
|
|
703
|
+
if name in self.components:
|
|
704
|
+
self.components[name]['status'] = status
|
|
705
|
+
self.components[name]['details'].update(details)
|
|
706
|
+
|
|
707
|
+
def set_url(self, name: str, url: str):
|
|
708
|
+
self.urls[name] = url
|
|
709
|
+
|
|
710
|
+
def generate_markdown(self) -> str:
|
|
711
|
+
lines = [
|
|
712
|
+
f"# Scaleway FaaS Setup Report: {self.name}",
|
|
713
|
+
"",
|
|
714
|
+
f"**Generated:** {self.timestamp}",
|
|
715
|
+
"**Script:** setup_scaleway_faas.py",
|
|
716
|
+
"**Infrastructure:** Scaleway Functions",
|
|
717
|
+
"",
|
|
718
|
+
"---",
|
|
719
|
+
"",
|
|
720
|
+
"## Configuration Summary",
|
|
721
|
+
"",
|
|
722
|
+
"| Setting | Value |",
|
|
723
|
+
"|---------|-------|",
|
|
724
|
+
f"| Name | `{self.name}` |",
|
|
725
|
+
f"| Region | {self.region} ({self.region_name}) |",
|
|
726
|
+
]
|
|
727
|
+
|
|
728
|
+
if self.domain:
|
|
729
|
+
lines.append(f"| Custom Domain | `{self.domain}` |")
|
|
730
|
+
|
|
731
|
+
lines.extend([
|
|
732
|
+
"",
|
|
733
|
+
"## Component Status",
|
|
734
|
+
"",
|
|
735
|
+
])
|
|
736
|
+
|
|
737
|
+
status_icons = {
|
|
738
|
+
'created': '[NEW]', 'exists': '[OK]', 'configured': '[OK]',
|
|
739
|
+
'skipped': '[SKIP]', 'failed': '[FAIL]', 'pending': '[ ]',
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
component_names = {
|
|
743
|
+
'namespace': 'Functions Namespace',
|
|
744
|
+
'function': 'Serverless Function',
|
|
745
|
+
'domain': 'Custom Domain',
|
|
746
|
+
'dns': 'DNS Configuration',
|
|
747
|
+
'ssl': 'SSL Certificate',
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
for comp_key, comp_name in component_names.items():
|
|
751
|
+
comp = self.components[comp_key]
|
|
752
|
+
status = comp['status']
|
|
753
|
+
icon = status_icons.get(status, '[ ]')
|
|
754
|
+
lines.append(f"### {icon} {comp_name}")
|
|
755
|
+
lines.append("")
|
|
756
|
+
lines.append(f"**Status:** {status.replace('_', ' ').title()}")
|
|
757
|
+
|
|
758
|
+
for key, value in comp['details'].items():
|
|
759
|
+
display_key = key.replace('_', ' ').title()
|
|
760
|
+
if isinstance(value, list):
|
|
761
|
+
lines.append(f"- **{display_key}:**")
|
|
762
|
+
for item in value:
|
|
763
|
+
lines.append(f" - `{item}`")
|
|
764
|
+
else:
|
|
765
|
+
lines.append(f"- **{display_key}:** `{value}`")
|
|
766
|
+
lines.append("")
|
|
767
|
+
|
|
768
|
+
if self.urls:
|
|
769
|
+
lines.extend([
|
|
770
|
+
"## URLs and Endpoints",
|
|
771
|
+
"",
|
|
772
|
+
"| Name | URL |",
|
|
773
|
+
"|------|-----|",
|
|
774
|
+
])
|
|
775
|
+
for name, url in self.urls.items():
|
|
776
|
+
lines.append(f"| {name.replace('_', ' ').title()} | {url} |")
|
|
777
|
+
lines.append("")
|
|
778
|
+
|
|
779
|
+
lines.extend([
|
|
780
|
+
"## Deployment Commands",
|
|
781
|
+
"",
|
|
782
|
+
"```bash",
|
|
783
|
+
"# Deploy function code",
|
|
784
|
+
f"scw function deploy --namespace-id <NAMESPACE_ID> --name {self.name}",
|
|
785
|
+
"",
|
|
786
|
+
"# Or using serverless framework with scaleway plugin",
|
|
787
|
+
"serverless deploy --stage production",
|
|
788
|
+
"```",
|
|
789
|
+
"",
|
|
790
|
+
"---",
|
|
791
|
+
"",
|
|
792
|
+
"*Generated by setup_scaleway_faas.py*",
|
|
793
|
+
])
|
|
794
|
+
|
|
795
|
+
return '\n'.join(lines)
|
|
796
|
+
|
|
797
|
+
def save_report(self, output_dir: str = '.') -> str:
|
|
798
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
799
|
+
timestamp = time.strftime('%Y%m%d-%H%M%S', time.gmtime())
|
|
800
|
+
filename = f"scaleway-faas-setup-{self.name}-{timestamp}.md"
|
|
801
|
+
filepath = os.path.join(output_dir, filename)
|
|
802
|
+
|
|
803
|
+
with open(filepath, 'w', encoding='utf-8') as f:
|
|
804
|
+
f.write(self.generate_markdown())
|
|
805
|
+
|
|
806
|
+
logging.info(f"Setup report saved to: {filepath}")
|
|
807
|
+
return filepath
|
|
808
|
+
|
|
809
|
+
def write_deploy_env(self, env_file: str = '.deploy.env') -> bool:
|
|
810
|
+
"""Write Scaleway configuration to .deploy.env file.
|
|
811
|
+
|
|
812
|
+
Appends or updates SCW_FUNCTION_NAMESPACE_ID, SCW_FUNCTION_ID, and SCW_REGION.
|
|
813
|
+
"""
|
|
814
|
+
namespace_id = self.components['namespace']['details'].get('namespace_id')
|
|
815
|
+
function_id = self.components['function']['details'].get('function_id')
|
|
816
|
+
|
|
817
|
+
if not namespace_id:
|
|
818
|
+
logging.warning("No namespace ID to write to deploy env")
|
|
819
|
+
return False
|
|
820
|
+
|
|
821
|
+
# Read existing content
|
|
822
|
+
existing_content = ""
|
|
823
|
+
existing_vars = {}
|
|
824
|
+
if os.path.exists(env_file):
|
|
825
|
+
with open(env_file, 'r') as f:
|
|
826
|
+
existing_content = f.read()
|
|
827
|
+
for line in existing_content.split('\n'):
|
|
828
|
+
if '=' in line and not line.strip().startswith('#'):
|
|
829
|
+
key = line.split('=')[0].strip()
|
|
830
|
+
existing_vars[key] = True
|
|
831
|
+
|
|
832
|
+
# Prepare new variables
|
|
833
|
+
new_vars = []
|
|
834
|
+
|
|
835
|
+
# Add header comment if file is empty or doesn't exist
|
|
836
|
+
if not existing_content.strip():
|
|
837
|
+
new_vars.append("# Scaleway Functions Configuration")
|
|
838
|
+
new_vars.append(f"# Generated by setup_scaleway_faas.py at {self.timestamp}")
|
|
839
|
+
new_vars.append("")
|
|
840
|
+
|
|
841
|
+
# Add/update variables
|
|
842
|
+
scw_vars = {
|
|
843
|
+
'SCW_FUNCTION_NAMESPACE_ID': namespace_id,
|
|
844
|
+
'SCW_REGION': self.region,
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
if function_id:
|
|
848
|
+
scw_vars['SCW_FUNCTION_ID'] = function_id
|
|
849
|
+
|
|
850
|
+
lines_to_add = []
|
|
851
|
+
for key, value in scw_vars.items():
|
|
852
|
+
if key in existing_vars:
|
|
853
|
+
# Update existing variable
|
|
854
|
+
import re
|
|
855
|
+
pattern = rf'^{key}=.*$'
|
|
856
|
+
existing_content = re.sub(pattern, f'{key}={value}', existing_content, flags=re.MULTILINE)
|
|
857
|
+
logging.info(f"Updated {key} in {env_file}")
|
|
858
|
+
else:
|
|
859
|
+
lines_to_add.append(f"{key}={value}")
|
|
860
|
+
|
|
861
|
+
# Write file
|
|
862
|
+
with open(env_file, 'w') as f:
|
|
863
|
+
# Write updated content
|
|
864
|
+
if existing_content.strip():
|
|
865
|
+
f.write(existing_content)
|
|
866
|
+
if not existing_content.endswith('\n'):
|
|
867
|
+
f.write('\n')
|
|
868
|
+
|
|
869
|
+
# Add new variables
|
|
870
|
+
if lines_to_add:
|
|
871
|
+
if existing_content.strip():
|
|
872
|
+
f.write('\n# Scaleway Functions (auto-generated)\n')
|
|
873
|
+
for line in lines_to_add:
|
|
874
|
+
f.write(f"{line}\n")
|
|
875
|
+
key = line.split('=')[0]
|
|
876
|
+
logging.info(f"Added {key} to {env_file}")
|
|
877
|
+
|
|
878
|
+
logging.info(f"[OK] Deploy env written to: {env_file}")
|
|
879
|
+
return True
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
# =============================================================================
|
|
883
|
+
# Main Setup
|
|
884
|
+
# =============================================================================
|
|
885
|
+
|
|
886
|
+
def parse_arguments():
|
|
887
|
+
parser = argparse.ArgumentParser(
|
|
888
|
+
description='Setup Scaleway Functions (FaaS)',
|
|
889
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
890
|
+
epilog="""
|
|
891
|
+
Examples:
|
|
892
|
+
python setup_scaleway_faas.py --name m3rp-backend --domain api.m3rp.ai
|
|
893
|
+
python setup_scaleway_faas.py --name m3rp-backend --region nl-ams
|
|
894
|
+
python setup_scaleway_faas.py --name m3rp-backend --setup-domain --dns-provider cloudflare
|
|
895
|
+
|
|
896
|
+
Environment Variables:
|
|
897
|
+
SCW_ACCESS_KEY: Scaleway access key
|
|
898
|
+
SCW_SECRET_KEY: Scaleway secret key
|
|
899
|
+
SCW_DEFAULT_PROJECT_ID: Scaleway project ID
|
|
900
|
+
CLOUDFLARE_API_TOKEN: For Cloudflare DNS
|
|
901
|
+
DESEC_API_TOKEN: For deSEC DNS
|
|
902
|
+
BUNNY_API_KEY: For Bunny.net DNS
|
|
903
|
+
"""
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
parser.add_argument('--name', required=True, help='Function/namespace name')
|
|
907
|
+
parser.add_argument('--domain', help='Custom domain (e.g., api.m3rp.ai)')
|
|
908
|
+
parser.add_argument('--region', default='fr-par', choices=list(SCALEWAY_REGIONS.keys()),
|
|
909
|
+
help='Scaleway region (default: fr-par)')
|
|
910
|
+
parser.add_argument('--runtime', default='node20', choices=list(SCALEWAY_RUNTIMES.keys()),
|
|
911
|
+
help='Function runtime (default: node20)')
|
|
912
|
+
parser.add_argument('--handler', default='handler.handler',
|
|
913
|
+
help='Function handler (default: handler.handler)')
|
|
914
|
+
parser.add_argument('--memory', type=int, default=512,
|
|
915
|
+
help='Memory limit in MB (default: 512)')
|
|
916
|
+
parser.add_argument('--timeout', default='30s',
|
|
917
|
+
help='Function timeout (default: 30s)')
|
|
918
|
+
parser.add_argument('--min-scale', type=int, default=0,
|
|
919
|
+
help='Minimum instances (default: 0)')
|
|
920
|
+
parser.add_argument('--max-scale', type=int, default=5,
|
|
921
|
+
help='Maximum instances (default: 5)')
|
|
922
|
+
parser.add_argument('--setup-domain', action='store_true',
|
|
923
|
+
help='Configure custom domain with DNS')
|
|
924
|
+
parser.add_argument('--dns-provider', choices=['cloudflare', 'desec', 'cloudns', 'bunny'],
|
|
925
|
+
default='cloudflare', help='DNS provider (default: cloudflare)')
|
|
926
|
+
parser.add_argument('--namespace-only', action='store_true',
|
|
927
|
+
help='Only create namespace, no function')
|
|
928
|
+
parser.add_argument('--env', action='append', metavar='KEY=VALUE',
|
|
929
|
+
help='Environment variable (can be used multiple times)')
|
|
930
|
+
parser.add_argument('--secret', action='append', metavar='KEY=VALUE',
|
|
931
|
+
help='Secret environment variable (can be used multiple times)')
|
|
932
|
+
parser.add_argument('--report-dir', default='.', help='Directory to save the setup report')
|
|
933
|
+
parser.add_argument('--deploy-env', default='.deploy.env',
|
|
934
|
+
help='Path to .deploy.env file (default: .deploy.env)')
|
|
935
|
+
parser.add_argument('--no-deploy-env', action='store_true',
|
|
936
|
+
help='Skip writing to .deploy.env file')
|
|
937
|
+
|
|
938
|
+
return parser.parse_args()
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def parse_env_vars(env_list: List[str]) -> dict:
|
|
942
|
+
"""Parse KEY=VALUE environment variable arguments."""
|
|
943
|
+
if not env_list:
|
|
944
|
+
return {}
|
|
945
|
+
|
|
946
|
+
result = {}
|
|
947
|
+
for item in env_list:
|
|
948
|
+
if '=' in item:
|
|
949
|
+
key, value = item.split('=', 1)
|
|
950
|
+
result[key] = value
|
|
951
|
+
return result
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def parse_secret_vars(secret_list: List[str]) -> List[dict]:
|
|
955
|
+
"""Parse KEY=VALUE secret arguments into Scaleway format."""
|
|
956
|
+
if not secret_list:
|
|
957
|
+
return []
|
|
958
|
+
|
|
959
|
+
result = []
|
|
960
|
+
for item in secret_list:
|
|
961
|
+
if '=' in item:
|
|
962
|
+
key, value = item.split('=', 1)
|
|
963
|
+
result.append({"key": key, "value": value})
|
|
964
|
+
return result
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
def main():
|
|
968
|
+
args = parse_arguments()
|
|
969
|
+
|
|
970
|
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
|
971
|
+
|
|
972
|
+
# Initialize report
|
|
973
|
+
report = SetupReport(name=args.name, region=args.region, domain=args.domain)
|
|
974
|
+
|
|
975
|
+
logging.info("=" * 50)
|
|
976
|
+
logging.info("SCALEWAY FUNCTIONS SETUP")
|
|
977
|
+
logging.info("=" * 50)
|
|
978
|
+
logging.info(f"Name: {args.name}")
|
|
979
|
+
logging.info(f"Region: {args.region} ({SCALEWAY_REGIONS[args.region]['name']})")
|
|
980
|
+
if args.domain:
|
|
981
|
+
logging.info(f"Custom Domain: {args.domain}")
|
|
982
|
+
logging.info(f"Runtime: {args.runtime}")
|
|
983
|
+
logging.info(f"Memory: {args.memory}MB")
|
|
984
|
+
logging.info("=" * 50)
|
|
985
|
+
|
|
986
|
+
try:
|
|
987
|
+
# Initialize client
|
|
988
|
+
client = ScalewayFunctionsClient(region=args.region)
|
|
989
|
+
|
|
990
|
+
# Parse environment variables
|
|
991
|
+
env_vars = parse_env_vars(args.env)
|
|
992
|
+
secret_vars = parse_secret_vars(args.secret)
|
|
993
|
+
|
|
994
|
+
# 1. Create or get namespace
|
|
995
|
+
logging.info("\n--- Step 1: Functions Namespace ---")
|
|
996
|
+
existing_ns = client.find_namespace_by_name(args.name)
|
|
997
|
+
|
|
998
|
+
if existing_ns:
|
|
999
|
+
logging.info(f"Namespace '{args.name}' already exists.")
|
|
1000
|
+
namespace = existing_ns
|
|
1001
|
+
report.set_component('namespace', 'exists',
|
|
1002
|
+
namespace_id=namespace['id'],
|
|
1003
|
+
namespace_name=args.name)
|
|
1004
|
+
else:
|
|
1005
|
+
namespace = client.create_namespace(
|
|
1006
|
+
name=args.name,
|
|
1007
|
+
environment_variables=env_vars,
|
|
1008
|
+
secret_environment_variables=secret_vars if secret_vars else None
|
|
1009
|
+
)
|
|
1010
|
+
report.set_component('namespace', 'created',
|
|
1011
|
+
namespace_id=namespace['id'],
|
|
1012
|
+
namespace_name=args.name)
|
|
1013
|
+
|
|
1014
|
+
namespace_id = namespace['id']
|
|
1015
|
+
logging.info(f"Namespace ID: {namespace_id}")
|
|
1016
|
+
|
|
1017
|
+
# Wait for namespace to be ready
|
|
1018
|
+
logging.info("Waiting for namespace to be ready...")
|
|
1019
|
+
for _ in range(30):
|
|
1020
|
+
ns_status = client.get_namespace(namespace_id)
|
|
1021
|
+
if ns_status.get('status') == 'ready':
|
|
1022
|
+
break
|
|
1023
|
+
time.sleep(2)
|
|
1024
|
+
|
|
1025
|
+
report.set_url('namespace_url', namespace.get('registry_endpoint', ''))
|
|
1026
|
+
|
|
1027
|
+
if args.namespace_only:
|
|
1028
|
+
logging.info("Namespace-only mode, skipping function creation.")
|
|
1029
|
+
report.set_component('function', 'skipped')
|
|
1030
|
+
else:
|
|
1031
|
+
# 2. Create function
|
|
1032
|
+
logging.info("\n--- Step 2: Serverless Function ---")
|
|
1033
|
+
function_name = f"{args.name}-api"
|
|
1034
|
+
existing_fn = client.find_function_by_name(namespace_id, function_name)
|
|
1035
|
+
|
|
1036
|
+
if existing_fn:
|
|
1037
|
+
logging.info(f"Function '{function_name}' already exists.")
|
|
1038
|
+
function = existing_fn
|
|
1039
|
+
report.set_component('function', 'exists',
|
|
1040
|
+
function_id=function['id'],
|
|
1041
|
+
function_name=function_name)
|
|
1042
|
+
else:
|
|
1043
|
+
function = client.create_function(
|
|
1044
|
+
namespace_id=namespace_id,
|
|
1045
|
+
name=function_name,
|
|
1046
|
+
runtime=args.runtime,
|
|
1047
|
+
handler=args.handler,
|
|
1048
|
+
min_scale=args.min_scale,
|
|
1049
|
+
max_scale=args.max_scale,
|
|
1050
|
+
memory_limit=args.memory,
|
|
1051
|
+
timeout=args.timeout,
|
|
1052
|
+
environment_variables=env_vars,
|
|
1053
|
+
secret_environment_variables=secret_vars if secret_vars else None
|
|
1054
|
+
)
|
|
1055
|
+
report.set_component('function', 'created',
|
|
1056
|
+
function_id=function['id'],
|
|
1057
|
+
function_name=function_name,
|
|
1058
|
+
runtime=args.runtime,
|
|
1059
|
+
memory=f"{args.memory}MB")
|
|
1060
|
+
|
|
1061
|
+
function_id = function['id']
|
|
1062
|
+
function_url = function.get('domain_name', '')
|
|
1063
|
+
|
|
1064
|
+
logging.info(f"Function ID: {function_id}")
|
|
1065
|
+
logging.info(f"Function URL: https://{function_url}")
|
|
1066
|
+
|
|
1067
|
+
report.set_url('function_url', f"https://{function_url}")
|
|
1068
|
+
|
|
1069
|
+
# 3. Configure custom domain
|
|
1070
|
+
if args.domain:
|
|
1071
|
+
logging.info("\n--- Step 3: Custom Domain ---")
|
|
1072
|
+
|
|
1073
|
+
existing_domains = client.list_domains(function_id)
|
|
1074
|
+
domain_exists = any(d.get('hostname') == args.domain for d in existing_domains)
|
|
1075
|
+
|
|
1076
|
+
if domain_exists:
|
|
1077
|
+
logging.info(f"Domain '{args.domain}' already configured.")
|
|
1078
|
+
report.set_component('domain', 'exists', hostname=args.domain)
|
|
1079
|
+
else:
|
|
1080
|
+
domain_result = client.create_domain(function_id, args.domain)
|
|
1081
|
+
report.set_component('domain', 'created',
|
|
1082
|
+
hostname=args.domain,
|
|
1083
|
+
domain_id=domain_result.get('id'))
|
|
1084
|
+
|
|
1085
|
+
report.set_url('custom_domain', f"https://{args.domain}")
|
|
1086
|
+
|
|
1087
|
+
# 4. Configure DNS
|
|
1088
|
+
if args.setup_domain:
|
|
1089
|
+
logging.info("\n--- Step 4: DNS Configuration ---")
|
|
1090
|
+
|
|
1091
|
+
# Extract base domain
|
|
1092
|
+
domain_parts = args.domain.split('.')
|
|
1093
|
+
if len(domain_parts) > 2:
|
|
1094
|
+
base_domain = '.'.join(domain_parts[-2:])
|
|
1095
|
+
subdomain = '.'.join(domain_parts[:-2])
|
|
1096
|
+
else:
|
|
1097
|
+
base_domain = args.domain
|
|
1098
|
+
subdomain = '@'
|
|
1099
|
+
|
|
1100
|
+
dns_provider = get_dns_provider(args.dns_provider)
|
|
1101
|
+
zone_id = dns_provider.get_zone_id(base_domain)
|
|
1102
|
+
|
|
1103
|
+
# Create CNAME to Scaleway function URL
|
|
1104
|
+
dns_provider.create_cname_record(
|
|
1105
|
+
zone_id, subdomain, function_url, base_domain
|
|
1106
|
+
)
|
|
1107
|
+
|
|
1108
|
+
report.set_component('dns', 'configured',
|
|
1109
|
+
provider=args.dns_provider,
|
|
1110
|
+
record=f"CNAME {args.domain} -> {function_url}")
|
|
1111
|
+
|
|
1112
|
+
# SSL is automatic with Scaleway
|
|
1113
|
+
report.set_component('ssl', 'configured',
|
|
1114
|
+
note="Automatic SSL via Scaleway")
|
|
1115
|
+
else:
|
|
1116
|
+
report.set_component('dns', 'skipped',
|
|
1117
|
+
note="Use --setup-domain to configure DNS")
|
|
1118
|
+
report.set_component('ssl', 'skipped')
|
|
1119
|
+
else:
|
|
1120
|
+
report.set_component('domain', 'skipped')
|
|
1121
|
+
report.set_component('dns', 'skipped')
|
|
1122
|
+
report.set_component('ssl', 'skipped')
|
|
1123
|
+
|
|
1124
|
+
# Summary
|
|
1125
|
+
logging.info("\n" + "=" * 50)
|
|
1126
|
+
logging.info("SCALEWAY FAAS SETUP COMPLETE")
|
|
1127
|
+
logging.info("=" * 50)
|
|
1128
|
+
logging.info(f"Namespace: {args.name} (ID: {namespace_id})")
|
|
1129
|
+
if not args.namespace_only:
|
|
1130
|
+
logging.info(f"Function URL: https://{function_url}")
|
|
1131
|
+
if args.domain:
|
|
1132
|
+
logging.info(f"Custom Domain: https://{args.domain}")
|
|
1133
|
+
logging.info("=" * 50)
|
|
1134
|
+
|
|
1135
|
+
# Save report
|
|
1136
|
+
report_path = report.save_report(args.report_dir)
|
|
1137
|
+
|
|
1138
|
+
# Write to .deploy.env
|
|
1139
|
+
if not args.no_deploy_env:
|
|
1140
|
+
report.write_deploy_env(args.deploy_env)
|
|
1141
|
+
|
|
1142
|
+
# Print next steps
|
|
1143
|
+
logging.info("\n[Next Steps]")
|
|
1144
|
+
logging.info("1. Deploy your function code using the Scaleway CLI or API")
|
|
1145
|
+
logging.info("2. Or configure serverless-scaleway-functions plugin")
|
|
1146
|
+
logging.info(f"\n[OK] Setup report: {report_path}")
|
|
1147
|
+
|
|
1148
|
+
return 0
|
|
1149
|
+
|
|
1150
|
+
except Exception as e:
|
|
1151
|
+
logging.error(f"An error occurred: {e}")
|
|
1152
|
+
import traceback
|
|
1153
|
+
traceback.print_exc()
|
|
1154
|
+
|
|
1155
|
+
try:
|
|
1156
|
+
report_path = report.save_report(args.report_dir)
|
|
1157
|
+
logging.info(f"[i] Partial report saved: {report_path}")
|
|
1158
|
+
except Exception:
|
|
1159
|
+
pass
|
|
1160
|
+
|
|
1161
|
+
return 1
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
if __name__ == "__main__":
|
|
1165
|
+
exit(main())
|