granny-devops 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. granny/__init__.py +19 -0
  2. granny/analyze/__init__.py +6 -0
  3. granny/analyze/lambdas.py +59 -0
  4. granny/analyze/vpcs.py +57 -0
  5. granny/cdn/__init__.py +9 -0
  6. granny/cdn/bunny.py +231 -0
  7. granny/cli/__init__.py +0 -0
  8. granny/cli/analyze.py +66 -0
  9. granny/cli/cdn.py +210 -0
  10. granny/cli/create.py +94 -0
  11. granny/cli/credentials.py +99 -0
  12. granny/cli/dns.py +290 -0
  13. granny/cli/docker.py +165 -0
  14. granny/cli/edge.py +106 -0
  15. granny/cli/email.py +224 -0
  16. granny/cli/main.py +98 -0
  17. granny/cli/serverless.py +278 -0
  18. granny/cli/storage.py +249 -0
  19. granny/create/__init__.py +4 -0
  20. granny/create/auto_certificate.py +1899 -0
  21. granny/create/cloudfront-security-headers.js +53 -0
  22. granny/create/manage-dns.sh +321 -0
  23. granny/create/manage_mailjet_contacts.py +619 -0
  24. granny/create/registrars.py +363 -0
  25. granny/create/setup_aws_cloudfront.py +2808 -0
  26. granny/create/setup_bunny_edge_script.py +923 -0
  27. granny/create/setup_bunny_storage.py +1719 -0
  28. granny/create/setup_cognito_identity_pool.py +740 -0
  29. granny/create/setup_hetzner_bunny.py +1482 -0
  30. granny/create/setup_mailjet_dns.py +1103 -0
  31. granny/create/setup_private_cdn.py +547 -0
  32. granny/create/setup_s3_website.py +1512 -0
  33. granny/create/setup_scaleway_faas.py +1165 -0
  34. granny/create/setup_workmail.py +1217 -0
  35. granny/create/www-redirect-function.js +17 -0
  36. granny/credentials/__init__.py +15 -0
  37. granny/credentials/secrets.py +403 -0
  38. granny/dns/__init__.py +22 -0
  39. granny/dns/base.py +113 -0
  40. granny/dns/bunny.py +150 -0
  41. granny/dns/cloudflare.py +192 -0
  42. granny/dns/cloudns.py +162 -0
  43. granny/dns/desec.py +152 -0
  44. granny/dns/factory.py +72 -0
  45. granny/dns/hetzner.py +165 -0
  46. granny/dns/manual.py +64 -0
  47. granny/dns/records.py +29 -0
  48. granny/docker/__init__.py +5 -0
  49. granny/docker/build_base.py +204 -0
  50. granny/edge/__init__.py +5 -0
  51. granny/edge/bunny.py +147 -0
  52. granny/email/__init__.py +7 -0
  53. granny/email/mailjet.py +119 -0
  54. granny/email/mailjet_contacts.py +115 -0
  55. granny/email/ses_forwarding.py +281 -0
  56. granny/email/workmail.py +145 -0
  57. granny/report.py +128 -0
  58. granny/serverless/__init__.py +5 -0
  59. granny/serverless/scaleway.py +264 -0
  60. granny/storage/__init__.py +7 -0
  61. granny/storage/aws.py +113 -0
  62. granny/storage/bunny.py +98 -0
  63. granny/storage/hetzner.py +118 -0
  64. granny_devops-0.4.0.dist-info/METADATA +445 -0
  65. granny_devops-0.4.0.dist-info/RECORD +68 -0
  66. granny_devops-0.4.0.dist-info/WHEEL +4 -0
  67. granny_devops-0.4.0.dist-info/entry_points.txt +2 -0
  68. granny_devops-0.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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())