granny-devops 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. granny/__init__.py +19 -0
  2. granny/analyze/__init__.py +6 -0
  3. granny/analyze/lambdas.py +59 -0
  4. granny/analyze/vpcs.py +57 -0
  5. granny/cdn/__init__.py +9 -0
  6. granny/cdn/bunny.py +231 -0
  7. granny/cli/__init__.py +0 -0
  8. granny/cli/analyze.py +66 -0
  9. granny/cli/cdn.py +210 -0
  10. granny/cli/create.py +94 -0
  11. granny/cli/credentials.py +99 -0
  12. granny/cli/dns.py +290 -0
  13. granny/cli/docker.py +165 -0
  14. granny/cli/edge.py +106 -0
  15. granny/cli/email.py +224 -0
  16. granny/cli/main.py +98 -0
  17. granny/cli/serverless.py +278 -0
  18. granny/cli/storage.py +249 -0
  19. granny/create/__init__.py +4 -0
  20. granny/create/auto_certificate.py +1899 -0
  21. granny/create/cloudfront-security-headers.js +53 -0
  22. granny/create/manage-dns.sh +321 -0
  23. granny/create/manage_mailjet_contacts.py +619 -0
  24. granny/create/registrars.py +363 -0
  25. granny/create/setup_aws_cloudfront.py +2808 -0
  26. granny/create/setup_bunny_edge_script.py +923 -0
  27. granny/create/setup_bunny_storage.py +1719 -0
  28. granny/create/setup_cognito_identity_pool.py +740 -0
  29. granny/create/setup_hetzner_bunny.py +1482 -0
  30. granny/create/setup_mailjet_dns.py +1103 -0
  31. granny/create/setup_private_cdn.py +547 -0
  32. granny/create/setup_s3_website.py +1512 -0
  33. granny/create/setup_scaleway_faas.py +1165 -0
  34. granny/create/setup_workmail.py +1217 -0
  35. granny/create/www-redirect-function.js +17 -0
  36. granny/credentials/__init__.py +15 -0
  37. granny/credentials/secrets.py +403 -0
  38. granny/dns/__init__.py +22 -0
  39. granny/dns/base.py +113 -0
  40. granny/dns/bunny.py +150 -0
  41. granny/dns/cloudflare.py +192 -0
  42. granny/dns/cloudns.py +162 -0
  43. granny/dns/desec.py +152 -0
  44. granny/dns/factory.py +72 -0
  45. granny/dns/hetzner.py +165 -0
  46. granny/dns/manual.py +64 -0
  47. granny/dns/records.py +29 -0
  48. granny/docker/__init__.py +5 -0
  49. granny/docker/build_base.py +204 -0
  50. granny/edge/__init__.py +5 -0
  51. granny/edge/bunny.py +147 -0
  52. granny/email/__init__.py +7 -0
  53. granny/email/mailjet.py +119 -0
  54. granny/email/mailjet_contacts.py +115 -0
  55. granny/email/ses_forwarding.py +281 -0
  56. granny/email/workmail.py +145 -0
  57. granny/report.py +128 -0
  58. granny/serverless/__init__.py +5 -0
  59. granny/serverless/scaleway.py +264 -0
  60. granny/storage/__init__.py +7 -0
  61. granny/storage/aws.py +113 -0
  62. granny/storage/bunny.py +98 -0
  63. granny/storage/hetzner.py +118 -0
  64. granny_devops-0.4.0.dist-info/METADATA +445 -0
  65. granny_devops-0.4.0.dist-info/RECORD +68 -0
  66. granny_devops-0.4.0.dist-info/WHEEL +4 -0
  67. granny_devops-0.4.0.dist-info/entry_points.txt +2 -0
  68. granny_devops-0.4.0.dist-info/licenses/LICENSE +21 -0
