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/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,5 @@
1
+ """Docker helpers: multi-arch base image builds with deterministic tagging."""
2
+
3
+ from granny.docker.build_base import build_base_image, compute_deps_hash
4
+
5
+ __all__ = ["build_base_image", "compute_deps_hash"]
@@ -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
@@ -0,0 +1,5 @@
1
+ """Edge scripting clients — Bunny Edge Scripting."""
2
+
3
+ from granny.edge.bunny import BunnyEdgeScriptClient
4
+
5
+ __all__ = ["BunnyEdgeScriptClient"]
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
+ )