@mriekki/project-forge 4.8.1 → 4.10.0

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.
@@ -0,0 +1,260 @@
1
+ """Minimal Cloudflare API client for Project Forge public-ingress orchestration.
2
+
3
+ The API token is supplied by the caller and is never persisted by this module.
4
+ Only the exact zone/tunnel/DNS surfaces needed by the Deployment Kit are exposed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import urllib.error
11
+ import urllib.parse
12
+ import urllib.request
13
+ from typing import Any
14
+
15
+ from .errors import DeploymentKitError
16
+
17
+ API_BASE = "https://api.cloudflare.com/client/v4"
18
+
19
+
20
+ class CloudflareApiError(DeploymentKitError):
21
+ """Cloudflare API request or response validation failure."""
22
+
23
+
24
+ def _api_error_message(payload: Any) -> str:
25
+ if isinstance(payload, dict):
26
+ errors = payload.get("errors")
27
+ if isinstance(errors, list):
28
+ parts = []
29
+ for item in errors:
30
+ if isinstance(item, dict):
31
+ code = item.get("code")
32
+ message = item.get("message")
33
+ if message:
34
+ parts.append(f"{code}: {message}" if code is not None else str(message))
35
+ if parts:
36
+ return "; ".join(parts)
37
+ return "Cloudflare API returned an unsuccessful response."
38
+
39
+
40
+ def _request(
41
+ method: str,
42
+ path: str,
43
+ *,
44
+ token: str,
45
+ body: dict[str, Any] | None = None,
46
+ query: dict[str, str | int | bool] | None = None,
47
+ timeout: float = 20.0,
48
+ ) -> dict[str, Any]:
49
+ if not isinstance(token, str) or not token.strip():
50
+ raise CloudflareApiError("Cloudflare API token is required.")
51
+ url = API_BASE + path
52
+ if query:
53
+ encoded = urllib.parse.urlencode(
54
+ {key: str(value).lower() if isinstance(value, bool) else str(value) for key, value in query.items()}
55
+ )
56
+ url += "?" + encoded
57
+ data = None
58
+ headers = {
59
+ "Authorization": f"Bearer {token.strip()}",
60
+ "Accept": "application/json",
61
+ "User-Agent": "project-forge-deployment-kit/1",
62
+ }
63
+ if body is not None:
64
+ data = json.dumps(body, separators=(",", ":")).encode("utf-8")
65
+ headers["Content-Type"] = "application/json"
66
+ request = urllib.request.Request(url, data=data, headers=headers, method=method)
67
+ try:
68
+ with urllib.request.urlopen(request, timeout=timeout) as response:
69
+ raw = response.read()
70
+ except urllib.error.HTTPError as error:
71
+ try:
72
+ payload = json.loads(error.read().decode("utf-8", errors="replace"))
73
+ except (json.JSONDecodeError, UnicodeError):
74
+ payload = None
75
+ raise CloudflareApiError(
76
+ f"Cloudflare API {method} {path} failed with HTTP {error.code}: {_api_error_message(payload)}"
77
+ ) from error
78
+ except urllib.error.URLError as error:
79
+ raise CloudflareApiError(f"Cloudflare API {method} {path} could not be reached: {error.reason}") from error
80
+
81
+ try:
82
+ payload = json.loads(raw.decode("utf-8"))
83
+ except (json.JSONDecodeError, UnicodeError) as error:
84
+ raise CloudflareApiError(f"Cloudflare API {method} {path} returned malformed JSON.") from error
85
+ if not isinstance(payload, dict) or payload.get("success") is not True:
86
+ raise CloudflareApiError(_api_error_message(payload))
87
+ return payload
88
+
89
+
90
+ def resolve_zone(token: str, zone_name: str) -> dict[str, Any]:
91
+ """Resolve one exact active Cloudflare zone and its owning account."""
92
+ if not isinstance(zone_name, str) or not zone_name.strip():
93
+ raise CloudflareApiError("Cloudflare zone name is required.")
94
+ normalized = zone_name.strip().lower().rstrip(".")
95
+ payload = _request(
96
+ "GET",
97
+ "/zones",
98
+ token=token,
99
+ query={"name": normalized, "status": "active", "per_page": 50},
100
+ )
101
+ result = payload.get("result")
102
+ if not isinstance(result, list):
103
+ raise CloudflareApiError("Cloudflare zone lookup returned an unexpected result.")
104
+ exact = [
105
+ item for item in result
106
+ if isinstance(item, dict) and str(item.get("name", "")).lower().rstrip(".") == normalized
107
+ ]
108
+ if len(exact) != 1:
109
+ raise CloudflareApiError(
110
+ f"Expected exactly one active Cloudflare zone named {normalized}; found {len(exact)}."
111
+ )
112
+ zone = exact[0]
113
+ account = zone.get("account")
114
+ if not isinstance(account, dict) or not account.get("id"):
115
+ raise CloudflareApiError("Cloudflare zone response did not include its account ID.")
116
+ if not zone.get("id"):
117
+ raise CloudflareApiError("Cloudflare zone response did not include its zone ID.")
118
+ return {
119
+ "zoneId": str(zone["id"]),
120
+ "zoneName": normalized,
121
+ "accountId": str(account["id"]),
122
+ "status": zone.get("status"),
123
+ }
124
+
125
+
126
+ def list_tunnels(token: str, account_id: str, *, name: str | None = None) -> list[dict[str, Any]]:
127
+ query: dict[str, str | int | bool] = {"is_deleted": False, "per_page": 1000}
128
+ if name is not None:
129
+ query["name"] = name
130
+ payload = _request(
131
+ "GET",
132
+ f"/accounts/{urllib.parse.quote(account_id, safe='')}/cfd_tunnel",
133
+ token=token,
134
+ query=query,
135
+ )
136
+ result = payload.get("result")
137
+ if not isinstance(result, list):
138
+ raise CloudflareApiError("Cloudflare tunnel list returned an unexpected result.")
139
+ return [item for item in result if isinstance(item, dict)]
140
+
141
+
142
+ def find_tunnel(token: str, account_id: str, name: str) -> dict[str, Any] | None:
143
+ matches = [
144
+ item for item in list_tunnels(token, account_id, name=name)
145
+ if item.get("name") == name and item.get("deleted_at") in {None, ""}
146
+ ]
147
+ if len(matches) > 1:
148
+ raise CloudflareApiError(f"Multiple active Cloudflare tunnels are named {name}; refusing ambiguous adoption.")
149
+ return matches[0] if matches else None
150
+
151
+
152
+ def create_tunnel(token: str, account_id: str, name: str) -> dict[str, Any]:
153
+ payload = _request(
154
+ "POST",
155
+ f"/accounts/{urllib.parse.quote(account_id, safe='')}/cfd_tunnel",
156
+ token=token,
157
+ body={"name": name, "config_src": "cloudflare"},
158
+ )
159
+ result = payload.get("result")
160
+ if not isinstance(result, dict) or not result.get("id"):
161
+ raise CloudflareApiError("Cloudflare tunnel creation did not return a tunnel ID.")
162
+ if result.get("config_src") not in {None, "cloudflare"}:
163
+ raise CloudflareApiError("Cloudflare created a tunnel with an unexpected configuration source.")
164
+ return result
165
+
166
+
167
+ def get_tunnel_token(token: str, account_id: str, tunnel_id: str) -> str:
168
+ payload = _request(
169
+ "GET",
170
+ f"/accounts/{urllib.parse.quote(account_id, safe='')}/cfd_tunnel/{urllib.parse.quote(tunnel_id, safe='')}/token",
171
+ token=token,
172
+ )
173
+ result = payload.get("result")
174
+ if not isinstance(result, str) or not result.strip():
175
+ raise CloudflareApiError("Cloudflare tunnel-token response was empty.")
176
+ return result.strip()
177
+
178
+
179
+ def get_tunnel_configuration(token: str, account_id: str, tunnel_id: str) -> dict[str, Any]:
180
+ payload = _request(
181
+ "GET",
182
+ f"/accounts/{urllib.parse.quote(account_id, safe='')}/cfd_tunnel/{urllib.parse.quote(tunnel_id, safe='')}/configurations",
183
+ token=token,
184
+ )
185
+ result = payload.get("result")
186
+ if not isinstance(result, dict):
187
+ raise CloudflareApiError("Cloudflare tunnel configuration returned an unexpected result.")
188
+ config = result.get("config")
189
+ if config is None:
190
+ return {"ingress": [{"service": "http_status:404"}]}
191
+ if not isinstance(config, dict):
192
+ raise CloudflareApiError("Cloudflare tunnel configuration 'config' must be an object.")
193
+ return config
194
+
195
+
196
+ def update_tunnel_configuration(
197
+ token: str,
198
+ account_id: str,
199
+ tunnel_id: str,
200
+ config: dict[str, Any],
201
+ ) -> dict[str, Any]:
202
+ payload = _request(
203
+ "PUT",
204
+ f"/accounts/{urllib.parse.quote(account_id, safe='')}/cfd_tunnel/{urllib.parse.quote(tunnel_id, safe='')}/configurations",
205
+ token=token,
206
+ body={"config": config},
207
+ )
208
+ result = payload.get("result")
209
+ if not isinstance(result, dict):
210
+ raise CloudflareApiError("Cloudflare tunnel configuration update returned an unexpected result.")
211
+ returned = result.get("config")
212
+ return returned if isinstance(returned, dict) else config
213
+
214
+
215
+ def list_dns_records(token: str, zone_id: str, *, name: str) -> list[dict[str, Any]]:
216
+ payload = _request(
217
+ "GET",
218
+ f"/zones/{urllib.parse.quote(zone_id, safe='')}/dns_records",
219
+ token=token,
220
+ query={"name": name, "per_page": 100},
221
+ )
222
+ result = payload.get("result")
223
+ if not isinstance(result, list):
224
+ raise CloudflareApiError("Cloudflare DNS lookup returned an unexpected result.")
225
+ return [item for item in result if isinstance(item, dict)]
226
+
227
+
228
+ def create_dns_cname(
229
+ token: str,
230
+ zone_id: str,
231
+ *,
232
+ name: str,
233
+ content: str,
234
+ comment: str,
235
+ ) -> dict[str, Any]:
236
+ payload = _request(
237
+ "POST",
238
+ f"/zones/{urllib.parse.quote(zone_id, safe='')}/dns_records",
239
+ token=token,
240
+ body={
241
+ "type": "CNAME",
242
+ "name": name,
243
+ "content": content,
244
+ "proxied": True,
245
+ "ttl": 1,
246
+ "comment": comment,
247
+ },
248
+ )
249
+ result = payload.get("result")
250
+ if not isinstance(result, dict) or not result.get("id"):
251
+ raise CloudflareApiError("Cloudflare DNS creation did not return a record ID.")
252
+ return result
253
+
254
+
255
+ def delete_dns_record(token: str, zone_id: str, record_id: str) -> None:
256
+ _request(
257
+ "DELETE",
258
+ f"/zones/{urllib.parse.quote(zone_id, safe='')}/dns_records/{urllib.parse.quote(record_id, safe='')}",
259
+ token=token,
260
+ )