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
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
function handler(event) {
|
|
2
|
+
var request = event.request;
|
|
3
|
+
var host = request.headers.host.value;
|
|
4
|
+
|
|
5
|
+
if (host.startsWith('www.')) {
|
|
6
|
+
var newHost = host.substring(4);
|
|
7
|
+
return {
|
|
8
|
+
statusCode: 301,
|
|
9
|
+
statusDescription: 'Moved Permanently',
|
|
10
|
+
headers: {
|
|
11
|
+
'location': { value: 'https://' + newHost + request.uri }
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return request;
|
|
17
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Granny credential resolution -- environment variables only."""
|
|
2
|
+
|
|
3
|
+
from granny.credentials.secrets import (
|
|
4
|
+
get_bunny_api_key,
|
|
5
|
+
get_secret,
|
|
6
|
+
list_bunny_customers,
|
|
7
|
+
load_secrets_into_env,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"get_bunny_api_key",
|
|
12
|
+
"get_secret",
|
|
13
|
+
"list_bunny_customers",
|
|
14
|
+
"load_secrets_into_env",
|
|
15
|
+
]
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""Fetch secrets with optional Vaultwarden support and env-var fallback.
|
|
2
|
+
|
|
3
|
+
Resolution order for each secret:
|
|
4
|
+
|
|
5
|
+
1. Environment variable (from ``.env``, ``.deploy.env``, or shell) — explicit wins
|
|
6
|
+
2. Vaultwarden vault via Locke — only if the optional ``[vault]`` extra is
|
|
7
|
+
installed and a vault session is reachable
|
|
8
|
+
|
|
9
|
+
Without the ``[vault]`` extra, secrets are env-var only — the vault-related
|
|
10
|
+
helpers are no-ops that return ``None``. Install with vault support via::
|
|
11
|
+
|
|
12
|
+
pip install "granny-devops[vault]"
|
|
13
|
+
|
|
14
|
+
Library usage::
|
|
15
|
+
|
|
16
|
+
from granny.credentials import get_secret
|
|
17
|
+
|
|
18
|
+
token = get_secret("BUNNY_API_KEY")
|
|
19
|
+
|
|
20
|
+
# Or load all registered secrets into os.environ at once:
|
|
21
|
+
from granny.credentials import load_secrets_into_env
|
|
22
|
+
load_secrets_into_env()
|
|
23
|
+
|
|
24
|
+
Multi-customer Bunny API keys
|
|
25
|
+
-----------------------------
|
|
26
|
+
When operating several Bunny accounts locally (e.g. one per customer), use
|
|
27
|
+
``get_bunny_api_key(customer=...)``. The customer name is slugified and
|
|
28
|
+
resolved as:
|
|
29
|
+
|
|
30
|
+
1. Vault: ``granny/infra/bunny-api-key-{slug}`` (if vault installed)
|
|
31
|
+
2. Env var: ``BUNNY_API_KEY_{UPPER_SLUG}``
|
|
32
|
+
|
|
33
|
+
Passing ``customer=None`` resolves the default ``BUNNY_API_KEY``.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import base64
|
|
37
|
+
import json
|
|
38
|
+
import logging
|
|
39
|
+
import os
|
|
40
|
+
import platform
|
|
41
|
+
import re
|
|
42
|
+
import time
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
# Optional dependency: Locke. Imported lazily inside helpers so that the
|
|
48
|
+
# module loads cleanly when the [vault] extra isn't installed.
|
|
49
|
+
try:
|
|
50
|
+
import locke # noqa: F401 -- presence check only
|
|
51
|
+
|
|
52
|
+
_LOCKE_AVAILABLE = True
|
|
53
|
+
except ImportError:
|
|
54
|
+
_LOCKE_AVAILABLE = False
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Vault session cache -- persists access_token + symmetric keys across
|
|
58
|
+
# process invocations so we don't re-authenticate on every CLI call.
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
_SESSION_TTL = 3600 # 1 hour -- Vaultwarden tokens typically last 2h
|
|
62
|
+
_SESSION_FILE = "vault_session.json"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _session_dir() -> Path:
|
|
66
|
+
"""Return the directory for the vault session cache file."""
|
|
67
|
+
if platform.system() == "Windows":
|
|
68
|
+
base = os.environ.get("LOCALAPPDATA", "")
|
|
69
|
+
d = Path(base) / "granny" if base else Path.home() / ".granny"
|
|
70
|
+
else:
|
|
71
|
+
d = Path.home() / ".granny"
|
|
72
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
return d
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _save_vault_session(
|
|
77
|
+
base_url: str,
|
|
78
|
+
access_token: str,
|
|
79
|
+
enc_key: bytes,
|
|
80
|
+
mac_key: bytes,
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Persist the vault session to disk for reuse by later processes."""
|
|
83
|
+
try:
|
|
84
|
+
payload = {
|
|
85
|
+
"base_url": base_url,
|
|
86
|
+
"access_token": access_token,
|
|
87
|
+
"enc_key": base64.b64encode(enc_key).decode("ascii"),
|
|
88
|
+
"mac_key": base64.b64encode(mac_key).decode("ascii"),
|
|
89
|
+
"created_at": time.time(),
|
|
90
|
+
}
|
|
91
|
+
target = _session_dir() / _SESSION_FILE
|
|
92
|
+
target.write_text(json.dumps(payload))
|
|
93
|
+
logger.debug("Vault session cached to %s", target)
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
logger.debug("Failed to save vault session: %s", exc)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _load_vault_session():
|
|
99
|
+
"""Load a cached vault session if it exists and is not expired.
|
|
100
|
+
|
|
101
|
+
Returns a VaultClient or None. Returns None unconditionally when the
|
|
102
|
+
[vault] extra isn't installed.
|
|
103
|
+
"""
|
|
104
|
+
if not _LOCKE_AVAILABLE:
|
|
105
|
+
return None
|
|
106
|
+
try:
|
|
107
|
+
target = _session_dir() / _SESSION_FILE
|
|
108
|
+
if not target.is_file():
|
|
109
|
+
return None
|
|
110
|
+
data = json.loads(target.read_text())
|
|
111
|
+
age = time.time() - float(data["created_at"])
|
|
112
|
+
if age < 0 or age >= _SESSION_TTL:
|
|
113
|
+
logger.debug("Vault session expired (age=%.0fs)", age)
|
|
114
|
+
target.unlink(missing_ok=True)
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
from locke.vault import VaultClient
|
|
118
|
+
|
|
119
|
+
client = VaultClient(
|
|
120
|
+
base_url=data["base_url"],
|
|
121
|
+
access_token=data["access_token"],
|
|
122
|
+
enc_key=base64.b64decode(data["enc_key"]),
|
|
123
|
+
mac_key=base64.b64decode(data["mac_key"]),
|
|
124
|
+
)
|
|
125
|
+
logger.debug("Restored vault session from cache (age=%.0fs)", age)
|
|
126
|
+
return client
|
|
127
|
+
except Exception as exc:
|
|
128
|
+
logger.debug("Failed to load vault session: %s", exc)
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _invalidate_vault_session() -> None:
|
|
133
|
+
"""Delete the cached vault session file."""
|
|
134
|
+
try:
|
|
135
|
+
target = _session_dir() / _SESSION_FILE
|
|
136
|
+
target.unlink(missing_ok=True)
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
# Map env var name -> vault secret name (kebab-case).
|
|
141
|
+
# The full vault path is: {VAULT_FOLDER}/{secret_name}
|
|
142
|
+
VAULT_FOLDER = "granny/infra"
|
|
143
|
+
|
|
144
|
+
SECRET_MAP: dict[str, str] = {
|
|
145
|
+
"CLOUDFLARE_API_TOKEN": "cloudflare-api-token",
|
|
146
|
+
"DESEC_API_TOKEN": "desec-api-token",
|
|
147
|
+
"HETZNER_S3_ACCESS_KEY": "hetzner-s3-access-key",
|
|
148
|
+
"HETZNER_S3_SECRET_KEY": "hetzner-s3-secret-key",
|
|
149
|
+
"HETZNER_DNS_API_TOKEN": "hetzner-dns-api-token",
|
|
150
|
+
"BUNNY_API_KEY": "bunny-api-key",
|
|
151
|
+
"SCW_ACCESS_KEY": "scw-access-key",
|
|
152
|
+
"SCW_SECRET_KEY": "scw-secret-key",
|
|
153
|
+
"SCW_DEFAULT_PROJECT_ID": "scw-default-project-id",
|
|
154
|
+
"MAILJET_API_KEY": "mailjet-api-key",
|
|
155
|
+
"MAILJET_SECRET_KEY": "mailjet-secret-key",
|
|
156
|
+
"CLOUDNS_AUTH_ID": "cloudns-auth-id",
|
|
157
|
+
"CLOUDNS_AUTH_PASSWORD": "cloudns-auth-password",
|
|
158
|
+
"CLOUDNS_SUB_AUTH_ID": "cloudns-sub-auth-id",
|
|
159
|
+
"CLOUDNS_SUB_AUTH_USER": "cloudns-sub-auth-user",
|
|
160
|
+
# Container registries
|
|
161
|
+
"DOCKER_HUB_USER": "docker-hub-user",
|
|
162
|
+
"DOCKER_HUB_TOKEN": "docker-hub-token",
|
|
163
|
+
# GitLab PyPI / Container Registry (for fetching private packages and pushing images)
|
|
164
|
+
"GITLAB_PYPI_USER": "gitlab-pypi-user",
|
|
165
|
+
"GITLAB_PYPI_PASSWORD": "gitlab-pypi-password",
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
# Module-level cache so vault is only contacted once per process
|
|
169
|
+
_cache: dict[str, str | None] = {}
|
|
170
|
+
_vault_client = None
|
|
171
|
+
_vault_checked = False
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _get_vault_client():
|
|
175
|
+
"""Lazily create a Locke VaultClient.
|
|
176
|
+
|
|
177
|
+
Tries a cached session first to avoid re-authenticating on every CLI
|
|
178
|
+
invocation. Falls back to a fresh ``VaultClient.connect()`` and
|
|
179
|
+
persists the new session for later processes.
|
|
180
|
+
|
|
181
|
+
Returns a connected client or None if vault is unavailable (including
|
|
182
|
+
when the [vault] extra isn't installed).
|
|
183
|
+
"""
|
|
184
|
+
global _vault_client, _vault_checked
|
|
185
|
+
if _vault_checked:
|
|
186
|
+
return _vault_client
|
|
187
|
+
_vault_checked = True
|
|
188
|
+
|
|
189
|
+
if not _LOCKE_AVAILABLE:
|
|
190
|
+
logger.debug("locke not installed — vault disabled (env-var only mode)")
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
# 1. Try cached session (avoids hitting the identity endpoint)
|
|
194
|
+
_vault_client = _load_vault_session()
|
|
195
|
+
if _vault_client is not None:
|
|
196
|
+
# Verify the token still works with a lightweight probe
|
|
197
|
+
try:
|
|
198
|
+
import httpx
|
|
199
|
+
|
|
200
|
+
resp = httpx.get(
|
|
201
|
+
f"{_vault_client._base_url}/api/sync",
|
|
202
|
+
headers=_vault_client._headers,
|
|
203
|
+
timeout=10,
|
|
204
|
+
)
|
|
205
|
+
if resp.status_code == 200:
|
|
206
|
+
logger.info("Reusing cached vault session")
|
|
207
|
+
return _vault_client
|
|
208
|
+
else:
|
|
209
|
+
logger.debug("Cached vault token expired (HTTP %s), re-authenticating", resp.status_code)
|
|
210
|
+
_invalidate_vault_session()
|
|
211
|
+
_vault_client = None
|
|
212
|
+
except Exception as exc:
|
|
213
|
+
logger.debug("Cached session probe failed: %s", exc)
|
|
214
|
+
_invalidate_vault_session()
|
|
215
|
+
_vault_client = None
|
|
216
|
+
|
|
217
|
+
# 2. Fresh authentication
|
|
218
|
+
try:
|
|
219
|
+
from locke.vault import VaultClient
|
|
220
|
+
|
|
221
|
+
_vault_client = VaultClient.connect()
|
|
222
|
+
logger.info("Connected to Vaultwarden via Locke for secret resolution")
|
|
223
|
+
# Persist session for subsequent processes
|
|
224
|
+
_save_vault_session(
|
|
225
|
+
_vault_client._base_url,
|
|
226
|
+
_vault_client._headers["Authorization"].removeprefix("Bearer "),
|
|
227
|
+
_vault_client._enc_key,
|
|
228
|
+
_vault_client._mac_key,
|
|
229
|
+
)
|
|
230
|
+
return _vault_client
|
|
231
|
+
except Exception as e:
|
|
232
|
+
logger.debug("Vault unavailable: %s", e)
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _fetch_from_vault(env_var: str) -> str | None:
|
|
237
|
+
"""Fetch a single secret from Vaultwarden by its env var name."""
|
|
238
|
+
secret_name = SECRET_MAP.get(env_var)
|
|
239
|
+
if not secret_name:
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
client = _get_vault_client()
|
|
243
|
+
if not client:
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
vault_path = f"{VAULT_FOLDER}/{secret_name}"
|
|
247
|
+
try:
|
|
248
|
+
return client.get_secret(vault_path)
|
|
249
|
+
except Exception as e:
|
|
250
|
+
logger.debug("Failed to fetch %s from vault: %s", vault_path, e)
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def get_secret(env_var: str) -> str | None:
|
|
255
|
+
"""Get a secret by its environment variable name.
|
|
256
|
+
|
|
257
|
+
Resolution order:
|
|
258
|
+
1. Process cache (avoids repeated vault lookups)
|
|
259
|
+
2. Environment variable / .env / .deploy.env (explicit wins)
|
|
260
|
+
3. Vaultwarden vault via Locke (only if [vault] extra installed)
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
env_var: The environment variable name (e.g. "BUNNY_API_KEY").
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
The secret value, or None if not found anywhere.
|
|
267
|
+
"""
|
|
268
|
+
if env_var in _cache:
|
|
269
|
+
return _cache[env_var]
|
|
270
|
+
|
|
271
|
+
# Env var wins so a project-local .deploy.env can override vault.
|
|
272
|
+
value = os.environ.get(env_var)
|
|
273
|
+
if value:
|
|
274
|
+
_cache[env_var] = value
|
|
275
|
+
return value
|
|
276
|
+
|
|
277
|
+
# Fall back to vault (only for registered secrets, only if locke installed)
|
|
278
|
+
if env_var in SECRET_MAP:
|
|
279
|
+
value = _fetch_from_vault(env_var)
|
|
280
|
+
if value:
|
|
281
|
+
_cache[env_var] = value
|
|
282
|
+
return value
|
|
283
|
+
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _slugify_customer(customer: str) -> str:
|
|
288
|
+
"""Normalise a customer name for use in vault paths and env vars.
|
|
289
|
+
|
|
290
|
+
Lower-cases, collapses non-alphanumerics to hyphens, strips ends.
|
|
291
|
+
"""
|
|
292
|
+
slug = re.sub(r"[^a-z0-9]+", "-", customer.strip().lower()).strip("-")
|
|
293
|
+
if not slug:
|
|
294
|
+
raise ValueError(f"Invalid customer name: {customer!r}")
|
|
295
|
+
return slug
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def get_bunny_api_key(customer: str | None = None) -> str | None:
|
|
299
|
+
"""Get a Bunny CDN API key, optionally for a specific customer.
|
|
300
|
+
|
|
301
|
+
Resolution order (for a given ``customer``):
|
|
302
|
+
1. Process cache
|
|
303
|
+
2. Env var: ``BUNNY_API_KEY_{UPPER_SLUG}`` (hyphens → underscores)
|
|
304
|
+
3. Vault: ``granny/infra/bunny-api-key-{slug}`` (if [vault] installed)
|
|
305
|
+
|
|
306
|
+
When ``customer`` is None, resolves the default ``BUNNY_API_KEY`` via
|
|
307
|
+
:func:`get_secret`.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
customer: Customer identifier, e.g. ``"acme"``. Case/spacing-insensitive.
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
The API key, or None if not found.
|
|
314
|
+
"""
|
|
315
|
+
if customer is None:
|
|
316
|
+
return get_secret("BUNNY_API_KEY")
|
|
317
|
+
|
|
318
|
+
slug = _slugify_customer(customer)
|
|
319
|
+
env_var = "BUNNY_API_KEY_" + slug.replace("-", "_").upper()
|
|
320
|
+
|
|
321
|
+
if env_var in _cache:
|
|
322
|
+
return _cache[env_var]
|
|
323
|
+
|
|
324
|
+
# Env var wins so a project-local .deploy.env can override vault.
|
|
325
|
+
value = os.environ.get(env_var)
|
|
326
|
+
if value:
|
|
327
|
+
_cache[env_var] = value
|
|
328
|
+
return value
|
|
329
|
+
|
|
330
|
+
# Fall back to vault
|
|
331
|
+
client = _get_vault_client()
|
|
332
|
+
if client:
|
|
333
|
+
vault_path = f"{VAULT_FOLDER}/bunny-api-key-{slug}"
|
|
334
|
+
try:
|
|
335
|
+
value = client.get_secret(vault_path)
|
|
336
|
+
if value:
|
|
337
|
+
_cache[env_var] = value
|
|
338
|
+
return value
|
|
339
|
+
except Exception as e:
|
|
340
|
+
logger.debug("Failed to fetch %s from vault: %s", vault_path, e)
|
|
341
|
+
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def list_bunny_customers() -> list[str]:
|
|
346
|
+
"""List known Bunny customer profiles.
|
|
347
|
+
|
|
348
|
+
Sources (merged, deduplicated, sorted):
|
|
349
|
+
* ``GRANNY_BUNNY_CUSTOMERS`` env var (comma-separated names)
|
|
350
|
+
* Any ``BUNNY_API_KEY_{NAME}`` env vars currently set
|
|
351
|
+
|
|
352
|
+
Note: the vault client does not expose a listing API, so vault-only
|
|
353
|
+
customers must be registered via ``GRANNY_BUNNY_CUSTOMERS`` to show up.
|
|
354
|
+
|
|
355
|
+
Returns:
|
|
356
|
+
Sorted list of slugified customer names.
|
|
357
|
+
"""
|
|
358
|
+
customers: set[str] = set()
|
|
359
|
+
|
|
360
|
+
registry = os.environ.get("GRANNY_BUNNY_CUSTOMERS", "")
|
|
361
|
+
for name in registry.split(","):
|
|
362
|
+
name = name.strip()
|
|
363
|
+
if name:
|
|
364
|
+
try:
|
|
365
|
+
customers.add(_slugify_customer(name))
|
|
366
|
+
except ValueError:
|
|
367
|
+
continue
|
|
368
|
+
|
|
369
|
+
for key in os.environ:
|
|
370
|
+
if key.startswith("BUNNY_API_KEY_") and key != "BUNNY_API_KEY_":
|
|
371
|
+
suffix = key[len("BUNNY_API_KEY_") :]
|
|
372
|
+
customers.add(suffix.lower().replace("_", "-"))
|
|
373
|
+
|
|
374
|
+
return sorted(customers)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def load_secrets_into_env(keys: list[str] | None = None) -> dict[str, bool]:
|
|
378
|
+
"""Load secrets from vault into os.environ for all registered keys.
|
|
379
|
+
|
|
380
|
+
Useful for scripts that already use os.environ.get() everywhere.
|
|
381
|
+
Call this once at startup after load_dotenv().
|
|
382
|
+
|
|
383
|
+
When the [vault] extra isn't installed, this only reports presence
|
|
384
|
+
of pre-existing env vars without mutating os.environ.
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
keys: Specific env var names to load. Defaults to all SECRET_MAP keys.
|
|
388
|
+
|
|
389
|
+
Returns:
|
|
390
|
+
Dict mapping env var name to True (found) or False (missing).
|
|
391
|
+
"""
|
|
392
|
+
if keys is None:
|
|
393
|
+
keys = list(SECRET_MAP.keys())
|
|
394
|
+
|
|
395
|
+
results = {}
|
|
396
|
+
for key in keys:
|
|
397
|
+
value = get_secret(key)
|
|
398
|
+
if value:
|
|
399
|
+
os.environ[key] = value
|
|
400
|
+
results[key] = True
|
|
401
|
+
else:
|
|
402
|
+
results[key] = False
|
|
403
|
+
return results
|
granny/dns/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""DNS management — provider-agnostic record CRUD.
|
|
2
|
+
|
|
3
|
+
This package is the shared home for DNS provider adapters that were
|
|
4
|
+
previously duplicated across seven ``tools/create/setup_*.py`` scripts.
|
|
5
|
+
|
|
6
|
+
Public API::
|
|
7
|
+
|
|
8
|
+
from granny.dns import get_provider, DNSProvider, DNSRecord
|
|
9
|
+
|
|
10
|
+
dns = get_provider("cloudflare")
|
|
11
|
+
zone_id = dns.get_zone_id("example.com")
|
|
12
|
+
dns.upsert_record(zone_id, "api", "A", "1.2.3.4", ttl=300)
|
|
13
|
+
|
|
14
|
+
The existing ``tools/create/setup_*.py`` scripts keep their local copies
|
|
15
|
+
and are intentionally unchanged.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from granny.dns.base import DNSProvider
|
|
19
|
+
from granny.dns.factory import get_provider, provider_choices
|
|
20
|
+
from granny.dns.records import DNSRecord
|
|
21
|
+
|
|
22
|
+
__all__ = ["DNSProvider", "DNSRecord", "get_provider", "provider_choices"]
|
granny/dns/base.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""DNSProvider — abstract base class for DNS record CRUD."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
from granny.dns.records import DNSRecord
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DNSProvider(ABC):
|
|
9
|
+
"""Provider-agnostic DNS record CRUD.
|
|
10
|
+
|
|
11
|
+
Implementations raise ``NotImplementedError`` for operations their upstream
|
|
12
|
+
API does not support (e.g. easyname has no DNS record endpoints — it is a
|
|
13
|
+
registrar only and is therefore not a DNS provider at all).
|
|
14
|
+
|
|
15
|
+
All ``name`` arguments are short names relative to the zone: ``""`` or
|
|
16
|
+
``"@"`` for apex, ``"api"`` for ``api.example.com``. Zone resolution is the
|
|
17
|
+
caller's job via :meth:`get_zone_id`.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
name: str = "generic"
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def get_zone_id(self, zone_name: str) -> str:
|
|
24
|
+
"""Return the provider-specific zone identifier for ``zone_name``.
|
|
25
|
+
|
|
26
|
+
Raises if the zone doesn't exist. Providers that use the domain name
|
|
27
|
+
as the identifier (deSEC, ClouDNS) return ``zone_name`` unchanged.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def get_nameservers(self, zone_name: str) -> list[str]:
|
|
31
|
+
"""Return the authoritative nameservers advertised by the provider."""
|
|
32
|
+
raise NotImplementedError(
|
|
33
|
+
f"{type(self).__name__} does not expose nameservers via API"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def list_records(
|
|
38
|
+
self,
|
|
39
|
+
zone_id: str,
|
|
40
|
+
name: str | None = None,
|
|
41
|
+
record_type: str | None = None,
|
|
42
|
+
) -> list[DNSRecord]:
|
|
43
|
+
"""List records in a zone, optionally filtered by name and/or type."""
|
|
44
|
+
|
|
45
|
+
def get_record(
|
|
46
|
+
self, zone_id: str, name: str, record_type: str
|
|
47
|
+
) -> DNSRecord | None:
|
|
48
|
+
"""Return the first record matching ``name`` + ``record_type``, else None."""
|
|
49
|
+
matches = self.list_records(zone_id, name=name, record_type=record_type)
|
|
50
|
+
return matches[0] if matches else None
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def create_record(
|
|
54
|
+
self,
|
|
55
|
+
zone_id: str,
|
|
56
|
+
name: str,
|
|
57
|
+
record_type: str,
|
|
58
|
+
value: str,
|
|
59
|
+
ttl: int = 300,
|
|
60
|
+
proxied: bool = False,
|
|
61
|
+
) -> DNSRecord:
|
|
62
|
+
"""Create a record. Returns the created record with its provider id."""
|
|
63
|
+
|
|
64
|
+
@abstractmethod
|
|
65
|
+
def delete_record(self, zone_id: str, record_id: str) -> None:
|
|
66
|
+
"""Delete a record by its provider-specific id."""
|
|
67
|
+
|
|
68
|
+
def update_record(
|
|
69
|
+
self,
|
|
70
|
+
zone_id: str,
|
|
71
|
+
record_id: str,
|
|
72
|
+
name: str,
|
|
73
|
+
record_type: str,
|
|
74
|
+
value: str,
|
|
75
|
+
ttl: int = 300,
|
|
76
|
+
proxied: bool = False,
|
|
77
|
+
) -> DNSRecord:
|
|
78
|
+
"""Update a record in-place.
|
|
79
|
+
|
|
80
|
+
Default implementation is delete + create. Providers that support
|
|
81
|
+
native PUT (Cloudflare, Bunny) override this for atomicity.
|
|
82
|
+
"""
|
|
83
|
+
self.delete_record(zone_id, record_id)
|
|
84
|
+
return self.create_record(
|
|
85
|
+
zone_id, name, record_type, value, ttl=ttl, proxied=proxied
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def upsert_record(
|
|
89
|
+
self,
|
|
90
|
+
zone_id: str,
|
|
91
|
+
name: str,
|
|
92
|
+
record_type: str,
|
|
93
|
+
value: str,
|
|
94
|
+
ttl: int = 300,
|
|
95
|
+
proxied: bool = False,
|
|
96
|
+
) -> DNSRecord:
|
|
97
|
+
"""Create the record if it doesn't exist, else update it."""
|
|
98
|
+
existing = self.get_record(zone_id, name, record_type)
|
|
99
|
+
if existing is None:
|
|
100
|
+
return self.create_record(
|
|
101
|
+
zone_id, name, record_type, value, ttl=ttl, proxied=proxied
|
|
102
|
+
)
|
|
103
|
+
if existing.value == value and existing.ttl == ttl:
|
|
104
|
+
return existing
|
|
105
|
+
return self.update_record(
|
|
106
|
+
zone_id,
|
|
107
|
+
existing.id,
|
|
108
|
+
name,
|
|
109
|
+
record_type,
|
|
110
|
+
value,
|
|
111
|
+
ttl=ttl,
|
|
112
|
+
proxied=proxied,
|
|
113
|
+
)
|