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/dns/factory.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""DNS provider factory — name -> DNSProvider instance.
|
|
2
|
+
|
|
3
|
+
Providers are instantiated lazily so importing :mod:`granny.dns` does not
|
|
4
|
+
trigger credential lookups until someone actually wants to use a provider.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
|
|
9
|
+
from granny.dns.base import DNSProvider
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _bunny() -> DNSProvider:
|
|
13
|
+
from granny.dns.bunny import BunnyDNSProvider
|
|
14
|
+
|
|
15
|
+
return BunnyDNSProvider()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _cloudflare() -> DNSProvider:
|
|
19
|
+
from granny.dns.cloudflare import CloudflareDNSProvider
|
|
20
|
+
|
|
21
|
+
return CloudflareDNSProvider()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _hetzner() -> DNSProvider:
|
|
25
|
+
from granny.dns.hetzner import HetznerDNSProvider
|
|
26
|
+
|
|
27
|
+
return HetznerDNSProvider()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _desec() -> DNSProvider:
|
|
31
|
+
from granny.dns.desec import DeSECDNSProvider
|
|
32
|
+
|
|
33
|
+
return DeSECDNSProvider()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _cloudns() -> DNSProvider:
|
|
37
|
+
from granny.dns.cloudns import ClouDNSDNSProvider
|
|
38
|
+
|
|
39
|
+
return ClouDNSDNSProvider()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _manual() -> DNSProvider:
|
|
43
|
+
from granny.dns.manual import ManualDNSProvider
|
|
44
|
+
|
|
45
|
+
return ManualDNSProvider()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_PROVIDERS: dict[str, Callable[[], DNSProvider]] = {
|
|
49
|
+
"bunny": _bunny,
|
|
50
|
+
"cloudflare": _cloudflare,
|
|
51
|
+
"hetzner": _hetzner,
|
|
52
|
+
"desec": _desec,
|
|
53
|
+
"cloudns": _cloudns,
|
|
54
|
+
"manual": _manual,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def provider_choices() -> list[str]:
|
|
59
|
+
"""Return the list of provider names for argparse/click."""
|
|
60
|
+
return sorted(_PROVIDERS.keys())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_provider(name: str) -> DNSProvider:
|
|
64
|
+
"""Instantiate the named provider. Raises ``ValueError`` if unknown."""
|
|
65
|
+
key = (name or "").strip().lower()
|
|
66
|
+
factory = _PROVIDERS.get(key)
|
|
67
|
+
if factory is None:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"Unknown DNS provider: {name!r}. "
|
|
70
|
+
f"Choices: {', '.join(provider_choices())}"
|
|
71
|
+
)
|
|
72
|
+
return factory()
|
granny/dns/hetzner.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Hetzner DNS provider — CRUD via the new Cloud API.
|
|
2
|
+
|
|
3
|
+
The legacy ``dns.hetzner.com`` API (``Auth-API-Token``) is deprecated. This
|
|
4
|
+
adapter uses the Hetzner Cloud API (``api.hetzner.cloud/v1``, Bearer auth,
|
|
5
|
+
rrset-based record management). One rrset bundles all records of the same
|
|
6
|
+
``(name, type)`` pair, so individual ``DNSRecord`` rows are synthesised from
|
|
7
|
+
each rrset's ``records`` list, with ``id`` formatted as ``"{name}/{type}"``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
from granny.credentials.secrets import get_secret
|
|
15
|
+
from granny.dns.base import DNSProvider
|
|
16
|
+
from granny.dns.records import DNSRecord
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
API_BASE = "https://api.hetzner.cloud/v1"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class HetznerDNSProvider(DNSProvider):
|
|
24
|
+
name = "hetzner"
|
|
25
|
+
|
|
26
|
+
def __init__(self, api_token: str | None = None) -> None:
|
|
27
|
+
api_token = api_token or get_secret("HETZNER_DNS_API_TOKEN")
|
|
28
|
+
if not api_token:
|
|
29
|
+
raise ValueError("HETZNER_DNS_API_TOKEN not set (env or vault).")
|
|
30
|
+
self.session = requests.Session()
|
|
31
|
+
self.session.headers.update(
|
|
32
|
+
{"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"}
|
|
33
|
+
)
|
|
34
|
+
self._zone_cache: dict[str, str] = {}
|
|
35
|
+
self._ns_cache: dict[str, list[str]] = {}
|
|
36
|
+
|
|
37
|
+
def _request(
|
|
38
|
+
self, method: str, path: str, *, params: dict | None = None, json: dict | None = None
|
|
39
|
+
) -> dict:
|
|
40
|
+
url = f"{API_BASE}{path}"
|
|
41
|
+
resp = getattr(self.session, method.lower())(
|
|
42
|
+
url, params=params, json=json, timeout=30
|
|
43
|
+
)
|
|
44
|
+
if resp.status_code >= 400:
|
|
45
|
+
raise RuntimeError(
|
|
46
|
+
f"Hetzner DNS {method} {path} failed: {resp.status_code} {resp.text}"
|
|
47
|
+
)
|
|
48
|
+
if resp.status_code == 204 or not resp.text:
|
|
49
|
+
return {}
|
|
50
|
+
return resp.json()
|
|
51
|
+
|
|
52
|
+
def get_zone_id(self, zone_name: str) -> str:
|
|
53
|
+
if zone_name in self._zone_cache:
|
|
54
|
+
return self._zone_cache[zone_name]
|
|
55
|
+
body = self._request("GET", "/zones", params={"name": zone_name})
|
|
56
|
+
for zone in body.get("zones") or []:
|
|
57
|
+
if zone.get("name") == zone_name:
|
|
58
|
+
zone_id = str(zone["id"])
|
|
59
|
+
self._zone_cache[zone_name] = zone_id
|
|
60
|
+
ns = zone.get("authoritative_nameservers", {}).get("assigned", [])
|
|
61
|
+
self._ns_cache[zone_name] = [n.rstrip(".") for n in ns]
|
|
62
|
+
return zone_id
|
|
63
|
+
raise RuntimeError(f"Hetzner DNS zone not found: {zone_name}")
|
|
64
|
+
|
|
65
|
+
def get_nameservers(self, zone_name: str) -> list[str]:
|
|
66
|
+
self.get_zone_id(zone_name)
|
|
67
|
+
return list(self._ns_cache.get(zone_name, []))
|
|
68
|
+
|
|
69
|
+
def list_records(
|
|
70
|
+
self,
|
|
71
|
+
zone_id: str,
|
|
72
|
+
name: str | None = None,
|
|
73
|
+
record_type: str | None = None,
|
|
74
|
+
) -> list[DNSRecord]:
|
|
75
|
+
body = self._request("GET", f"/zones/{zone_id}/rrsets")
|
|
76
|
+
out: list[DNSRecord] = []
|
|
77
|
+
rtype = record_type.upper() if record_type else None
|
|
78
|
+
for rrset in body.get("rrsets") or []:
|
|
79
|
+
if name is not None and rrset.get("name") != name:
|
|
80
|
+
continue
|
|
81
|
+
if rtype is not None and rrset.get("type") != rtype:
|
|
82
|
+
continue
|
|
83
|
+
ttl = rrset.get("ttl") or 300
|
|
84
|
+
for rec in rrset.get("records") or []:
|
|
85
|
+
out.append(
|
|
86
|
+
DNSRecord(
|
|
87
|
+
name=rrset.get("name", ""),
|
|
88
|
+
type=rrset.get("type", ""),
|
|
89
|
+
value=rec.get("value", ""),
|
|
90
|
+
ttl=int(ttl),
|
|
91
|
+
id=rrset.get("id"), # "{name}/{type}"
|
|
92
|
+
provider_data=rrset,
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
def _short_name(self, name: str) -> str:
|
|
98
|
+
return "@" if not name or name == "@" else name
|
|
99
|
+
|
|
100
|
+
def _normalise_value(self, record_type: str, value: str) -> str:
|
|
101
|
+
# CNAME / NS / MX targets must be FQDN with trailing dot.
|
|
102
|
+
if record_type.upper() in ("CNAME", "NS"):
|
|
103
|
+
return value if value.endswith(".") else f"{value}."
|
|
104
|
+
return value
|
|
105
|
+
|
|
106
|
+
def create_record(
|
|
107
|
+
self,
|
|
108
|
+
zone_id: str,
|
|
109
|
+
name: str,
|
|
110
|
+
record_type: str,
|
|
111
|
+
value: str,
|
|
112
|
+
ttl: int = 300,
|
|
113
|
+
proxied: bool = False,
|
|
114
|
+
) -> DNSRecord:
|
|
115
|
+
short = self._short_name(name)
|
|
116
|
+
rtype = record_type.upper()
|
|
117
|
+
normalised = self._normalise_value(rtype, value)
|
|
118
|
+
payload = {
|
|
119
|
+
"name": short,
|
|
120
|
+
"type": rtype,
|
|
121
|
+
"ttl": ttl,
|
|
122
|
+
"records": [{"value": normalised}],
|
|
123
|
+
}
|
|
124
|
+
body = self._request("POST", f"/zones/{zone_id}/rrsets", json=payload)
|
|
125
|
+
rrset = body.get("rrset") or {}
|
|
126
|
+
logger.info("Hetzner DNS: created %s %s -> %s", rtype, short, normalised)
|
|
127
|
+
return DNSRecord(
|
|
128
|
+
name=rrset.get("name", short),
|
|
129
|
+
type=rrset.get("type", rtype),
|
|
130
|
+
value=normalised,
|
|
131
|
+
ttl=int(rrset.get("ttl") or ttl),
|
|
132
|
+
id=rrset.get("id", f"{short}/{rtype}"),
|
|
133
|
+
provider_data=rrset,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def update_record(
|
|
137
|
+
self,
|
|
138
|
+
zone_id: str,
|
|
139
|
+
record_id: str,
|
|
140
|
+
name: str,
|
|
141
|
+
record_type: str,
|
|
142
|
+
value: str,
|
|
143
|
+
ttl: int = 300,
|
|
144
|
+
proxied: bool = False,
|
|
145
|
+
) -> DNSRecord:
|
|
146
|
+
short = self._short_name(name)
|
|
147
|
+
rtype = record_type.upper()
|
|
148
|
+
rrset_id = record_id if "/" in str(record_id) else f"{short}/{rtype}"
|
|
149
|
+
normalised = self._normalise_value(rtype, value)
|
|
150
|
+
payload = {"ttl": ttl, "records": [{"value": normalised}]}
|
|
151
|
+
body = self._request("PUT", f"/zones/{zone_id}/rrsets/{rrset_id}", json=payload)
|
|
152
|
+
rrset = body.get("rrset") or {}
|
|
153
|
+
return DNSRecord(
|
|
154
|
+
name=rrset.get("name", short),
|
|
155
|
+
type=rrset.get("type", rtype),
|
|
156
|
+
value=normalised,
|
|
157
|
+
ttl=int(rrset.get("ttl") or ttl),
|
|
158
|
+
id=rrset.get("id", rrset_id),
|
|
159
|
+
provider_data=rrset,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def delete_record(self, zone_id: str, record_id: str) -> None:
|
|
163
|
+
# record_id is the rrset id "{name}/{type}"
|
|
164
|
+
self._request("DELETE", f"/zones/{zone_id}/rrsets/{record_id}")
|
|
165
|
+
logger.info("Hetzner DNS: deleted rrset %s", record_id)
|
granny/dns/manual.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""ManualDNSProvider — no-op provider that prints instructions.
|
|
2
|
+
|
|
3
|
+
Use when the authoritative DNS lives with a provider granny does not
|
|
4
|
+
support via API (e.g. easyname's web-UI-only DNS). Every mutating call
|
|
5
|
+
logs the record the operator must configure manually.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
from granny.dns.base import DNSProvider
|
|
11
|
+
from granny.dns.records import DNSRecord
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ManualDNSProvider(DNSProvider):
|
|
17
|
+
name = "manual"
|
|
18
|
+
|
|
19
|
+
def get_zone_id(self, zone_name: str) -> str:
|
|
20
|
+
return zone_name
|
|
21
|
+
|
|
22
|
+
def get_nameservers(self, zone_name: str) -> list[str]:
|
|
23
|
+
return []
|
|
24
|
+
|
|
25
|
+
def list_records(
|
|
26
|
+
self,
|
|
27
|
+
zone_id: str,
|
|
28
|
+
name: str | None = None,
|
|
29
|
+
record_type: str | None = None,
|
|
30
|
+
) -> list[DNSRecord]:
|
|
31
|
+
logger.warning("ManualDNSProvider: cannot list records for %s", zone_id)
|
|
32
|
+
return []
|
|
33
|
+
|
|
34
|
+
def _announce(self, action: str, record: DNSRecord) -> None:
|
|
35
|
+
logger.warning("=" * 70)
|
|
36
|
+
logger.warning("MANUAL DNS ACTION REQUIRED (%s)", action)
|
|
37
|
+
logger.warning("Configure at your DNS provider:")
|
|
38
|
+
logger.warning(" %s", record)
|
|
39
|
+
logger.warning("=" * 70)
|
|
40
|
+
|
|
41
|
+
def create_record(
|
|
42
|
+
self,
|
|
43
|
+
zone_id: str,
|
|
44
|
+
name: str,
|
|
45
|
+
record_type: str,
|
|
46
|
+
value: str,
|
|
47
|
+
ttl: int = 300,
|
|
48
|
+
proxied: bool = False,
|
|
49
|
+
) -> DNSRecord:
|
|
50
|
+
short = "@" if not name or name == "@" else name
|
|
51
|
+
record = DNSRecord(
|
|
52
|
+
name=short,
|
|
53
|
+
type=record_type.upper(),
|
|
54
|
+
value=value,
|
|
55
|
+
ttl=ttl,
|
|
56
|
+
fqdn=(
|
|
57
|
+
f"{short}.{zone_id}" if short != "@" else zone_id
|
|
58
|
+
),
|
|
59
|
+
)
|
|
60
|
+
self._announce("create", record)
|
|
61
|
+
return record
|
|
62
|
+
|
|
63
|
+
def delete_record(self, zone_id: str, record_id: str) -> None:
|
|
64
|
+
logger.warning("MANUAL DNS ACTION REQUIRED (delete): remove record %s", record_id)
|
granny/dns/records.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""DNSRecord — provider-agnostic record representation."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class DNSRecord:
|
|
9
|
+
"""A DNS record as returned by any provider.
|
|
10
|
+
|
|
11
|
+
``name`` is always the short name (subname) relative to the zone — ``""``
|
|
12
|
+
or ``"@"`` for apex, ``"api"`` for ``api.example.com``. ``fqdn`` carries
|
|
13
|
+
the full name for display. ``id`` is the provider-specific record id used
|
|
14
|
+
for updates and deletes. ``provider_data`` holds the raw provider payload
|
|
15
|
+
for anything the generic shape doesn't capture (Cloudflare ``proxied``,
|
|
16
|
+
Bunny ``Disabled``, etc.).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
name: str
|
|
20
|
+
type: str
|
|
21
|
+
value: str
|
|
22
|
+
ttl: int = 300
|
|
23
|
+
id: str | None = None
|
|
24
|
+
fqdn: str | None = None
|
|
25
|
+
provider_data: dict[str, Any] = field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
def __str__(self) -> str:
|
|
28
|
+
display = self.fqdn or self.name or "@"
|
|
29
|
+
return f"{self.type:<6} {display:<40} {self.value} (ttl={self.ttl})"
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Build and push a multi-arch Docker base image with deterministic tagging.
|
|
2
|
+
|
|
3
|
+
Pattern:
|
|
4
|
+
1. Hash the files that affect the base image (deps, Dockerfile, etc.) into
|
|
5
|
+
a short DEPS_HASH used as an image tag.
|
|
6
|
+
2. Check if `<image>:<hash>` already exists in the registry. If yes, skip.
|
|
7
|
+
3. Otherwise build multi-arch via `docker buildx` and push.
|
|
8
|
+
|
|
9
|
+
Secrets (GitLab PyPI token, etc.) are passed via BuildKit `--mount=type=secret`
|
|
10
|
+
so they never enter image history. Credentials needed for `docker login` come
|
|
11
|
+
from granny.credentials (env-var resolution), not from .netrc.
|
|
12
|
+
|
|
13
|
+
Typical usage from another project's CLAUDE.md:
|
|
14
|
+
|
|
15
|
+
granny docker build-base \\
|
|
16
|
+
--context T:\\projects\\LuLarge\\icaap-climaterisk \\
|
|
17
|
+
--dockerfile Dockerfile.base \\
|
|
18
|
+
--image budelius/icaap-climaterisk-base \\
|
|
19
|
+
--hash-file environment.docker.yml \\
|
|
20
|
+
--hash-file pyproject.toml \\
|
|
21
|
+
--hash-file Dockerfile.base \\
|
|
22
|
+
--secret gitlab_token=GITLAB_PYPI_PASSWORD
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import hashlib
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
import subprocess
|
|
30
|
+
import tempfile
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class BuildBaseConfig:
|
|
39
|
+
"""Inputs for a multi-arch base image build."""
|
|
40
|
+
|
|
41
|
+
context: Path
|
|
42
|
+
dockerfile: Path
|
|
43
|
+
image: str
|
|
44
|
+
hash_files: list[Path] = field(default_factory=list)
|
|
45
|
+
platforms: str = "linux/amd64,linux/arm64"
|
|
46
|
+
builder: str = "granny-multiarch"
|
|
47
|
+
# secret_id -> env var name (value read from env; env should be populated
|
|
48
|
+
# via granny.credentials.load_secrets_into_env before calling).
|
|
49
|
+
secrets: dict[str, str] = field(default_factory=dict)
|
|
50
|
+
force: bool = False
|
|
51
|
+
hash_length: int = 16
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def compute_deps_hash(files: list[Path], length: int = 16) -> str:
|
|
55
|
+
"""Compute a short hex sha256 over the concatenated raw bytes of files.
|
|
56
|
+
|
|
57
|
+
Order matters - pass files in a stable order. Matches the shell equivalent:
|
|
58
|
+
cat <files> | sha256sum | cut -c1-<length>
|
|
59
|
+
"""
|
|
60
|
+
if not files:
|
|
61
|
+
raise ValueError("hash_files must not be empty")
|
|
62
|
+
h = hashlib.sha256()
|
|
63
|
+
for path in files:
|
|
64
|
+
h.update(path.read_bytes())
|
|
65
|
+
return h.hexdigest()[:length]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _run(cmd: list[str], check: bool = True, capture: bool = False) -> subprocess.CompletedProcess:
|
|
69
|
+
"""Run a subprocess and log it (without leaking secret flag values)."""
|
|
70
|
+
# Redact anything after --build-arg or --secret for logging only
|
|
71
|
+
safe_cmd = []
|
|
72
|
+
i = 0
|
|
73
|
+
while i < len(cmd):
|
|
74
|
+
token = cmd[i]
|
|
75
|
+
safe_cmd.append(token)
|
|
76
|
+
if token in ("--build-arg", "--secret") and i + 1 < len(cmd):
|
|
77
|
+
next_tok = cmd[i + 1]
|
|
78
|
+
# For --secret id=foo,src=/tmp/xyz keep as-is (src is a file path, not a value)
|
|
79
|
+
# For --build-arg KEY=VALUE, redact VALUE
|
|
80
|
+
if token == "--build-arg" and "=" in next_tok:
|
|
81
|
+
key, _ = next_tok.split("=", 1)
|
|
82
|
+
safe_cmd.append(f"{key}=***")
|
|
83
|
+
else:
|
|
84
|
+
safe_cmd.append(next_tok)
|
|
85
|
+
i += 2
|
|
86
|
+
continue
|
|
87
|
+
i += 1
|
|
88
|
+
logger.debug("run: %s", " ".join(safe_cmd))
|
|
89
|
+
return subprocess.run(
|
|
90
|
+
cmd,
|
|
91
|
+
check=check,
|
|
92
|
+
capture_output=capture,
|
|
93
|
+
text=True,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _manifest_exists(image_ref: str) -> bool:
|
|
98
|
+
"""Return True if the image exists in the remote registry."""
|
|
99
|
+
try:
|
|
100
|
+
result = subprocess.run(
|
|
101
|
+
["docker", "manifest", "inspect", image_ref],
|
|
102
|
+
capture_output=True,
|
|
103
|
+
text=True,
|
|
104
|
+
check=False,
|
|
105
|
+
)
|
|
106
|
+
return result.returncode == 0
|
|
107
|
+
except FileNotFoundError:
|
|
108
|
+
raise RuntimeError("docker CLI not found on PATH")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _ensure_builder(name: str) -> None:
|
|
112
|
+
"""Create a docker-container buildx builder if it doesn't exist."""
|
|
113
|
+
result = subprocess.run(
|
|
114
|
+
["docker", "buildx", "ls"],
|
|
115
|
+
capture_output=True,
|
|
116
|
+
text=True,
|
|
117
|
+
check=False,
|
|
118
|
+
)
|
|
119
|
+
if result.returncode == 0 and name in result.stdout:
|
|
120
|
+
subprocess.run(["docker", "buildx", "use", name], check=False, capture_output=True)
|
|
121
|
+
return
|
|
122
|
+
logger.info("Creating buildx builder %s", name)
|
|
123
|
+
subprocess.run(
|
|
124
|
+
["docker", "buildx", "create", "--name", name, "--driver", "docker-container", "--use"],
|
|
125
|
+
check=True,
|
|
126
|
+
capture_output=True,
|
|
127
|
+
)
|
|
128
|
+
subprocess.run(["docker", "buildx", "inspect", "--bootstrap"], check=True, capture_output=True)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def build_base_image(config: BuildBaseConfig) -> tuple[str, bool]:
|
|
132
|
+
"""Build and push a multi-arch base image.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
(deps_hash, was_built): tuple of the computed hash and whether a new
|
|
136
|
+
build was actually performed (False means it already existed in registry).
|
|
137
|
+
"""
|
|
138
|
+
# Resolve files relative to context
|
|
139
|
+
ctx = config.context.resolve()
|
|
140
|
+
if not ctx.is_dir():
|
|
141
|
+
raise FileNotFoundError(f"Context directory not found: {ctx}")
|
|
142
|
+
|
|
143
|
+
dockerfile = (ctx / config.dockerfile).resolve() if not config.dockerfile.is_absolute() else config.dockerfile
|
|
144
|
+
if not dockerfile.is_file():
|
|
145
|
+
raise FileNotFoundError(f"Dockerfile not found: {dockerfile}")
|
|
146
|
+
|
|
147
|
+
hash_paths = [
|
|
148
|
+
(ctx / p).resolve() if not p.is_absolute() else p
|
|
149
|
+
for p in config.hash_files
|
|
150
|
+
]
|
|
151
|
+
for p in hash_paths:
|
|
152
|
+
if not p.is_file():
|
|
153
|
+
raise FileNotFoundError(f"Hash file not found: {p}")
|
|
154
|
+
|
|
155
|
+
deps_hash = compute_deps_hash(hash_paths, length=config.hash_length)
|
|
156
|
+
tagged = f"{config.image}:{deps_hash}"
|
|
157
|
+
latest = f"{config.image}:latest"
|
|
158
|
+
logger.info("DEPS_HASH=%s", deps_hash)
|
|
159
|
+
|
|
160
|
+
if not config.force and _manifest_exists(tagged):
|
|
161
|
+
logger.info("%s already exists in registry - skipping build", tagged)
|
|
162
|
+
return deps_hash, False
|
|
163
|
+
|
|
164
|
+
_ensure_builder(config.builder)
|
|
165
|
+
|
|
166
|
+
# Write each secret to a tempfile so BuildKit can mount them without
|
|
167
|
+
# exposing values on the command line.
|
|
168
|
+
secret_files: dict[str, Path] = {}
|
|
169
|
+
try:
|
|
170
|
+
for secret_id, env_var in config.secrets.items():
|
|
171
|
+
value = os.environ.get(env_var)
|
|
172
|
+
if value is None:
|
|
173
|
+
raise RuntimeError(
|
|
174
|
+
f"Required secret env var {env_var!r} is not set. "
|
|
175
|
+
f"Ensure granny.credentials.load_secrets_into_env() was called."
|
|
176
|
+
)
|
|
177
|
+
tmp = Path(tempfile.mkstemp(prefix=f"granny-secret-{secret_id}-")[1])
|
|
178
|
+
tmp.write_text(value, encoding="utf-8")
|
|
179
|
+
secret_files[secret_id] = tmp
|
|
180
|
+
|
|
181
|
+
cmd: list[str] = [
|
|
182
|
+
"docker", "buildx", "build",
|
|
183
|
+
"--builder", config.builder,
|
|
184
|
+
"--platform", config.platforms,
|
|
185
|
+
"-t", tagged,
|
|
186
|
+
"-t", latest,
|
|
187
|
+
"-f", str(dockerfile),
|
|
188
|
+
]
|
|
189
|
+
for secret_id, path in secret_files.items():
|
|
190
|
+
cmd += ["--secret", f"id={secret_id},src={path}"]
|
|
191
|
+
cmd += [
|
|
192
|
+
"--cache-from", f"type=registry,ref={config.image}:cache",
|
|
193
|
+
"--push",
|
|
194
|
+
str(ctx),
|
|
195
|
+
]
|
|
196
|
+
_run(cmd)
|
|
197
|
+
finally:
|
|
198
|
+
for p in secret_files.values():
|
|
199
|
+
try:
|
|
200
|
+
p.unlink()
|
|
201
|
+
except OSError:
|
|
202
|
+
pass
|
|
203
|
+
|
|
204
|
+
return deps_hash, True
|
granny/edge/__init__.py
ADDED
granny/edge/bunny.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Bunny Edge Scripting client — script CRUD, code deploy, variables, publish.
|
|
2
|
+
|
|
3
|
+
Lifted from ``tools/create/setup_bunny_edge_script.py:BunnyEdgeScriptClient``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from granny.credentials.secrets import get_bunny_api_key
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
API_BASE = "https://api.bunny.net"
|
|
16
|
+
EDGE_SCRIPTING_API = "/api/v1/edgescripting"
|
|
17
|
+
|
|
18
|
+
SCRIPT_TYPES = {
|
|
19
|
+
"standalone": 0,
|
|
20
|
+
"middleware": 3,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BunnyEdgeScriptClient:
|
|
25
|
+
"""Bunny.net Edge Scripting management.
|
|
26
|
+
|
|
27
|
+
API reference: https://docs.bunny.net/docs/edge-scripting-overview
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, api_key: str | None = None, customer: str | None = None) -> None:
|
|
31
|
+
api_key = api_key or get_bunny_api_key(customer)
|
|
32
|
+
if not api_key:
|
|
33
|
+
raise ValueError("BUNNY_API_KEY not set (env or vault).")
|
|
34
|
+
self.session = requests.Session()
|
|
35
|
+
self.session.headers.update(
|
|
36
|
+
{
|
|
37
|
+
"AccessKey": api_key,
|
|
38
|
+
"Content-Type": "application/json",
|
|
39
|
+
"Accept": "application/json",
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def _request(self, method: str, path: str, data: Any = None) -> Any:
|
|
44
|
+
url = f"{API_BASE}{path}"
|
|
45
|
+
kwargs: dict = {"timeout": 60}
|
|
46
|
+
if data is not None:
|
|
47
|
+
kwargs["json"] = data
|
|
48
|
+
resp = getattr(self.session, method.lower())(url, **kwargs)
|
|
49
|
+
if resp.status_code >= 400:
|
|
50
|
+
raise RuntimeError(
|
|
51
|
+
f"Bunny Edge {method} {path}: {resp.status_code} {resp.text[:500]}"
|
|
52
|
+
)
|
|
53
|
+
if resp.status_code == 204 or not resp.text:
|
|
54
|
+
return {}
|
|
55
|
+
return resp.json()
|
|
56
|
+
|
|
57
|
+
# -- Script CRUD -----------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def list_scripts(self, search: str | None = None) -> list[dict]:
|
|
60
|
+
params = f"?search={requests.utils.quote(search)}" if search else ""
|
|
61
|
+
result = self._request("GET", f"{EDGE_SCRIPTING_API}{params}")
|
|
62
|
+
if isinstance(result, list):
|
|
63
|
+
return result
|
|
64
|
+
return result.get("Items", result.get("items", []))
|
|
65
|
+
|
|
66
|
+
def find_script(self, name: str) -> dict | None:
|
|
67
|
+
for s in self.list_scripts(search=name):
|
|
68
|
+
if s.get("Name") == name:
|
|
69
|
+
return s
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
def get_script(self, script_id: int) -> dict:
|
|
73
|
+
return self._request("GET", f"{EDGE_SCRIPTING_API}/{script_id}")
|
|
74
|
+
|
|
75
|
+
def create_script(
|
|
76
|
+
self,
|
|
77
|
+
name: str,
|
|
78
|
+
*,
|
|
79
|
+
code: str = "",
|
|
80
|
+
script_type: str = "standalone",
|
|
81
|
+
create_linked_pull_zone: bool = True,
|
|
82
|
+
linked_pull_zone_name: str | None = None,
|
|
83
|
+
) -> dict:
|
|
84
|
+
type_id = SCRIPT_TYPES.get(script_type, 0)
|
|
85
|
+
payload: dict[str, Any] = {"Name": name, "ScriptType": type_id}
|
|
86
|
+
if code:
|
|
87
|
+
payload["Code"] = code
|
|
88
|
+
if type_id == 0:
|
|
89
|
+
payload["CreateLinkedPullZone"] = create_linked_pull_zone
|
|
90
|
+
if linked_pull_zone_name:
|
|
91
|
+
payload["LinkedPullZoneName"] = linked_pull_zone_name
|
|
92
|
+
result = self._request("POST", EDGE_SCRIPTING_API, payload)
|
|
93
|
+
logger.info("Created edge script %s (ID %s)", name, result.get("Id"))
|
|
94
|
+
return result
|
|
95
|
+
|
|
96
|
+
def delete_script(
|
|
97
|
+
self, script_id: int, *, delete_linked_pull_zones: bool = False
|
|
98
|
+
) -> None:
|
|
99
|
+
flag = "true" if delete_linked_pull_zones else "false"
|
|
100
|
+
self._request(
|
|
101
|
+
"DELETE",
|
|
102
|
+
f"{EDGE_SCRIPTING_API}/{script_id}?deleteLinkedPullZones={flag}",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# -- Code deployment -------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def get_code(self, script_id: int) -> str:
|
|
108
|
+
result = self._request("GET", f"{EDGE_SCRIPTING_API}/{script_id}/code")
|
|
109
|
+
return result.get("Code", "")
|
|
110
|
+
|
|
111
|
+
def deploy_code(self, script_id: int, code: str) -> dict:
|
|
112
|
+
logger.info("Deploying code to script %s (%d bytes)", script_id, len(code))
|
|
113
|
+
return self._request(
|
|
114
|
+
"POST", f"{EDGE_SCRIPTING_API}/{script_id}/code", {"Code": code}
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# -- Releases --------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def list_releases(self, script_id: int) -> list[dict]:
|
|
120
|
+
result = self._request("GET", f"{EDGE_SCRIPTING_API}/{script_id}/releases")
|
|
121
|
+
if isinstance(result, list):
|
|
122
|
+
return result
|
|
123
|
+
return result.get("Items", result.get("items", []))
|
|
124
|
+
|
|
125
|
+
def publish(self, script_id: int) -> None:
|
|
126
|
+
self._request("POST", f"{EDGE_SCRIPTING_API}/{script_id}/publish", {})
|
|
127
|
+
logger.info("Published edge script %s", script_id)
|
|
128
|
+
|
|
129
|
+
# -- Variables (env + secrets share the same endpoint) ----------------------
|
|
130
|
+
|
|
131
|
+
def list_variables(self, script_id: int) -> list[dict]:
|
|
132
|
+
detail = self.get_script(script_id)
|
|
133
|
+
return detail.get("EdgeScriptVariables", [])
|
|
134
|
+
|
|
135
|
+
def upsert_variable(
|
|
136
|
+
self, script_id: int, name: str, value: str, *, required: bool = True
|
|
137
|
+
) -> dict:
|
|
138
|
+
return self._request(
|
|
139
|
+
"PUT",
|
|
140
|
+
f"{EDGE_SCRIPTING_API}/{script_id}/variables",
|
|
141
|
+
{"Name": name, "DefaultValue": value, "Required": required},
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def delete_variable(self, script_id: int, var_id: int) -> None:
|
|
145
|
+
self._request(
|
|
146
|
+
"DELETE", f"{EDGE_SCRIPTING_API}/{script_id}/variables/{var_id}"
|
|
147
|
+
)
|