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
@@ -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
+ )