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.
- forktex_cloud/__init__.py +92 -0
- forktex_cloud/bridge/__init__.py +24 -0
- forktex_cloud/bridge/local_compose.py +295 -0
- forktex_cloud/bridge/log_formatter.py +62 -0
- forktex_cloud/bridge/loki.py +109 -0
- forktex_cloud/bridge/persistence_defaults.py +120 -0
- forktex_cloud/client/__init__.py +83 -0
- forktex_cloud/client/client.py +599 -0
- forktex_cloud/client/generated/__init__.py +1057 -0
- forktex_cloud/config.py +63 -0
- forktex_cloud/manifest/__init__.py +58 -0
- forktex_cloud/manifest/errors.py +34 -0
- forktex_cloud/manifest/loader.py +296 -0
- forktex_cloud/manifest/merge.py +52 -0
- forktex_cloud/manifest/schema.py +88 -0
- forktex_cloud/paths.py +297 -0
- forktex_cloud/scaffold/__init__.py +24 -0
- forktex_cloud/scaffold/templates.py +143 -0
- forktex_cloud/secrets/__init__.py +24 -0
- forktex_cloud/secrets/base.py +52 -0
- forktex_cloud/secrets/factory.py +57 -0
- forktex_cloud/secrets/fernet.py +96 -0
- forktex_cloud/secrets/resolver.py +59 -0
- forktex_cloud/templates/observability/loki.yml +55 -0
- forktex_cloud/templates/observability/promtail.yml +46 -0
- forktex_cloud-0.2.3.dist-info/METADATA +226 -0
- forktex_cloud-0.2.3.dist-info/RECORD +30 -0
- forktex_cloud-0.2.3.dist-info/WHEEL +4 -0
- forktex_cloud-0.2.3.dist-info/licenses/LICENSE +45 -0
- forktex_cloud-0.2.3.dist-info/licenses/NOTICE +23 -0
|
@@ -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())
|