granny/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """Granny -- Cloud tools collection for AWS infrastructure and DevOps automation."""
2
+
3
+ __version__ = "0.4.0"
4
+ __all__ = [
5
+ "get_secret",
6
+ "load_secrets_into_env",
7
+ ]
8
+
9
+
10
+ def __getattr__(name: str):
11
+ if name == "get_secret":
12
+ from granny.credentials.secrets import get_secret
13
+
14
+ return get_secret
15
+ if name == "load_secrets_into_env":
16
+ from granny.credentials.secrets import load_secrets_into_env
17
+
18
+ return load_secrets_into_env
19
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,6 @@
1
+ """AWS resource analysis and inventory."""
2
+
3
+ from granny.analyze.lambdas import get_all_regions, list_lambdas
4
+ from granny.analyze.vpcs import list_vpcs
5
+
6
+ __all__ = ["get_all_regions", "list_lambdas", "list_vpcs"]
@@ -0,0 +1,59 @@
1
+ """List AWS Lambda functions across regions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from dataclasses import dataclass
7
+
8
+ import boto3
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ @dataclass
14
+ class LambdaFunction:
15
+ name: str
16
+ runtime: str
17
+ version: str
18
+ region: str
19
+
20
+
21
+ def get_all_regions() -> list[str]:
22
+ """Return all available AWS region names."""
23
+ ec2 = boto3.client("ec2")
24
+ response = ec2.describe_regions()
25
+ return [r["RegionName"] for r in response["Regions"]]
26
+
27
+
28
+ def list_lambdas(
29
+ regions: list[str] | None = None,
30
+ ) -> list[LambdaFunction]:
31
+ """List Lambda functions, optionally filtered by region.
32
+
33
+ Args:
34
+ regions: Specific regions to query. Defaults to all regions.
35
+
36
+ Returns:
37
+ List of LambdaFunction objects.
38
+ """
39
+ if regions is None:
40
+ regions = get_all_regions()
41
+
42
+ results: list[LambdaFunction] = []
43
+ for region in regions:
44
+ try:
45
+ client = boto3.client("lambda", region_name=region)
46
+ paginator = client.get_paginator("list_functions")
47
+ for page in paginator.paginate():
48
+ for fn in page.get("Functions", []):
49
+ results.append(
50
+ LambdaFunction(
51
+ name=fn["FunctionName"],
52
+ runtime=fn.get("Runtime", "N/A"),
53
+ version=fn.get("Version", "N/A"),
54
+ region=region,
55
+ )
56
+ )
57
+ except Exception as e:
58
+ logger.warning("Error listing lambdas in %s: %s", region, e)
59
+ return results
granny/analyze/vpcs.py ADDED
@@ -0,0 +1,57 @@
1
+ """List AWS VPCs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from dataclasses import dataclass
7
+
8
+ import boto3
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ @dataclass
14
+ class VpcInfo:
15
+ vpc_id: str
16
+ cidr_block: str
17
+ name: str
18
+ is_default: bool
19
+ region: str
20
+
21
+
22
+ def list_vpcs(regions: list[str] | None = None) -> list[VpcInfo]:
23
+ """List all VPCs, optionally filtered by region.
24
+
25
+ Args:
26
+ regions: Specific regions to query. Defaults to the session's default region.
27
+
28
+ Returns:
29
+ List of VpcInfo objects.
30
+ """
31
+ if regions is None:
32
+ regions = [boto3.session.Session().region_name or "us-east-1"]
33
+
34
+ results: list[VpcInfo] = []
35
+ for region in regions:
36
+ try:
37
+ ec2 = boto3.client("ec2", region_name=region)
38
+ paginator = ec2.get_paginator("describe_vpcs")
39
+ for page in paginator.paginate():
40
+ for vpc in page["Vpcs"]:
41
+ name = "N/A"
42
+ for tag in vpc.get("Tags", []):
43
+ if tag["Key"] == "Name":
44
+ name = tag["Value"]
45
+ break
46
+ results.append(
47
+ VpcInfo(
48
+ vpc_id=vpc["VpcId"],
49
+ cidr_block=vpc["CidrBlock"],
50
+ name=name,
51
+ is_default=vpc.get("IsDefault", False),
52
+ region=region,
53
+ )
54
+ )
55
+ except Exception as e:
56
+ logger.warning("Error listing VPCs in %s: %s", region, e)
57
+ return results
granny/cdn/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """CDN provider clients — Bunny CDN pull zone management.
2
+
3
+ CloudFront support is deferred to a future PR (the setup_aws_cloudfront.py
4
+ script is 2800 lines and works as-is).
5
+ """
6
+
7
+ from granny.cdn.bunny import BunnyCDNClient
8
+
9
+ __all__ = ["BunnyCDNClient"]
granny/cdn/bunny.py ADDED
@@ -0,0 +1,231 @@
1
+ """Bunny CDN pull zone client — unified from 4 setup scripts.
2
+
3
+ Merged superset of ``setup_hetzner_bunny.py:BunnyCDNClient`` (token auth,
4
+ origin shield) and ``setup_bunny_storage.py:BunnyCDNClient`` (storage-zone
5
+ pull zones, SPA routing, edge rules). The existing scripts keep their local
6
+ copies; this module is the shared home.
7
+ """
8
+
9
+ import logging
10
+ from typing import Any
11
+
12
+ import requests
13
+
14
+ from granny.credentials.secrets import get_bunny_api_key
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ API_BASE = "https://api.bunny.net"
19
+
20
+
21
+ class BunnyCDNClient:
22
+ """Bunny CDN pull zone management.
23
+
24
+ Covers pull zone CRUD, custom hostnames, SSL certificates, edge rules,
25
+ token authentication, origin shield, and cache purging.
26
+ """
27
+
28
+ def __init__(self, api_key: str | None = None, customer: str | None = None) -> None:
29
+ api_key = api_key or get_bunny_api_key(customer)
30
+ if not api_key:
31
+ label = f"BUNNY_API_KEY_{customer.upper()}" if customer else "BUNNY_API_KEY"
32
+ raise ValueError(f"{label} not set (env or vault).")
33
+ self.session = requests.Session()
34
+ self.session.headers.update(
35
+ {"AccessKey": api_key, "Content-Type": "application/json"}
36
+ )
37
+
38
+ def _request(self, method: str, path: str, data: dict | None = None) -> Any:
39
+ url = f"{API_BASE}{path}"
40
+ resp = getattr(self.session, method.lower())(url, json=data, timeout=30)
41
+ if resp.status_code >= 400:
42
+ raise RuntimeError(
43
+ f"Bunny CDN {method} {path}: {resp.status_code} {resp.text}"
44
+ )
45
+ if resp.status_code == 204 or not resp.text:
46
+ return {}
47
+ return resp.json()
48
+
49
+ # -- Pull Zone CRUD --------------------------------------------------------
50
+
51
+ def list_pull_zones(self) -> list[dict]:
52
+ return self._request("GET", "/pullzone")
53
+
54
+ def find_pull_zone(
55
+ self, *, name: str | None = None, hostname: str | None = None
56
+ ) -> dict | None:
57
+ """Find a pull zone by name or by a registered hostname."""
58
+ for zone in self.list_pull_zones():
59
+ if name and zone.get("Name") == name:
60
+ return zone
61
+ if hostname:
62
+ for h in zone.get("Hostnames", []):
63
+ if h.get("Value") == hostname:
64
+ return zone
65
+ return None
66
+
67
+ def get_pull_zone(self, pull_zone_id: int) -> dict:
68
+ return self._request("GET", f"/pullzone/{pull_zone_id}")
69
+
70
+ def create_pull_zone(
71
+ self,
72
+ name: str,
73
+ *,
74
+ origin_url: str | None = None,
75
+ storage_zone_id: int | None = None,
76
+ enable_geo_zones: bool = True,
77
+ ) -> dict:
78
+ """Create a pull zone.
79
+
80
+ Pass ``origin_url`` for an S3/HTTP origin, or ``storage_zone_id``
81
+ for a Bunny Storage-backed pull zone. Exactly one must be provided.
82
+ """
83
+ if origin_url and storage_zone_id:
84
+ raise ValueError("Pass origin_url OR storage_zone_id, not both.")
85
+ if not origin_url and not storage_zone_id:
86
+ raise ValueError("Either origin_url or storage_zone_id is required.")
87
+
88
+ data: dict[str, Any] = {
89
+ "Name": name,
90
+ "EnableGeoZoneUS": enable_geo_zones,
91
+ "EnableGeoZoneEU": enable_geo_zones,
92
+ "EnableGeoZoneASIA": enable_geo_zones,
93
+ "EnableGeoZoneSA": enable_geo_zones,
94
+ "EnableGeoZoneAF": enable_geo_zones,
95
+ }
96
+ if storage_zone_id:
97
+ data["OriginType"] = 2
98
+ data["StorageZoneId"] = storage_zone_id
99
+ else:
100
+ data["OriginUrl"] = origin_url
101
+ data["Type"] = 0
102
+
103
+ result = self._request("POST", "/pullzone", data)
104
+ logger.info("Created pull zone %s (ID %s)", name, result.get("Id"))
105
+ return result
106
+
107
+ def update_pull_zone(self, pull_zone_id: int, **settings: Any) -> dict:
108
+ return self._request("POST", f"/pullzone/{pull_zone_id}", settings)
109
+
110
+ def delete_pull_zone(self, pull_zone_id: int) -> None:
111
+ self._request("DELETE", f"/pullzone/{pull_zone_id}")
112
+
113
+ # -- Hostnames + SSL -------------------------------------------------------
114
+
115
+ def add_hostname(self, pull_zone_id: int, hostname: str) -> dict:
116
+ """Add a custom hostname. Idempotent — returns status dict."""
117
+ try:
118
+ self._request(
119
+ "POST",
120
+ f"/pullzone/{pull_zone_id}/addHostname",
121
+ {"Hostname": hostname},
122
+ )
123
+ logger.info("Added hostname %s to pull zone %s", hostname, pull_zone_id)
124
+ return {"added": True}
125
+ except RuntimeError as exc:
126
+ if "hostname_already_registered" in str(exc):
127
+ logger.info("Hostname %s already registered", hostname)
128
+ return {"added": False, "exists": True}
129
+ raise
130
+
131
+ def request_ssl_certificate(
132
+ self, hostname: str, *, use_dns01: bool = False
133
+ ) -> bool:
134
+ """Request a free SSL certificate for a hostname."""
135
+ params = f"hostname={hostname}"
136
+ if use_dns01:
137
+ params += "&useOnlyHttp01=false"
138
+ url = f"{API_BASE}/pullzone/loadFreeCertificate?{params}"
139
+ resp = self.session.get(url, timeout=30)
140
+ ok = resp.status_code in (200, 204)
141
+ if ok:
142
+ logger.info("SSL certificate requested for %s", hostname)
143
+ else:
144
+ logger.warning("SSL request for %s returned %s", hostname, resp.status_code)
145
+ return ok
146
+
147
+ # -- Token Authentication + Origin Shield ----------------------------------
148
+
149
+ def enable_token_auth(
150
+ self, pull_zone_id: int, *, token_key: str | None = None
151
+ ) -> dict:
152
+ """Enable token authentication (signed URLs). Returns updated zone."""
153
+ data: dict[str, Any] = {
154
+ "ZoneSecurityEnabled": True,
155
+ "ZoneSecurityIncludeHashRemoteIP": False,
156
+ }
157
+ if token_key:
158
+ data["ZoneSecurityKey"] = token_key
159
+ self._request("POST", f"/pullzone/{pull_zone_id}", data)
160
+ logger.info("Token auth enabled on pull zone %s", pull_zone_id)
161
+ return self.get_pull_zone(pull_zone_id)
162
+
163
+ def enable_origin_shield(
164
+ self, pull_zone_id: int, zone_code: str = "DE"
165
+ ) -> None:
166
+ """Block direct origin access — route everything through CDN."""
167
+ self._request(
168
+ "POST",
169
+ f"/pullzone/{pull_zone_id}",
170
+ {"EnableOriginShield": True, "OriginShieldZoneCode": zone_code},
171
+ )
172
+ logger.info("Origin shield enabled on pull zone %s", pull_zone_id)
173
+
174
+ # -- SPA + Edge Rules ------------------------------------------------------
175
+
176
+ def configure_spa_error_page(self, pull_zone_id: int) -> None:
177
+ """Configure custom error page for SPA support."""
178
+ self._request(
179
+ "POST",
180
+ f"/pullzone/{pull_zone_id}",
181
+ {
182
+ "ErrorPageEnableCustomCode": True,
183
+ "ErrorPageCustomCode": (
184
+ '<!DOCTYPE html><html><head>'
185
+ '<script>sessionStorage.redirect=location.pathname'
186
+ '+location.search+location.hash;</script>'
187
+ '<meta http-equiv="refresh" content="0;url=/"></head></html>'
188
+ ),
189
+ "ErrorPageWhitelabel": True,
190
+ },
191
+ )
192
+ logger.info("SPA error page configured on pull zone %s", pull_zone_id)
193
+
194
+ def configure_spa_routing(self, storage_zone_id: int) -> None:
195
+ """Configure SPA routing on a Bunny storage zone (404 -> /index.html)."""
196
+ self._request(
197
+ "POST",
198
+ f"/storagezone/{storage_zone_id}",
199
+ {"Custom404FilePath": "/index.html", "Rewrite404To200": True},
200
+ )
201
+ logger.info("SPA routing configured on storage zone %s", storage_zone_id)
202
+
203
+ def get_edge_rules(self, pull_zone_id: int) -> list[dict]:
204
+ pz = self._request("GET", f"/pullzone/{pull_zone_id}")
205
+ return pz.get("EdgeRules", [])
206
+
207
+ def add_or_update_edge_rule(self, pull_zone_id: int, rule: dict) -> dict:
208
+ return self._request(
209
+ "POST", f"/pullzone/{pull_zone_id}/edgerules/addOrUpdate", rule
210
+ )
211
+
212
+ def delete_edge_rule(self, pull_zone_id: int, rule_guid: str) -> dict:
213
+ return self._request(
214
+ "DELETE",
215
+ f"/pullzone/{pull_zone_id}/edgerules/{rule_guid}",
216
+ )
217
+
218
+ # -- Cache -----------------------------------------------------------------
219
+
220
+ def purge_cache(self, pull_zone_id: int) -> None:
221
+ self._request("POST", f"/pullzone/{pull_zone_id}/purgeCache")
222
+ logger.info("Cache purged for pull zone %s", pull_zone_id)
223
+
224
+ # -- Helpers ---------------------------------------------------------------
225
+
226
+ def get_default_hostname(self, pull_zone: dict) -> str:
227
+ """Return the ``*.b-cdn.net`` hostname for a pull zone."""
228
+ for h in pull_zone.get("Hostnames", []):
229
+ if h.get("Value", "").endswith(".b-cdn.net"):
230
+ return h["Value"]
231
+ return f"{pull_zone['Name']}.b-cdn.net"
granny/cli/__init__.py ADDED
File without changes
granny/cli/analyze.py ADDED
@@ -0,0 +1,66 @@
1
+ """CLI commands for AWS resource analysis."""
2
+
3
+ import json
4
+
5
+ import click
6
+
7
+
8
+ @click.group()
9
+ def analyze() -> None:
10
+ """Analyze and inventory AWS resources."""
11
+
12
+
13
+ @analyze.command()
14
+ @click.option("--region", multiple=True, help="Specific region(s) to query (default: all)")
15
+ @click.option("--json-output", "as_json", is_flag=True, help="Output as JSON")
16
+ def lambdas(region: tuple[str, ...], as_json: bool) -> None:
17
+ """List Lambda functions across AWS regions."""
18
+ from granny.analyze.lambdas import list_lambdas
19
+
20
+ regions = list(region) if region else None
21
+ results = list_lambdas(regions=regions)
22
+
23
+ if not results:
24
+ click.echo("No Lambda functions found.")
25
+ return
26
+
27
+ if as_json:
28
+ click.echo(json.dumps([vars(r) for r in results], indent=2))
29
+ return
30
+
31
+ current_region = None
32
+ for fn in results:
33
+ if fn.region != current_region:
34
+ current_region = fn.region
35
+ click.echo(f"\nRegion: {current_region}")
36
+ click.echo("-" * 70)
37
+ click.echo(f"{'Function Name':<40} {'Runtime':<15} {'Version':<10}")
38
+ click.echo("-" * 70)
39
+ click.echo(f"{fn.name:<40} {fn.runtime:<15} {fn.version:<10}")
40
+
41
+
42
+ @analyze.command()
43
+ @click.option("--region", multiple=True, help="Specific region(s) to query")
44
+ @click.option("--json-output", "as_json", is_flag=True, help="Output as JSON")
45
+ def vpcs(region: tuple[str, ...], as_json: bool) -> None:
46
+ """List VPCs in the AWS account."""
47
+ from granny.analyze.vpcs import list_vpcs
48
+
49
+ regions = list(region) if region else None
50
+ results = list_vpcs(regions=regions)
51
+
52
+ if not results:
53
+ click.echo("No VPCs found.")
54
+ return
55
+
56
+ if as_json:
57
+ click.echo(json.dumps([vars(r) for r in results], indent=2))
58
+ return
59
+
60
+ click.echo(f"\n{'VPC ID':<20} {'CIDR Block':<18} {'Name':<30} {'Default':<10} {'Region'}")
61
+ click.echo("-" * 95)
62
+ for vpc in results:
63
+ click.echo(
64
+ f"{vpc.vpc_id:<20} {vpc.cidr_block:<18} {vpc.name:<30} "
65
+ f"{str(vpc.is_default):<10} {vpc.region}"
66
+ )
granny/cli/cdn.py ADDED
@@ -0,0 +1,210 @@
1
+ """``granny cdn`` — Bunny CDN pull zone management.
2
+
3
+ Expanded from cache-purge-only to full pull zone CRUD using the shared
4
+ ``granny.cdn.BunnyCDNClient``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+
11
+ import click
12
+
13
+ from granny.credentials.secrets import (
14
+ get_bunny_api_key,
15
+ list_bunny_customers,
16
+ )
17
+
18
+
19
+ def _client(customer: str | None = None):
20
+ from granny.cdn.bunny import BunnyCDNClient
21
+
22
+ return BunnyCDNClient(customer=customer)
23
+
24
+
25
+ customer_option = click.option(
26
+ "--customer",
27
+ "-c",
28
+ default=None,
29
+ help="Bunny customer profile (resolves BUNNY_API_KEY_<CUSTOMER>).",
30
+ )
31
+
32
+
33
+ @click.group()
34
+ def cdn() -> None:
35
+ """Bunny CDN management commands."""
36
+
37
+
38
+ @cdn.command()
39
+ @click.argument("pull_zone_id", type=int)
40
+ @customer_option
41
+ def purge(pull_zone_id: int, customer: str | None) -> None:
42
+ """Purge the Bunny CDN cache for a pull zone."""
43
+ c = _client(customer)
44
+ c.purge_cache(pull_zone_id)
45
+ click.echo(f"Cache purged for pull zone {pull_zone_id}.")
46
+
47
+
48
+ @cdn.command("list-customers")
49
+ def list_customers() -> None:
50
+ """List known Bunny customer profiles."""
51
+ customers = list_bunny_customers()
52
+ if not customers:
53
+ click.echo(
54
+ "No customer profiles registered. Add names to "
55
+ "GRANNY_BUNNY_CUSTOMERS or export BUNNY_API_KEY_<NAME>."
56
+ )
57
+ return
58
+ click.echo("Known Bunny customer profiles:")
59
+ for name in customers:
60
+ key = get_bunny_api_key(name)
61
+ indicator = "ok" if key else "MISSING"
62
+ click.echo(f" {name:<30} {indicator}")
63
+
64
+
65
+ @cdn.command("list-zones")
66
+ @customer_option
67
+ def list_zones(customer: str | None) -> None:
68
+ """List all Bunny CDN pull zones."""
69
+ c = _client(customer)
70
+ zones = c.list_pull_zones()
71
+ if not zones:
72
+ click.echo("No pull zones found.")
73
+ return
74
+ for z in zones:
75
+ default_host = c.get_default_hostname(z)
76
+ click.echo(f" {z['Id']:<10} {z['Name']:<30} {default_host}")
77
+
78
+
79
+ @cdn.command("get-zone")
80
+ @click.argument("pull_zone_id", type=int)
81
+ @customer_option
82
+ def get_zone(pull_zone_id: int, customer: str | None) -> None:
83
+ """Get details of a pull zone."""
84
+ c = _client(customer)
85
+ zone = c.get_pull_zone(pull_zone_id)
86
+ click.echo(json.dumps(zone, indent=2))
87
+
88
+
89
+ @cdn.command("add-hostname")
90
+ @click.argument("pull_zone_id", type=int)
91
+ @click.argument("hostname")
92
+ @customer_option
93
+ def add_hostname(pull_zone_id: int, hostname: str, customer: str | None) -> None:
94
+ """Add a custom hostname to a pull zone."""
95
+ c = _client(customer)
96
+ result = c.add_hostname(pull_zone_id, hostname)
97
+ if result.get("added"):
98
+ click.echo(f"Hostname {hostname} added to pull zone {pull_zone_id}.")
99
+ else:
100
+ click.echo(f"Hostname {hostname} already registered.")
101
+
102
+
103
+ @cdn.command("ssl")
104
+ @click.argument("hostname")
105
+ @click.option("--dns01", is_flag=True, help="Use DNS-01 validation (for Bunny DNS zones).")
106
+ @customer_option
107
+ def ssl(hostname: str, dns01: bool, customer: str | None) -> None:
108
+ """Request a free SSL certificate for a hostname."""
109
+ c = _client(customer)
110
+ ok = c.request_ssl_certificate(hostname, use_dns01=dns01)
111
+ if ok:
112
+ click.echo(f"SSL certificate requested for {hostname}.")
113
+ else:
114
+ raise click.ClickException(f"SSL request failed for {hostname}.")
115
+
116
+
117
+ @cdn.command("list-edge-rules")
118
+ @click.argument("pull_zone_id", type=int)
119
+ @customer_option
120
+ def list_edge_rules(pull_zone_id: int, customer: str | None) -> None:
121
+ """List edge rules for a pull zone."""
122
+ c = _client(customer)
123
+ rules = c.get_edge_rules(pull_zone_id)
124
+ if not rules:
125
+ click.echo("No edge rules configured.")
126
+ return
127
+ for r in rules:
128
+ status = "enabled" if r.get("Enabled") else "disabled"
129
+ desc = r.get("Description", "(no description)")
130
+ guid = r.get("Guid", "?")
131
+ action = r.get("ActionType", "?")
132
+ target = r.get("ActionParameter1", "")
133
+ triggers = []
134
+ for t in r.get("Triggers", []):
135
+ triggers.extend(t.get("PatternMatches", []))
136
+ click.echo(
137
+ f" {guid} action={action} {status} "
138
+ f"patterns={triggers} target={target} {desc}"
139
+ )
140
+
141
+
142
+ @cdn.command("add-redirect")
143
+ @click.argument("pull_zone_id", type=int)
144
+ @click.argument("from_pattern")
145
+ @click.argument("to_url")
146
+ @click.option("--description", "-d", default=None, help="Rule description.")
147
+ @customer_option
148
+ def add_redirect(
149
+ pull_zone_id: int,
150
+ from_pattern: str,
151
+ to_url: str,
152
+ description: str | None,
153
+ customer: str | None,
154
+ ) -> None:
155
+ """Add a URL redirect edge rule to a pull zone.
156
+
157
+ FROM_PATTERN is a Bunny wildcard pattern (e.g. '*/10-Must-Haves/*').
158
+ TO_URL is the full redirect target (e.g. 'https://www.example.com/new-path/').
159
+ """
160
+ desc = description or f"Redirect {from_pattern} -> {to_url}"
161
+ rule = {
162
+ "ActionType": 2,
163
+ "ActionParameter1": to_url,
164
+ "Triggers": [
165
+ {
166
+ "Type": 0,
167
+ "PatternMatchingType": 0,
168
+ "PatternMatches": [from_pattern],
169
+ }
170
+ ],
171
+ "TriggerMatchingType": 0,
172
+ "Description": desc,
173
+ "Enabled": True,
174
+ }
175
+ c = _client(customer)
176
+ c.add_or_update_edge_rule(pull_zone_id, rule)
177
+ click.echo(f"Redirect rule added: {from_pattern} -> {to_url}")
178
+
179
+
180
+ @cdn.command("delete-edge-rule")
181
+ @click.argument("pull_zone_id", type=int)
182
+ @click.argument("rule_guid")
183
+ @customer_option
184
+ def delete_edge_rule(pull_zone_id: int, rule_guid: str, customer: str | None) -> None:
185
+ """Delete an edge rule by its GUID."""
186
+ c = _client(customer)
187
+ c.delete_edge_rule(pull_zone_id, rule_guid)
188
+ click.echo(f"Edge rule {rule_guid} deleted.")
189
+
190
+
191
+ @cdn.command("create-zone")
192
+ @click.argument("name")
193
+ @click.option("--origin-url", default=None, help="HTTP origin URL (S3-backed zones).")
194
+ @click.option("--storage-zone-id", default=None, type=int, help="Bunny storage zone ID.")
195
+ @customer_option
196
+ def create_zone(
197
+ name: str,
198
+ origin_url: str | None,
199
+ storage_zone_id: int | None,
200
+ customer: str | None,
201
+ ) -> None:
202
+ """Create a new Bunny CDN pull zone."""
203
+ if not origin_url and not storage_zone_id:
204
+ raise click.UsageError("Either --origin-url or --storage-zone-id is required.")
205
+ c = _client(customer)
206
+ zone = c.create_pull_zone(
207
+ name, origin_url=origin_url, storage_zone_id=storage_zone_id
208
+ )
209
+ click.echo(f"Pull zone created: ID={zone['Id']} Name={zone['Name']}")
210
+ click.echo(f"Default hostname: {c.get_default_hostname(zone)}")