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.
- {granny_devops-0.5.0 → granny_devops-0.6.0}/PKG-INFO +1 -1
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/__init__.py +1 -1
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cli/create.py +1 -0
- granny_devops-0.6.0/granny/create/setup_scaleway_container.py +306 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/credentials/secrets.py +5 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/dns/factory.py +7 -0
- granny_devops-0.6.0/granny/dns/inwx.py +290 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/pyproject.toml +1 -1
- {granny_devops-0.5.0 → granny_devops-0.6.0}/.gitignore +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/LICENSE +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/README.md +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/analyze/__init__.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/analyze/lambdas.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/analyze/vpcs.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cdn/__init__.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cdn/bunny.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cli/__init__.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cli/analyze.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cli/cdn.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cli/cloudflare.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cli/credentials.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cli/dns.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cli/docker.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cli/edge.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cli/email.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cli/main.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cli/serverless.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cli/storage.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cloudflare/__init__.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cloudflare/d1.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cloudflare/r2.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/cloudflare/workers.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/create/__init__.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/create/auto_certificate.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/create/cloudfront-security-headers.js +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/create/manage-dns.sh +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/create/manage_mailjet_contacts.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/create/registrars.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/create/setup_aws_cloudfront.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/create/setup_bunny_edge_script.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/create/setup_bunny_storage.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/create/setup_cognito_identity_pool.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/create/setup_hetzner_bunny.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/create/setup_mailjet_dns.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/create/setup_private_cdn.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/create/setup_s3_website.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/create/setup_scaleway_faas.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/create/setup_workmail.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/create/www-redirect-function.js +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/credentials/__init__.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/dns/__init__.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/dns/base.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/dns/bunny.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/dns/cloudflare.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/dns/cloudns.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/dns/desec.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/dns/hetzner.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/dns/manual.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/dns/records.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/docker/__init__.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/docker/build_base.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/edge/__init__.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/edge/bunny.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/email/__init__.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/email/mailjet.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/email/mailjet_contacts.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/email/ses_forwarding.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/email/workmail.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/report.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/serverless/__init__.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/serverless/scaleway.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/storage/__init__.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/storage/aws.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/storage/bunny.py +0 -0
- {granny_devops-0.5.0 → granny_devops-0.6.0}/granny/storage/hetzner.py +0 -0
|
@@ -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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|