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
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,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)}")
|