granny-devops 0.5.0__tar.gz → 0.6.0__tar.gz

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 (75) hide show
  1. {granny_devops-0.5.0 → granny_devops-0.6.0}/PKG-INFO +1 -1
  2. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/__init__.py +1 -1
  3. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cli/create.py +1 -0
  4. granny_devops-0.6.0/granny/create/setup_scaleway_container.py +306 -0
  5. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/credentials/secrets.py +5 -0
  6. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/dns/factory.py +7 -0
  7. granny_devops-0.6.0/granny/dns/inwx.py +290 -0
  8. {granny_devops-0.5.0 → granny_devops-0.6.0}/pyproject.toml +1 -1
  9. {granny_devops-0.5.0 → granny_devops-0.6.0}/.gitignore +0 -0
  10. {granny_devops-0.5.0 → granny_devops-0.6.0}/LICENSE +0 -0
  11. {granny_devops-0.5.0 → granny_devops-0.6.0}/README.md +0 -0
  12. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/analyze/__init__.py +0 -0
  13. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/analyze/lambdas.py +0 -0
  14. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/analyze/vpcs.py +0 -0
  15. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cdn/__init__.py +0 -0
  16. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cdn/bunny.py +0 -0
  17. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cli/__init__.py +0 -0
  18. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cli/analyze.py +0 -0
  19. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cli/cdn.py +0 -0
  20. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cli/cloudflare.py +0 -0
  21. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cli/credentials.py +0 -0
  22. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cli/dns.py +0 -0
  23. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cli/docker.py +0 -0
  24. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cli/edge.py +0 -0
  25. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cli/email.py +0 -0
  26. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cli/main.py +0 -0
  27. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cli/serverless.py +0 -0
  28. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cli/storage.py +0 -0
  29. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cloudflare/__init__.py +0 -0
  30. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cloudflare/d1.py +0 -0
  31. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cloudflare/r2.py +0 -0
  32. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cloudflare/workers.py +0 -0
  33. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/create/__init__.py +0 -0
  34. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/create/auto_certificate.py +0 -0
  35. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/create/cloudfront-security-headers.js +0 -0
  36. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/create/manage-dns.sh +0 -0
  37. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/create/manage_mailjet_contacts.py +0 -0
  38. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/create/registrars.py +0 -0
  39. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/create/setup_aws_cloudfront.py +0 -0
  40. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/create/setup_bunny_edge_script.py +0 -0
  41. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/create/setup_bunny_storage.py +0 -0
  42. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/create/setup_cognito_identity_pool.py +0 -0
  43. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/create/setup_hetzner_bunny.py +0 -0
  44. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/create/setup_mailjet_dns.py +0 -0
  45. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/create/setup_private_cdn.py +0 -0
  46. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/create/setup_s3_website.py +0 -0
  47. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/create/setup_scaleway_faas.py +0 -0
  48. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/create/setup_workmail.py +0 -0
  49. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/create/www-redirect-function.js +0 -0
  50. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/credentials/__init__.py +0 -0
  51. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/dns/__init__.py +0 -0
  52. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/dns/base.py +0 -0
  53. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/dns/bunny.py +0 -0
  54. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/dns/cloudflare.py +0 -0
  55. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/dns/cloudns.py +0 -0
  56. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/dns/desec.py +0 -0
  57. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/dns/hetzner.py +0 -0
  58. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/dns/manual.py +0 -0
  59. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/dns/records.py +0 -0
  60. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/docker/__init__.py +0 -0
  61. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/docker/build_base.py +0 -0
  62. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/edge/__init__.py +0 -0
  63. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/edge/bunny.py +0 -0
  64. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/email/__init__.py +0 -0
  65. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/email/mailjet.py +0 -0
  66. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/email/mailjet_contacts.py +0 -0
  67. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/email/ses_forwarding.py +0 -0
  68. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/email/workmail.py +0 -0
  69. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/report.py +0 -0
  70. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/serverless/__init__.py +0 -0
  71. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/serverless/scaleway.py +0 -0
  72. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/storage/__init__.py +0 -0
  73. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/storage/aws.py +0 -0
  74. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/storage/bunny.py +0 -0
  75. {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/storage/hetzner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: granny-devops
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Cloud tools collection -- AWS infrastructure, CDN, and DevOps automation
5
5
  Author-email: Martin Wieser <martin.wieser@pseekoo.com>
6
6
  License: MIT License
@@ -1,6 +1,6 @@
1
1
  """Granny -- Cloud tools collection for AWS infrastructure and DevOps automation."""
2
2
 
3
- __version__ = "0.5.0"
3
+ __version__ = "0.6.0"
4
4
  __all__ = [
5
5
  "get_secret",
6
6
  "load_secrets_into_env",
@@ -30,6 +30,7 @@ COMMANDS: dict[str, str] = {
30
30
  "private-cdn": "setup_private_cdn.py",
31
31
  "s3-website": "setup_s3_website.py",
32
32
  "scaleway-faas": "setup_scaleway_faas.py",
33
+ "scaleway-container": "setup_scaleway_container.py",
33
34
  "workmail": "setup_workmail.py",
34
35
  "auto-certificate": "auto_certificate.py",
35
36
  "mailjet-contacts": "manage_mailjet_contacts.py",
@@ -0,0 +1,306 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Scaleway Serverless Containers Setup Script
4
+
5
+ Provisions a Scaleway Serverless Containers namespace and (optionally) a
6
+ container within it. Intended as the pre-step for a CI pipeline that builds
7
+ and pushes Docker images to the namespace's registry endpoint and then
8
+ patches the container to use the new image.
9
+
10
+ Environment Variables:
11
+ SCW_ACCESS_KEY: Scaleway access key
12
+ SCW_SECRET_KEY: Scaleway secret key
13
+ SCW_DEFAULT_PROJECT_ID: Scaleway project ID
14
+
15
+ Usage:
16
+ # Create namespace + container with placeholder image
17
+ python setup_scaleway_container.py --name my-app --port 3000
18
+
19
+ # Namespace only (no container)
20
+ python setup_scaleway_container.py --name my-app --namespace-only
21
+
22
+ # Different region
23
+ python setup_scaleway_container.py --name my-app --region nl-ams
24
+ """
25
+
26
+ import argparse
27
+ import logging
28
+ import os
29
+ import sys
30
+ import time
31
+ from typing import Optional
32
+
33
+ import requests
34
+
35
+ from dotenv import load_dotenv
36
+
37
+ load_dotenv()
38
+ try:
39
+ from granny.credentials import load_secrets_into_env
40
+ load_secrets_into_env()
41
+ except Exception:
42
+ pass
43
+
44
+
45
+ SCALEWAY_REGIONS = ("fr-par", "nl-ams", "pl-waw")
46
+
47
+ # Terminal states returned by the Scaleway Containers API.
48
+ _TERMINAL_NAMESPACE_STATES = {"ready", "error", "locked"}
49
+ _TERMINAL_CONTAINER_STATES = {"ready", "error", "pending", "created"}
50
+
51
+
52
+ class ScalewayContainersClient:
53
+ """Minimal client for Scaleway Serverless Containers API."""
54
+
55
+ def __init__(
56
+ self,
57
+ *,
58
+ access_key: Optional[str] = None,
59
+ secret_key: Optional[str] = None,
60
+ project_id: Optional[str] = None,
61
+ region: str = "fr-par",
62
+ ) -> None:
63
+ self.access_key = access_key or os.environ.get("SCW_ACCESS_KEY")
64
+ self.secret_key = secret_key or os.environ.get("SCW_SECRET_KEY")
65
+ self.project_id = project_id or os.environ.get("SCW_DEFAULT_PROJECT_ID")
66
+ self.region = region
67
+
68
+ if not all([self.access_key, self.secret_key, self.project_id]):
69
+ raise ValueError(
70
+ "Missing Scaleway credentials. Set SCW_ACCESS_KEY, SCW_SECRET_KEY, "
71
+ "and SCW_DEFAULT_PROJECT_ID (env or vault)."
72
+ )
73
+ if region not in SCALEWAY_REGIONS:
74
+ raise ValueError(
75
+ f"Unknown region: {region}. Choices: {', '.join(SCALEWAY_REGIONS)}"
76
+ )
77
+
78
+ self.api_base = f"https://api.scaleway.com/containers/v1beta1/regions/{region}"
79
+ self.session = requests.Session()
80
+ self.session.headers.update(
81
+ {"X-Auth-Token": self.secret_key, "Content-Type": "application/json"}
82
+ )
83
+
84
+ def _request(self, method: str, path: str, data: Optional[dict] = None) -> dict:
85
+ url = f"{self.api_base}{path}"
86
+ resp = self.session.request(method, url, json=data, timeout=30)
87
+ if resp.status_code >= 400:
88
+ raise RuntimeError(
89
+ f"Scaleway {method} {path}: {resp.status_code} {resp.text}"
90
+ )
91
+ if resp.status_code == 204 or not resp.text:
92
+ return {}
93
+ return resp.json()
94
+
95
+ # -- Namespaces ------------------------------------------------------------
96
+
97
+ def list_namespaces(self) -> list[dict]:
98
+ result = self._request("GET", f"/namespaces?project_id={self.project_id}")
99
+ return result.get("namespaces", [])
100
+
101
+ def find_namespace(self, name: str) -> Optional[dict]:
102
+ for ns in self.list_namespaces():
103
+ if ns.get("name") == name:
104
+ return ns
105
+ return None
106
+
107
+ def get_namespace(self, namespace_id: str) -> dict:
108
+ return self._request("GET", f"/namespaces/{namespace_id}")
109
+
110
+ def create_namespace(self, name: str, description: str = "") -> dict:
111
+ logging.info("Creating Scaleway Containers namespace: %s", name)
112
+ return self._request(
113
+ "POST",
114
+ "/namespaces",
115
+ {
116
+ "name": name,
117
+ "project_id": self.project_id,
118
+ "description": description or f"Container namespace for {name}",
119
+ },
120
+ )
121
+
122
+ def wait_for_namespace(self, namespace_id: str, timeout: int = 300) -> dict:
123
+ """Poll until the namespace reaches a terminal state."""
124
+ start = time.monotonic()
125
+ while True:
126
+ ns = self.get_namespace(namespace_id)
127
+ status = ns.get("status")
128
+ if status in _TERMINAL_NAMESPACE_STATES:
129
+ return ns
130
+ if time.monotonic() - start > timeout:
131
+ raise TimeoutError(
132
+ f"Namespace {namespace_id} still '{status}' after {timeout}s"
133
+ )
134
+ time.sleep(5)
135
+
136
+ # -- Containers ------------------------------------------------------------
137
+
138
+ def list_containers(self, namespace_id: str) -> list[dict]:
139
+ result = self._request("GET", f"/containers?namespace_id={namespace_id}")
140
+ return result.get("containers", [])
141
+
142
+ def find_container(self, namespace_id: str, name: str) -> Optional[dict]:
143
+ for c in self.list_containers(namespace_id):
144
+ if c.get("name") == name:
145
+ return c
146
+ return None
147
+
148
+ def create_container(
149
+ self,
150
+ *,
151
+ namespace_id: str,
152
+ name: str,
153
+ registry_image: str,
154
+ port: int = 8080,
155
+ min_scale: int = 0,
156
+ max_scale: int = 1,
157
+ memory_limit: int = 512,
158
+ cpu_limit: int = 560,
159
+ timeout: str = "30s",
160
+ protocol: str = "http1",
161
+ http_option: str = "redirected",
162
+ privacy: str = "public",
163
+ environment_variables: Optional[dict] = None,
164
+ ) -> dict:
165
+ logging.info("Creating container: %s (image=%s)", name, registry_image)
166
+ data = {
167
+ "namespace_id": namespace_id,
168
+ "name": name,
169
+ "registry_image": registry_image,
170
+ "port": port,
171
+ "min_scale": min_scale,
172
+ "max_scale": max_scale,
173
+ "memory_limit": memory_limit,
174
+ "cpu_limit": cpu_limit,
175
+ "timeout": timeout,
176
+ "protocol": protocol,
177
+ "http_option": http_option,
178
+ "privacy": privacy,
179
+ }
180
+ if environment_variables:
181
+ data["environment_variables"] = environment_variables
182
+ return self._request("POST", "/containers", data)
183
+
184
+
185
+ # -- CLI -----------------------------------------------------------------------
186
+
187
+ def _build_parser() -> argparse.ArgumentParser:
188
+ parser = argparse.ArgumentParser(
189
+ description="Setup Scaleway Serverless Containers (namespace + container)",
190
+ formatter_class=argparse.RawDescriptionHelpFormatter,
191
+ epilog=(
192
+ "Examples:\n"
193
+ " python setup_scaleway_container.py --name my-app --port 3000\n"
194
+ " python setup_scaleway_container.py --name my-app --namespace-only\n"
195
+ ),
196
+ )
197
+ parser.add_argument("--name", required=True, help="Namespace and container name")
198
+ parser.add_argument(
199
+ "--region", choices=list(SCALEWAY_REGIONS), default="fr-par",
200
+ help="Scaleway region (default: fr-par)",
201
+ )
202
+ parser.add_argument(
203
+ "--port", type=int, default=8080,
204
+ help="Container listen port (default: 8080)",
205
+ )
206
+ parser.add_argument(
207
+ "--initial-image", default="docker.io/library/nginx:alpine",
208
+ help="Placeholder image used until CI pushes the real one "
209
+ "(default: docker.io/library/nginx:alpine)",
210
+ )
211
+ parser.add_argument("--memory", type=int, default=1024, help="Memory MB (default: 1024). Scaleway requires memory_mb >= cpu_mvcpu.")
212
+ parser.add_argument("--cpu", type=int, default=560, help="CPU mVCPU (default: 560)")
213
+ parser.add_argument("--min-scale", type=int, default=0)
214
+ parser.add_argument("--max-scale", type=int, default=1)
215
+ parser.add_argument(
216
+ "--namespace-only", action="store_true",
217
+ help="Only create the namespace, skip container creation",
218
+ )
219
+ parser.add_argument(
220
+ "--deploy-env", default=".deploy.env",
221
+ help="Path to .deploy.env file (default: .deploy.env)",
222
+ )
223
+ parser.add_argument("--no-deploy-env", action="store_true")
224
+ return parser
225
+
226
+
227
+ def _write_deploy_env(path: str, pairs: dict[str, str]) -> None:
228
+ existing = {}
229
+ if os.path.exists(path):
230
+ with open(path) as f:
231
+ for line in f:
232
+ line = line.strip()
233
+ if line and not line.startswith("#") and "=" in line:
234
+ k, v = line.split("=", 1)
235
+ existing[k] = v
236
+ existing.update(pairs)
237
+ with open(path, "w") as f:
238
+ for k, v in existing.items():
239
+ f.write(f"{k}={v}\n")
240
+
241
+
242
+ def main() -> int:
243
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
244
+ args = _build_parser().parse_args()
245
+
246
+ client = ScalewayContainersClient(region=args.region)
247
+
248
+ # Namespace: reuse or create.
249
+ ns = client.find_namespace(args.name)
250
+ if ns:
251
+ logging.info("Namespace '%s' already exists (id=%s)", args.name, ns["id"])
252
+ else:
253
+ ns = client.create_namespace(args.name)
254
+
255
+ if ns.get("status") != "ready":
256
+ logging.info("Waiting for namespace '%s' to become ready...", args.name)
257
+ ns = client.wait_for_namespace(ns["id"])
258
+ if ns.get("status") != "ready":
259
+ logging.error("Namespace did not reach 'ready': status=%s", ns.get("status"))
260
+ return 1
261
+
262
+ registry_endpoint = ns.get("registry_endpoint", "")
263
+ logging.info("Namespace ready. Registry endpoint: %s", registry_endpoint)
264
+
265
+ deploy_env_pairs = {
266
+ "SCW_REGION": args.region,
267
+ "SCW_CONTAINER_NAMESPACE": args.name,
268
+ "SCW_CONTAINER_NAMESPACE_ID": ns["id"],
269
+ "SCW_REGISTRY_ENDPOINT": registry_endpoint,
270
+ }
271
+
272
+ if args.namespace_only:
273
+ logging.info("--namespace-only: skipping container creation")
274
+ else:
275
+ container = client.find_container(ns["id"], args.name)
276
+ if container:
277
+ logging.info("Container '%s' already exists (id=%s)", args.name, container["id"])
278
+ else:
279
+ container = client.create_container(
280
+ namespace_id=ns["id"],
281
+ name=args.name,
282
+ registry_image=args.initial_image,
283
+ port=args.port,
284
+ min_scale=args.min_scale,
285
+ max_scale=args.max_scale,
286
+ memory_limit=args.memory,
287
+ cpu_limit=args.cpu,
288
+ )
289
+ logging.info("Container created. ID: %s", container.get("id"))
290
+ deploy_env_pairs["SCW_CONTAINER_NAME"] = args.name
291
+ deploy_env_pairs["SCW_CONTAINER_ID"] = container["id"]
292
+ if container.get("domain_name"):
293
+ deploy_env_pairs["SCW_CONTAINER_DOMAIN"] = container["domain_name"]
294
+
295
+ if not args.no_deploy_env:
296
+ _write_deploy_env(args.deploy_env, deploy_env_pairs)
297
+ logging.info("Wrote %d keys to %s", len(deploy_env_pairs), args.deploy_env)
298
+
299
+ print("\n=== Scaleway Containers Provisioned ===")
300
+ for k, v in deploy_env_pairs.items():
301
+ print(f"{k}={v}")
302
+ return 0
303
+
304
+
305
+ if __name__ == "__main__":
306
+ sys.exit(main())
@@ -157,6 +157,11 @@ SECRET_MAP: dict[str, str] = {
157
157
  "CLOUDNS_AUTH_PASSWORD": "cloudns-auth-password",
158
158
  "CLOUDNS_SUB_AUTH_ID": "cloudns-sub-auth-id",
159
159
  "CLOUDNS_SUB_AUTH_USER": "cloudns-sub-auth-user",
160
+ # INWX (api.domrobot.com). INWX_SHARED_SECRET is the TOTP base32 secret;
161
+ # only required when 2FA is enabled on the account.
162
+ "INWX_USERNAME": "inwx-username",
163
+ "INWX_PASSWORD": "inwx-password",
164
+ "INWX_SHARED_SECRET": "inwx-shared-secret",
160
165
  # Container registries
161
166
  "DOCKER_HUB_USER": "docker-hub-user",
162
167
  "DOCKER_HUB_TOKEN": "docker-hub-token",
@@ -39,6 +39,12 @@ def _cloudns() -> DNSProvider:
39
39
  return ClouDNSDNSProvider()
40
40
 
41
41
 
42
+ def _inwx() -> DNSProvider:
43
+ from granny.dns.inwx import INWXDNSProvider
44
+
45
+ return INWXDNSProvider()
46
+
47
+
42
48
  def _manual() -> DNSProvider:
43
49
  from granny.dns.manual import ManualDNSProvider
44
50
 
@@ -51,6 +57,7 @@ _PROVIDERS: dict[str, Callable[[], DNSProvider]] = {
51
57
  "hetzner": _hetzner,
52
58
  "desec": _desec,
53
59
  "cloudns": _cloudns,
60
+ "inwx": _inwx,
54
61
  "manual": _manual,
55
62
  }
56
63
 
@@ -0,0 +1,290 @@
1
+ """INWX DNS provider -- JSON-RPC via https://api.domrobot.com/jsonrpc/.
2
+
3
+ INWX (inwx.de / inwx.com) is a German domain registrar. Their API is the
4
+ DOMRobot JSON-RPC endpoint; there is no REST surface. Auth is username +
5
+ password via ``account.login`` returning a session cookie that must be
6
+ reused for all subsequent calls. Optional TOTP (Google Authenticator) is
7
+ followed by ``account.unlock``.
8
+
9
+ Zone addressing uses the bare domain name -- no separate zone-id lookup.
10
+ Records are referenced by integer ``id`` returned from ``createRecord`` or
11
+ listed via ``nameserver.info``. Apex records use the zone name as ``name``
12
+ (NOT ``@``); short subnames are joined to the zone for the API call and
13
+ split back to short form for :class:`~granny.dns.records.DNSRecord` output.
14
+
15
+ Example::
16
+
17
+ from granny.dns.inwx import INWXDNSProvider
18
+ p = INWXDNSProvider()
19
+ zid = p.get_zone_id("example.com")
20
+ p.upsert_record(zid, "_acme-challenge", "TXT", "abc-token", ttl=300)
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import base64
26
+ import hashlib
27
+ import hmac
28
+ import logging
29
+ import struct
30
+ import time
31
+ from typing import Any
32
+
33
+ import requests
34
+
35
+ from granny.credentials.secrets import get_secret
36
+ from granny.dns.base import DNSProvider
37
+ from granny.dns.records import DNSRecord
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ API_URL = "https://api.domrobot.com/jsonrpc/"
42
+ OTE_URL = "https://api.ote.domrobot.com/jsonrpc/" # sandbox
43
+ MIN_TTL = 300 # INWX platform minimum
44
+ DEFAULT_TTL = 3600
45
+ TFA_GOOGLE_AUTH = "GOOGLE-AUTH"
46
+ NAMESERVERS = [
47
+ "ns.inwx.de",
48
+ "ns2.inwx.de",
49
+ "ns3.inwx.eu",
50
+ "ns4.inwx.com",
51
+ "ns5.inwx.net",
52
+ ]
53
+
54
+
55
+ def _totp(secret_b32: str, when: float | None = None) -> str:
56
+ """Compute the current 6-digit TOTP from a base32 secret (RFC 6238)."""
57
+ key = base64.b32decode(secret_b32.replace(" ", "").upper())
58
+ counter = int((when if when is not None else time.time()) // 30)
59
+ msg = struct.pack(">Q", counter)
60
+ digest = hmac.new(key, msg, hashlib.sha1).digest()
61
+ offset = digest[-1] & 0x0F
62
+ code = (struct.unpack(">I", digest[offset : offset + 4])[0] & 0x7FFFFFFF) % 10**6
63
+ return f"{code:06d}"
64
+
65
+
66
+ class INWXDNSProvider(DNSProvider):
67
+ name = "inwx"
68
+
69
+ def __init__(
70
+ self,
71
+ username: str | None = None,
72
+ password: str | None = None,
73
+ shared_secret: str | None = None,
74
+ sandbox: bool = False,
75
+ ) -> None:
76
+ # Resolve credentials lazily so operations that don't hit the API
77
+ # (e.g. get_nameservers) work without configured secrets. _ensure_login
78
+ # raises if creds are missing when the first network call happens.
79
+ self.username = username or get_secret("INWX_USERNAME")
80
+ self.password = password or get_secret("INWX_PASSWORD")
81
+ self.shared_secret = shared_secret or get_secret("INWX_SHARED_SECRET")
82
+ self.url = OTE_URL if sandbox else API_URL
83
+ self.session = requests.Session()
84
+ self._logged_in = False
85
+ self._req_id = 0
86
+
87
+ # -- low-level JSON-RPC ---------------------------------------------------
88
+
89
+ def _call(self, method: str, params: dict[str, Any] | None = None) -> dict:
90
+ self._req_id += 1
91
+ payload = {
92
+ "jsonrpc": "2.0",
93
+ "id": self._req_id,
94
+ "method": method,
95
+ "params": params or {},
96
+ }
97
+ resp = self.session.post(self.url, json=payload, timeout=30)
98
+ if resp.status_code >= 400:
99
+ raise RuntimeError(f"INWX HTTP {resp.status_code}: {resp.text[:300]}")
100
+ body = resp.json()
101
+ code = body.get("code")
102
+ if code != 1000:
103
+ # 2302 = object does not exist; surface as KeyError for callers.
104
+ if code == 2302:
105
+ raise KeyError(method)
106
+ msg = body.get("msg") or body.get("reason") or "unknown"
107
+ raise RuntimeError(f"INWX {method} failed: code={code} msg={msg}")
108
+ return body.get("resData") or {}
109
+
110
+ def _ensure_login(self) -> None:
111
+ if self._logged_in:
112
+ return
113
+ if not self.username or not self.password:
114
+ raise RuntimeError(
115
+ "INWX_USERNAME and INWX_PASSWORD must be set (env or vault)."
116
+ )
117
+ result = self._call(
118
+ "account.login",
119
+ {"user": self.username, "pass": self.password, "lang": "en"},
120
+ )
121
+ # Some accounts require a follow-up TOTP step.
122
+ tfa = (result or {}).get("tfa")
123
+ if tfa == TFA_GOOGLE_AUTH:
124
+ if not self.shared_secret:
125
+ raise RuntimeError(
126
+ "INWX account requires 2FA but INWX_SHARED_SECRET is not set."
127
+ )
128
+ # Anti-replay: don't reuse a TAN inside the same 30s window.
129
+ tan = _totp(self.shared_secret)
130
+ self._call("account.unlock", {"tan": tan})
131
+ self._logged_in = True
132
+
133
+ # -- zone helpers ---------------------------------------------------------
134
+
135
+ @staticmethod
136
+ def _short_to_fqdn(name: str | None, zone_name: str) -> str:
137
+ if name is None or name in ("", "@"):
138
+ return zone_name
139
+ if name == zone_name or name.endswith(f".{zone_name}"):
140
+ return name
141
+ return f"{name}.{zone_name}"
142
+
143
+ @staticmethod
144
+ def _fqdn_to_short(fqdn: str, zone_name: str) -> str:
145
+ if fqdn == zone_name:
146
+ return ""
147
+ suffix = f".{zone_name}"
148
+ if fqdn.endswith(suffix):
149
+ return fqdn[: -len(suffix)]
150
+ return fqdn
151
+
152
+ # -- DNSProvider impl -----------------------------------------------------
153
+
154
+ def get_zone_id(self, zone_name: str) -> str:
155
+ self._ensure_login()
156
+ try:
157
+ self._call("nameserver.info", {"domain": zone_name})
158
+ except KeyError:
159
+ raise RuntimeError(f"INWX domain not hosted on this account: {zone_name}")
160
+ # The bare zone name *is* the addressing key for subsequent calls.
161
+ return zone_name
162
+
163
+ def get_nameservers(self, zone_name: str) -> list[str]:
164
+ return list(NAMESERVERS)
165
+
166
+ def list_records(
167
+ self,
168
+ zone_id: str,
169
+ name: str | None = None,
170
+ record_type: str | None = None,
171
+ ) -> list[DNSRecord]:
172
+ self._ensure_login()
173
+ params: dict[str, Any] = {"domain": zone_id}
174
+ if record_type:
175
+ params["type"] = record_type.upper()
176
+ if name is not None:
177
+ params["name"] = self._short_to_fqdn(name, zone_id)
178
+ try:
179
+ data = self._call("nameserver.info", params)
180
+ except KeyError:
181
+ return []
182
+ out: list[DNSRecord] = []
183
+ for r in data.get("record") or []:
184
+ short = self._fqdn_to_short(r.get("name", ""), zone_id)
185
+ out.append(
186
+ DNSRecord(
187
+ name=short,
188
+ type=r.get("type", ""),
189
+ value=r.get("content", ""),
190
+ ttl=int(r.get("ttl") or DEFAULT_TTL),
191
+ id=str(r.get("id", "")),
192
+ fqdn=r.get("name"),
193
+ provider_data=r,
194
+ )
195
+ )
196
+ return out
197
+
198
+ def create_record(
199
+ self,
200
+ zone_id: str,
201
+ name: str,
202
+ record_type: str,
203
+ value: str,
204
+ ttl: int = DEFAULT_TTL,
205
+ proxied: bool = False,
206
+ ) -> DNSRecord:
207
+ self._ensure_login()
208
+ rtype = record_type.upper()
209
+ ttl = max(int(ttl), MIN_TTL)
210
+ fqdn = self._short_to_fqdn(name, zone_id)
211
+
212
+ params: dict[str, Any] = {
213
+ "domain": zone_id,
214
+ "type": rtype,
215
+ "name": fqdn,
216
+ "content": value,
217
+ "ttl": ttl,
218
+ }
219
+
220
+ # MX priority rides in `prio`; never inline it into `content`. Accept
221
+ # callers that still pass "10 mail.example.com" by splitting once.
222
+ if rtype == "MX":
223
+ head, _, tail = value.strip().partition(" ")
224
+ if head.isdigit() and tail:
225
+ params["prio"] = int(head)
226
+ params["content"] = tail
227
+ else:
228
+ params["prio"] = 10 # sane default
229
+
230
+ result = self._call("nameserver.createRecord", params)
231
+ record_id = str(result.get("id", ""))
232
+ logger.info(
233
+ "INWX: created %s %s -> %s (id=%s)", rtype, fqdn, params["content"], record_id
234
+ )
235
+ return DNSRecord(
236
+ name=self._fqdn_to_short(fqdn, zone_id),
237
+ type=rtype,
238
+ value=params["content"],
239
+ ttl=ttl,
240
+ id=record_id,
241
+ fqdn=fqdn,
242
+ )
243
+
244
+ def update_record(
245
+ self,
246
+ zone_id: str,
247
+ record_id: str,
248
+ name: str,
249
+ record_type: str,
250
+ value: str,
251
+ ttl: int = DEFAULT_TTL,
252
+ proxied: bool = False,
253
+ ) -> DNSRecord:
254
+ # Native PUT-equivalent: avoids the delete+recreate roundtrip and
255
+ # preserves the record id so callers tracking ids stay consistent.
256
+ self._ensure_login()
257
+ rtype = record_type.upper()
258
+ ttl = max(int(ttl), MIN_TTL)
259
+ fqdn = self._short_to_fqdn(name, zone_id)
260
+ params: dict[str, Any] = {
261
+ "id": int(record_id),
262
+ "name": fqdn,
263
+ "type": rtype,
264
+ "content": value,
265
+ "ttl": ttl,
266
+ }
267
+ if rtype == "MX":
268
+ head, _, tail = value.strip().partition(" ")
269
+ if head.isdigit() and tail:
270
+ params["prio"] = int(head)
271
+ params["content"] = tail
272
+ self._call("nameserver.updateRecord", params)
273
+ logger.info("INWX: updated record id=%s -> %s %s", record_id, rtype, params["content"])
274
+ return DNSRecord(
275
+ name=self._fqdn_to_short(fqdn, zone_id),
276
+ type=rtype,
277
+ value=params["content"],
278
+ ttl=ttl,
279
+ id=record_id,
280
+ fqdn=fqdn,
281
+ )
282
+
283
+ def delete_record(self, zone_id: str, record_id: str) -> None:
284
+ self._ensure_login()
285
+ try:
286
+ self._call("nameserver.deleteRecord", {"id": int(record_id)})
287
+ except KeyError:
288
+ # Already gone -- treat as success.
289
+ return
290
+ logger.info("INWX: deleted record id=%s", record_id)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "granny-devops"
3
- version = "0.5.0"
3
+ version = "0.6.0"
4
4
  description = "Cloud tools collection -- AWS infrastructure, CDN, and DevOps automation"
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
File without changes
File without changes
File without changes