forktex-cloud 0.2.3__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.
@@ -0,0 +1,599 @@
1
+ # Copyright (C) 2026 FORKTEX S.R.L.
2
+ #
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-ForkTex-Commercial
4
+ #
5
+ # This file is part of forktex-cloud.
6
+ #
7
+ # For commercial licensing -- including use in proprietary products, SaaS
8
+ # deployments, or any context where AGPL obligations cannot be met -- you
9
+ # MUST obtain a commercial license from FORKTEX S.R.L. (info@forktex.com).
10
+ #
11
+ # This program is free software: you can redistribute it and/or modify
12
+ # it under the terms of the GNU Affero General Public License as published by
13
+ # the Free Software Foundation, either version 3 of the License, or
14
+ # (at your option) any later version.
15
+ #
16
+ # This program is distributed in the hope that it will be useful,
17
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
18
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
+ # GNU Affero General Public License for more details.
20
+ #
21
+ # You should have received a copy of the GNU Affero General Public License
22
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
23
+
24
+ """Typed httpx client for the ForkTex Cloud control plane API.
25
+
26
+ All methods are synchronous — the CLI is sync (click-based).
27
+ Supports dual auth: JWT Bearer token (user login) or X-API-Key (org-scoped CI/CD).
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import base64
33
+ import fnmatch
34
+ import io
35
+ import json
36
+ import tarfile
37
+ from pathlib import Path
38
+ from typing import Any, Callable, Iterator
39
+
40
+ import httpx
41
+
42
+ from forktex_cloud.client.generated import (
43
+ ApiKeyCreated,
44
+ ApiKeyRead,
45
+ EnvironmentRead,
46
+ EventRead,
47
+ HealthRead,
48
+ JobResponse,
49
+ MeResponse,
50
+ OpsResponse,
51
+ OrgRead,
52
+ ProjectRead,
53
+ ServerRead,
54
+ StatusResponse,
55
+ TokenResponse,
56
+ VaultGetResponse,
57
+ WorkspaceRead,
58
+ )
59
+ from forktex_cloud.config import CloudContext
60
+
61
+
62
+ class CloudAPIError(Exception):
63
+ """Raised when the cloud API returns a non-2xx response."""
64
+
65
+ def __init__(self, status_code: int, detail: str) -> None:
66
+ self.status_code = status_code
67
+ self.detail = detail
68
+ super().__init__(f"HTTP {status_code}: {detail}")
69
+
70
+
71
+ class ForktexCloudClient:
72
+ """Synchronous client for the ForkTex Cloud API.
73
+
74
+ Supports dual auth:
75
+ - ``access_token``: JWT Bearer token (preferred, from ``forktex cloud login``)
76
+ - ``account_key``: Org-scoped API key (for CI/CD pipelines)
77
+ """
78
+
79
+ def __init__(
80
+ self,
81
+ base_url: str,
82
+ account_key: str | None = None,
83
+ *,
84
+ access_token: str | None = None,
85
+ org_id: str | None = None,
86
+ timeout: float = 30.0,
87
+ ) -> None:
88
+ self._base_url = base_url.rstrip("/")
89
+ self._account_key = account_key
90
+ self._access_token = access_token
91
+ self._org_id = org_id
92
+ headers: dict[str, str] = {"Content-Type": "application/json"}
93
+ if access_token:
94
+ headers["Authorization"] = f"Bearer {access_token}"
95
+ elif account_key:
96
+ headers["X-API-Key"] = account_key
97
+ self._client = httpx.Client(
98
+ base_url=self._base_url,
99
+ headers=headers,
100
+ timeout=timeout,
101
+ )
102
+
103
+ @classmethod
104
+ def from_context(cls, ctx: CloudContext, **kwargs) -> ForktexCloudClient:
105
+ """Create a client from a CloudContext."""
106
+ ctx.require_connection()
107
+ return cls(
108
+ ctx.controller, # type: ignore[arg-type]
109
+ ctx.account_key,
110
+ access_token=ctx.access_token,
111
+ org_id=ctx.org_id,
112
+ **kwargs,
113
+ )
114
+
115
+ def close(self) -> None:
116
+ self._client.close()
117
+
118
+ def __enter__(self):
119
+ return self
120
+
121
+ def __exit__(self, *args):
122
+ self.close()
123
+
124
+ # ── helpers ──
125
+
126
+ @property
127
+ def _org_prefix(self) -> str:
128
+ """URL prefix for org-scoped routes: /api/org/{org_id}."""
129
+ if not self._org_id:
130
+ raise RuntimeError("No org_id configured. Run: forktex cloud login")
131
+ return f"/api/org/{self._org_id}"
132
+
133
+ def _check(self, resp: httpx.Response) -> httpx.Response:
134
+ if resp.status_code >= 400:
135
+ try:
136
+ body = resp.json()
137
+ detail = body.get("detail", resp.text)
138
+ except Exception:
139
+ detail = resp.text
140
+ raise CloudAPIError(resp.status_code, str(detail))
141
+ return resp
142
+
143
+ # ── Auth (public, no org prefix) ──
144
+
145
+ def register(self, email: str, password: str) -> TokenResponse:
146
+ resp = self._check(
147
+ self._client.post("/api/auth/register", json={"email": email, "password": password})
148
+ )
149
+ return TokenResponse.model_validate(resp.json())
150
+
151
+ def login(self, email: str, password: str) -> TokenResponse:
152
+ resp = self._check(
153
+ self._client.post("/api/auth/login", json={"email": email, "password": password})
154
+ )
155
+ return TokenResponse.model_validate(resp.json())
156
+
157
+ def me(self) -> MeResponse:
158
+ resp = self._check(self._client.get("/api/me"))
159
+ return MeResponse.model_validate(resp.json())
160
+
161
+ # ── Orgs (JWT-authed, no org prefix) ──
162
+
163
+ def list_orgs(self) -> list[OrgRead]:
164
+ resp = self._check(self._client.get("/api/orgs"))
165
+ return [OrgRead.model_validate(o) for o in resp.json()]
166
+
167
+ # ── Health (public, no org prefix) ──
168
+
169
+ def health(self) -> HealthRead:
170
+ resp = self._check(self._client.get("/api/health"))
171
+ return HealthRead.model_validate(resp.json())
172
+
173
+ # ── Projects (org-scoped) ──
174
+
175
+ def list_projects(self) -> list[ProjectRead]:
176
+ resp = self._check(self._client.get(f"{self._org_prefix}/projects"))
177
+ return [ProjectRead.model_validate(p) for p in resp.json()]
178
+
179
+ def create_project(
180
+ self,
181
+ name: str,
182
+ *,
183
+ manifest: str | None = None,
184
+ project_id: str | None = None,
185
+ ) -> ProjectRead:
186
+ body: dict[str, Any] = {"name": name}
187
+ if manifest is not None:
188
+ body["manifest"] = manifest
189
+ if project_id is not None:
190
+ body["project_id"] = project_id
191
+ resp = self._check(self._client.post(f"{self._org_prefix}/projects", json=body))
192
+ return ProjectRead.model_validate(resp.json())
193
+
194
+ def get_project(self, project_id: str) -> ProjectRead:
195
+ resp = self._check(self._client.get(f"{self._org_prefix}/projects/{project_id}"))
196
+ return ProjectRead.model_validate(resp.json())
197
+
198
+ def list_project_environments(self, project_id: str) -> list[EnvironmentRead]:
199
+ resp = self._check(
200
+ self._client.get(f"{self._org_prefix}/projects/{project_id}/environments")
201
+ )
202
+ return [EnvironmentRead.model_validate(e) for e in resp.json()]
203
+
204
+ # ── Servers (org-scoped) ──
205
+
206
+ def list_servers(self) -> list[ServerRead]:
207
+ resp = self._check(self._client.get(f"{self._org_prefix}/servers"))
208
+ return [ServerRead.model_validate(s) for s in resp.json()]
209
+
210
+ def create_server(
211
+ self,
212
+ name: str,
213
+ *,
214
+ flavour: str | None = None,
215
+ region: str | None = None,
216
+ server_type: str | None = None,
217
+ image: str | None = None,
218
+ location: str | None = None,
219
+ project_id: str = "",
220
+ environment: str | None = None,
221
+ ) -> ServerRead:
222
+ body: dict[str, Any] = {"name": name, "project_id": project_id}
223
+ if flavour is not None:
224
+ body["flavour"] = flavour
225
+ if region is not None:
226
+ body["region"] = region
227
+ if server_type is not None:
228
+ body["server_type"] = server_type
229
+ if image is not None:
230
+ body["image"] = image
231
+ if location is not None:
232
+ body["location"] = location
233
+ if environment is not None:
234
+ body["environment"] = environment
235
+ resp = self._check(self._client.post(f"{self._org_prefix}/servers", json=body))
236
+ return ServerRead.model_validate(resp.json())
237
+
238
+ def get_server(self, server_id: str) -> ServerRead:
239
+ resp = self._check(self._client.get(f"{self._org_prefix}/servers/{server_id}"))
240
+ return ServerRead.model_validate(resp.json())
241
+
242
+ def destroy_server(self, server_id: str) -> StatusResponse:
243
+ resp = self._check(self._client.delete(f"{self._org_prefix}/servers/{server_id}"))
244
+ return StatusResponse.model_validate(resp.json())
245
+
246
+ def import_server(
247
+ self,
248
+ name: str,
249
+ host: str,
250
+ *,
251
+ user: str = "root",
252
+ project_id: str | None = None,
253
+ ) -> StatusResponse:
254
+ body: dict[str, Any] = {"name": name, "host": host, "user": user}
255
+ if project_id is not None:
256
+ body["project_id"] = project_id
257
+ resp = self._check(self._client.post(f"{self._org_prefix}/servers/import", json=body))
258
+ return StatusResponse.model_validate(resp.json())
259
+
260
+ def server_status(self, server_id: str) -> dict[str, Any]:
261
+ resp = self._check(self._client.get(f"{self._org_prefix}/servers/{server_id}/status"))
262
+ return resp.json()
263
+
264
+ def server_restart(self, server_id: str, *, service: str | None = None) -> OpsResponse:
265
+ body: dict[str, Any] = {}
266
+ if service is not None:
267
+ body["service"] = service
268
+ resp = self._check(
269
+ self._client.post(f"{self._org_prefix}/servers/{server_id}/restart", json=body)
270
+ )
271
+ return OpsResponse.model_validate(resp.json())
272
+
273
+ def server_exec(self, server_id: str, *, service: str, command: str) -> OpsResponse:
274
+ body = {"service": service, "command": command}
275
+ resp = self._check(
276
+ self._client.post(f"{self._org_prefix}/servers/{server_id}/exec", json=body)
277
+ )
278
+ return OpsResponse.model_validate(resp.json())
279
+
280
+ def server_switch(self, server_id: str, *, component: str, to_color: str) -> OpsResponse:
281
+ body = {"component": component, "to_color": to_color}
282
+ resp = self._check(
283
+ self._client.post(f"{self._org_prefix}/servers/{server_id}/switch", json=body)
284
+ )
285
+ return OpsResponse.model_validate(resp.json())
286
+
287
+ def server_update(self, server_id: str, *, component: str, new_image: str) -> OpsResponse:
288
+ body = {"component": component, "new_image": new_image}
289
+ resp = self._check(
290
+ self._client.post(f"{self._org_prefix}/servers/{server_id}/update", json=body)
291
+ )
292
+ return OpsResponse.model_validate(resp.json())
293
+
294
+ def stream_logs(
295
+ self,
296
+ server_id: str,
297
+ *,
298
+ service: str | None = None,
299
+ lines: int = 50,
300
+ since: str | None = None,
301
+ query: str | None = None,
302
+ ) -> Iterator[str]:
303
+ """Stream server logs via SSE. Yields raw SSE lines.
304
+
305
+ When the server has Loki enabled and ``since`` or ``query`` are
306
+ provided, the API queries Loki's LogQL instead of ``docker logs``.
307
+ """
308
+ params: dict[str, Any] = {"lines": lines}
309
+ if service:
310
+ params["service"] = service
311
+ if since:
312
+ params["since"] = since
313
+ if query:
314
+ params["query"] = query
315
+ with self._client.stream(
316
+ "GET",
317
+ f"{self._org_prefix}/servers/{server_id}/logs",
318
+ params=params,
319
+ ) as resp:
320
+ self._check(resp)
321
+ for line in resp.iter_lines():
322
+ yield line
323
+
324
+ # ── Deploy (org-scoped) ──
325
+
326
+ def deploy(
327
+ self,
328
+ server_id: str,
329
+ *,
330
+ tags: list[str] | None = None,
331
+ service: str | None = None,
332
+ manifest_data: dict | None = None,
333
+ assets_tarball_b64: str | None = None,
334
+ project_dir: Path | None = None,
335
+ ) -> JobResponse:
336
+ body: dict[str, Any] = {"server_id": server_id}
337
+ if tags is not None:
338
+ body["tags"] = tags
339
+ if service is not None:
340
+ body["service"] = service
341
+
342
+ # Load local manifest if project_dir is provided
343
+ if project_dir and not manifest_data:
344
+ manifest_path = project_dir / "forktex.json"
345
+ if manifest_path.exists():
346
+ manifest_data = json.loads(manifest_path.read_text())
347
+
348
+ if manifest_data is not None:
349
+ body["manifest_data"] = manifest_data
350
+
351
+ # Auto-detect and tarball local asset directories from volumes
352
+ if project_dir and manifest_data and not assets_tarball_b64:
353
+ tarball = self._build_assets_tarball(project_dir, manifest_data)
354
+ if tarball:
355
+ body["assets_tarball_b64"] = tarball
356
+ elif assets_tarball_b64 is not None:
357
+ body["assets_tarball_b64"] = assets_tarball_b64
358
+
359
+ resp = self._check(self._client.post(f"{self._org_prefix}/deploy", json=body))
360
+ return JobResponse.model_validate(resp.json())
361
+
362
+ # ── Pipeline: up / down (org-scoped) ──
363
+
364
+ def up(
365
+ self,
366
+ *,
367
+ name: str | None = None,
368
+ flavour: str | None = None,
369
+ region: str | None = None,
370
+ env: str | None = None,
371
+ skip_dns: bool = False,
372
+ skip_ssl: bool = False,
373
+ manifest_data: dict | None = None,
374
+ project_dir: Path | None = None,
375
+ ) -> JobResponse:
376
+ """Trigger the up pipeline via POST {org_prefix}/up.
377
+
378
+ If *project_dir* is provided the local ``forktex.json`` is read and sent
379
+ as ``manifest_data``. Local asset directories referenced as bind-mount
380
+ volumes are auto-tarballed and sent as ``assets_tarball_b64``.
381
+ """
382
+ body: dict[str, Any] = {
383
+ "skip_dns": skip_dns,
384
+ "skip_ssl": skip_ssl,
385
+ }
386
+ if name:
387
+ body["name"] = name
388
+ if flavour:
389
+ body["flavour"] = flavour
390
+ if region:
391
+ body["region"] = region
392
+ if env:
393
+ body["env"] = env
394
+
395
+ # Load local manifest if project_dir is provided
396
+ if project_dir and not manifest_data:
397
+ manifest_path = project_dir / "forktex.json"
398
+ if manifest_path.exists():
399
+ manifest_data = json.loads(manifest_path.read_text())
400
+
401
+ if manifest_data:
402
+ body["manifest_data"] = manifest_data
403
+
404
+ # Auto-detect and tarball local asset directories from volumes
405
+ if project_dir and manifest_data:
406
+ tarball = self._build_assets_tarball(project_dir, manifest_data)
407
+ if tarball:
408
+ body["assets_tarball_b64"] = tarball
409
+
410
+ resp = self._check(self._client.post(f"{self._org_prefix}/up", json=body))
411
+ return JobResponse.model_validate(resp.json())
412
+
413
+ def down(
414
+ self,
415
+ *,
416
+ keep_dns: bool = False,
417
+ name: str | None = None,
418
+ manifest_data: dict | None = None,
419
+ project_dir: Path | None = None,
420
+ ) -> JobResponse:
421
+ """Trigger the down pipeline via POST {org_prefix}/down."""
422
+ body: dict[str, Any] = {"keep_dns": keep_dns}
423
+ if name:
424
+ body["name"] = name
425
+
426
+ # Load local manifest if project_dir is provided
427
+ if project_dir and not manifest_data:
428
+ manifest_path = project_dir / "forktex.json"
429
+ if manifest_path.exists():
430
+ manifest_data = json.loads(manifest_path.read_text())
431
+
432
+ if manifest_data:
433
+ body["manifest_data"] = manifest_data
434
+
435
+ resp = self._check(self._client.post(f"{self._org_prefix}/down", json=body))
436
+ return JobResponse.model_validate(resp.json())
437
+
438
+ @staticmethod
439
+ def _dockerignore_filter(
440
+ directory: Path,
441
+ ) -> Callable[[tarfile.TarInfo], tarfile.TarInfo | None] | None:
442
+ """Parse a .dockerignore file and return a tarfile filter.
443
+
444
+ Returns ``None`` if no ``.dockerignore`` exists in *directory*.
445
+ The returned filter accepts a ``TarInfo`` and returns ``None``
446
+ for entries that should be excluded.
447
+ """
448
+ ignore_file = directory / ".dockerignore"
449
+ if not ignore_file.is_file():
450
+ return None
451
+
452
+ patterns: list[str] = []
453
+ for line in ignore_file.read_text().splitlines():
454
+ line = line.strip()
455
+ if not line or line.startswith("#"):
456
+ continue
457
+ patterns.append(line)
458
+
459
+ if not patterns:
460
+ return None
461
+
462
+ def _filter(info: tarfile.TarInfo) -> tarfile.TarInfo | None:
463
+ # info.name is the arcname (e.g. "client/build/node_modules/...")
464
+ # We need to check path components against patterns
465
+ parts = Path(info.name).parts
466
+ for part in parts:
467
+ for pat in patterns:
468
+ if fnmatch.fnmatch(part, pat):
469
+ return None
470
+ return info
471
+
472
+ return _filter
473
+
474
+ @staticmethod
475
+ def _build_assets_tarball(project_dir: Path, manifest_data: dict) -> str | None:
476
+ """Scan manifest services for bind-mount volumes and build contexts, tarball them."""
477
+ # (local_path, arcname_prefix, source_basename)
478
+ local_entries: list[tuple[Path, str, str]] = []
479
+ for svc in manifest_data.get("services", []):
480
+ svc_id = svc.get("id", "")
481
+ svc_type = svc.get("type", "compute")
482
+ service_prefix = f"services/{svc_id}"
483
+
484
+ # Volume-mounted local files and directories
485
+ for vol in svc.get("volumes", []):
486
+ if isinstance(vol, str) and ":" in vol:
487
+ parts = vol.split(":")
488
+ local_part = parts[0]
489
+ if local_part.startswith("./") or local_part.startswith("/"):
490
+ local_path = (project_dir / local_part).resolve()
491
+ if local_path.is_dir() or local_path.is_file():
492
+ source_basename = Path(local_part).name
493
+ local_entries.append((local_path, service_prefix, source_basename))
494
+
495
+ # Build context: if image has no registry prefix and a matching
496
+ # directory with a Dockerfile exists, include it
497
+ if svc_type != "persistence":
498
+ image = svc.get("image", "")
499
+ if "/" not in image:
500
+ build_dir = project_dir / svc_id
501
+ if (build_dir / "Dockerfile").is_file():
502
+ local_entries.append((build_dir, f"artifacts/{svc_id}", "build"))
503
+
504
+ if not local_entries:
505
+ return None
506
+
507
+ buf = io.BytesIO()
508
+ with tarfile.open(fileobj=buf, mode="w:gz") as tar:
509
+ for local_path, prefix, source_basename in local_entries:
510
+ arcname = f"{prefix}/{source_basename}"
511
+ if local_path.is_file():
512
+ tar.add(str(local_path), arcname=arcname)
513
+ else:
514
+ tar_filter = ForktexCloudClient._dockerignore_filter(local_path)
515
+ tar.add(str(local_path), arcname=arcname, filter=tar_filter)
516
+
517
+ return base64.b64encode(buf.getvalue()).decode("ascii")
518
+
519
+ # ── Validate (org-scoped) ──
520
+
521
+ def validate(self, *, manifest_path: str | None = None) -> dict[str, Any]:
522
+ body: dict[str, Any] = {}
523
+ if manifest_path is not None:
524
+ body["manifest_path"] = manifest_path
525
+ resp = self._check(self._client.post(f"{self._org_prefix}/validate", json=body or None))
526
+ return resp.json()
527
+
528
+ # ── Manifest (org-scoped) ──
529
+
530
+ def get_manifest(self, *, env: str | None = None) -> dict[str, Any]:
531
+ params: dict[str, str] = {}
532
+ if env:
533
+ params["env"] = env
534
+ resp = self._check(self._client.get(f"{self._org_prefix}/manifest", params=params))
535
+ return resp.json()
536
+
537
+ # ── Vault (org-scoped) ──
538
+
539
+ def vault_list(self, *, env: str = "default") -> list[str]:
540
+ resp = self._check(self._client.get(f"{self._org_prefix}/vault", params={"env": env}))
541
+ return resp.json()
542
+
543
+ def vault_get(self, key: str, *, env: str = "default") -> VaultGetResponse:
544
+ resp = self._check(self._client.get(f"{self._org_prefix}/vault/{key}", params={"env": env}))
545
+ return VaultGetResponse.model_validate(resp.json())
546
+
547
+ def vault_set(self, key: str, value: str, *, env: str = "default") -> StatusResponse:
548
+ body = {"key": key, "value": value, "env": env}
549
+ resp = self._check(self._client.post(f"{self._org_prefix}/vault", json=body))
550
+ return StatusResponse.model_validate(resp.json())
551
+
552
+ def vault_delete(self, key: str, *, env: str = "default") -> StatusResponse:
553
+ resp = self._check(
554
+ self._client.delete(f"{self._org_prefix}/vault/{key}", params={"env": env})
555
+ )
556
+ return StatusResponse.model_validate(resp.json())
557
+
558
+ # ── Events (org-scoped) ──
559
+
560
+ def list_events(self, *, project_id: str | None = None) -> list[EventRead]:
561
+ params: dict[str, str] = {}
562
+ if project_id:
563
+ params["project_id"] = project_id
564
+ resp = self._check(self._client.get(f"{self._org_prefix}/events", params=params))
565
+ return [EventRead.model_validate(e) for e in resp.json()]
566
+
567
+ # ── Workspace (org-scoped) ──
568
+
569
+ def get_workspace(self) -> WorkspaceRead:
570
+ resp = self._check(self._client.get(f"{self._org_prefix}/workspace"))
571
+ return WorkspaceRead.model_validate(resp.json())
572
+
573
+ def update_workspace(
574
+ self,
575
+ *,
576
+ current_environment: str | None = None,
577
+ current_server: str | None = None,
578
+ ) -> StatusResponse:
579
+ body: dict[str, Any] = {}
580
+ if current_environment is not None:
581
+ body["current_environment"] = current_environment
582
+ if current_server is not None:
583
+ body["current_server"] = current_server
584
+ resp = self._check(self._client.patch(f"{self._org_prefix}/workspace", json=body))
585
+ return StatusResponse.model_validate(resp.json())
586
+
587
+ # ── API Keys (org-scoped) ──
588
+
589
+ def create_api_key(self, label: str) -> ApiKeyCreated:
590
+ resp = self._check(self._client.post(f"{self._org_prefix}/api-keys", json={"label": label}))
591
+ return ApiKeyCreated.model_validate(resp.json())
592
+
593
+ def list_api_keys(self) -> list[ApiKeyRead]:
594
+ resp = self._check(self._client.get(f"{self._org_prefix}/api-keys"))
595
+ return [ApiKeyRead.model_validate(k) for k in resp.json()]
596
+
597
+ def delete_api_key(self, key_id: str) -> StatusResponse:
598
+ resp = self._check(self._client.delete(f"{self._org_prefix}/api-keys/{key_id}"))
599
+ return StatusResponse.model_validate(resp.json())