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,92 @@
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
+ """forktex_cloud — Standalone Python SDK for the ForkTex Cloud platform.
25
+
26
+ Usage::
27
+
28
+ from forktex_cloud import ForktexCloudClient, CloudContext
29
+
30
+ ctx = CloudContext(controller="https://cloud.forktex.com", account_key="ftx-...")
31
+ with ForktexCloudClient.from_context(ctx) as client:
32
+ projects = client.list_projects()
33
+ servers = client.list_servers()
34
+ """
35
+
36
+ __version__ = "0.2.3"
37
+
38
+ from forktex_cloud import paths
39
+ from forktex_cloud.client.client import CloudAPIError, ForktexCloudClient
40
+ from forktex_cloud.client.generated import (
41
+ SPEC_HASH,
42
+ SPEC_VERSION,
43
+ ApiKeyCreated,
44
+ ApiKeyRead,
45
+ EnvironmentRead,
46
+ EventRead,
47
+ HealthRead,
48
+ JobResponse,
49
+ MeResponse,
50
+ OrgRead,
51
+ ProjectRead,
52
+ ServerRead,
53
+ StatusResponse,
54
+ TokenResponse,
55
+ UserRead,
56
+ VaultGetResponse,
57
+ WorkspaceRead,
58
+ )
59
+ from forktex_cloud.config import CloudContext
60
+ from forktex_cloud.manifest.loader import Manifest, ManifestError
61
+
62
+ __all__ = [
63
+ # Filesystem layout spec (V1)
64
+ "paths",
65
+ # Codegen contract (wire-compatibility markers)
66
+ "SPEC_VERSION",
67
+ "SPEC_HASH",
68
+ # Client
69
+ "ForktexCloudClient",
70
+ "CloudAPIError",
71
+ # Config
72
+ "CloudContext",
73
+ # Manifest
74
+ "Manifest",
75
+ "ManifestError",
76
+ # Models (from OpenAPI codegen — the single source of truth)
77
+ "ApiKeyCreated",
78
+ "ApiKeyRead",
79
+ "EnvironmentRead",
80
+ "EventRead",
81
+ "HealthRead",
82
+ "JobResponse",
83
+ "MeResponse",
84
+ "OrgRead",
85
+ "ProjectRead",
86
+ "ServerRead",
87
+ "StatusResponse",
88
+ "TokenResponse",
89
+ "UserRead",
90
+ "VaultGetResponse",
91
+ "WorkspaceRead",
92
+ ]
@@ -0,0 +1,24 @@
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
+ """Bridge modules: manifest-to-Docker Compose for local dev."""
@@ -0,0 +1,295 @@
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
+ """Manifest -> local docker-compose generator.
25
+
26
+ Generates a simple local-oriented docker-compose.local.yml from a forktex manifest.
27
+ No proxy, no blue-green, no SSL -- just plain containers with direct port mapping.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import shutil
33
+ from pathlib import Path
34
+ from typing import Any
35
+
36
+ from forktex_cloud import paths
37
+ from forktex_cloud.bridge.persistence_defaults import detect_persistence_defaults
38
+ from forktex_cloud.manifest.loader import Manifest
39
+ from forktex_cloud.secrets.base import SecretsProvider
40
+
41
+ # Ports reserved by observability services (Loki)
42
+ _OBSERVABILITY_PORTS = {3100}
43
+
44
+
45
+ def _allocate_host_ports(
46
+ services: list[dict[str, Any]],
47
+ *,
48
+ reserved: set[int] | None = None,
49
+ ) -> dict[str, int]:
50
+ """Allocate host ports for compute services, avoiding conflicts."""
51
+ used: set[int] = set(reserved or ())
52
+ mapping: dict[str, int] = {}
53
+
54
+ # First pass: claim explicit hostPort values (any type)
55
+ for svc in services:
56
+ hp = svc.get("hostPort")
57
+ if hp is not None:
58
+ mapping[svc["id"]] = int(hp)
59
+ used.add(int(hp))
60
+
61
+ # Second pass: auto-assign only compute services
62
+ for svc in services:
63
+ sid = svc["id"]
64
+ if sid in mapping:
65
+ continue
66
+ svc_type = svc.get("type", "compute")
67
+ if svc_type != "compute":
68
+ continue
69
+ port = svc.get("port", 80)
70
+ candidate = 8080 if port == 80 else port
71
+ while candidate in used:
72
+ candidate += 1
73
+ mapping[sid] = candidate
74
+ used.add(candidate)
75
+
76
+ return mapping
77
+
78
+
79
+ def _templates_dir() -> Path:
80
+ """Locate the forktex/cloud/templates/ directory."""
81
+ return Path(__file__).resolve().parent.parent / "templates"
82
+
83
+
84
+ def _write_observability_configs(project_root: Path) -> Path:
85
+ """Copy observability config files to the canonical observability dir."""
86
+ out_dir = paths.observability_dir(project_root)
87
+ out_dir.mkdir(parents=True, exist_ok=True)
88
+ src_dir = _templates_dir() / "observability"
89
+ if src_dir.is_dir():
90
+ for src_file in src_dir.iterdir():
91
+ if src_file.is_file():
92
+ shutil.copy2(src_file, out_dir / src_file.name)
93
+ return out_dir
94
+
95
+
96
+ def _add_observability_services(
97
+ services: dict[str, Any],
98
+ named_volumes: dict[str, Any],
99
+ project_root: Path,
100
+ ) -> None:
101
+ """Add Loki + Promtail services."""
102
+ obs_dir = _write_observability_configs(project_root)
103
+
104
+ services["loki"] = {
105
+ "image": "grafana/loki:2.9.0",
106
+ "ports": ["3100:3100"],
107
+ "volumes": [
108
+ f"../{obs_dir.relative_to(project_root)}/loki.yml:/etc/loki/local-config.yaml:ro",
109
+ "loki-data:/loki",
110
+ ],
111
+ "healthcheck": {
112
+ "test": [
113
+ "CMD-SHELL",
114
+ "wget --quiet --tries=1 --output-document=- http://localhost:3100/ready || exit 1",
115
+ ],
116
+ "interval": "10s",
117
+ "timeout": "5s",
118
+ "retries": 5,
119
+ },
120
+ "networks": ["forktex"],
121
+ }
122
+ named_volumes["loki-data"] = None
123
+
124
+ services["promtail"] = {
125
+ "image": "grafana/promtail:2.9.0",
126
+ "volumes": [
127
+ f"../{obs_dir.relative_to(project_root)}/promtail.yml:/etc/promtail/config.yml:ro",
128
+ "/var/run/docker.sock:/var/run/docker.sock:ro",
129
+ "/var/lib/docker/containers:/var/lib/docker/containers:ro",
130
+ ],
131
+ "depends_on": {
132
+ "loki": {"condition": "service_healthy"},
133
+ },
134
+ "networks": ["forktex"],
135
+ }
136
+
137
+
138
+ def local_compose_from_manifest(
139
+ manifest: Manifest,
140
+ project_root: Path,
141
+ *,
142
+ secrets_provider: SecretsProvider | None = None,
143
+ observability: bool = True,
144
+ ) -> dict[str, Any]:
145
+ """Build a docker-compose dict suitable for local development."""
146
+ services: dict[str, Any] = {}
147
+ named_volumes: dict[str, Any] = {}
148
+ env_name = getattr(manifest, "env_name", None) or "default"
149
+
150
+ local_services = manifest.services_for_env(env=manifest.env_name or "local")
151
+
152
+ reserved = _OBSERVABILITY_PORTS if observability else set()
153
+ host_ports = _allocate_host_ports(local_services, reserved=reserved)
154
+
155
+ persistence_ids: list[str] = []
156
+ for svc_def in local_services:
157
+ if svc_def.get("type") == "persistence":
158
+ persistence_ids.append(svc_def["id"])
159
+
160
+ for svc_def in local_services:
161
+ sid = svc_def["id"]
162
+ image = svc_def.get("image", "")
163
+ port = svc_def.get("port", 80)
164
+ svc_type = svc_def.get("type", "compute")
165
+
166
+ svc: dict[str, Any] = {"image": image}
167
+
168
+ if svc_type == "compute":
169
+ build_cfg = svc_def.get("build")
170
+ if build_cfg and isinstance(build_cfg, dict):
171
+ # Use explicit build config from manifest overlay
172
+ build_entry: dict[str, str] = {}
173
+ ctx = build_cfg.get("context", f"./{sid}")
174
+ # Rewrite relative context to be relative to .forktex/ dir
175
+ if ctx.startswith("./"):
176
+ build_entry["context"] = f"../{ctx[2:]}"
177
+ else:
178
+ build_entry["context"] = ctx
179
+ if build_cfg.get("dockerfile"):
180
+ build_entry["dockerfile"] = build_cfg["dockerfile"]
181
+ svc["build"] = build_entry
182
+ else:
183
+ dockerfile = project_root / sid / "Dockerfile"
184
+ if dockerfile.is_file():
185
+ svc["build"] = {"context": f"../{sid}"}
186
+
187
+ if sid in host_ports:
188
+ host_port = host_ports[sid]
189
+ svc["ports"] = [f"{host_port}:{port}"]
190
+
191
+ if svc_type in ("persistence", "observability"):
192
+ svc["restart"] = "always"
193
+
194
+ if svc_type == "persistence":
195
+ defaults = detect_persistence_defaults(image)
196
+ if defaults and "healthcheck" in defaults:
197
+ svc["healthcheck"] = defaults["healthcheck"]
198
+
199
+ if svc_def.get("healthcheck"):
200
+ svc["healthcheck"] = svc_def["healthcheck"]
201
+
202
+ comp_env = svc_def.get("environment")
203
+ if comp_env:
204
+ comp_env = dict(comp_env) if isinstance(comp_env, dict) else list(comp_env)
205
+ if isinstance(comp_env, dict) and secrets_provider:
206
+ from forktex_cloud.secrets.resolver import (
207
+ has_vault_references,
208
+ resolve_secrets,
209
+ )
210
+
211
+ if has_vault_references(comp_env):
212
+ comp_env = resolve_secrets(comp_env, secrets_provider, env_name)
213
+ svc["environment"] = comp_env
214
+
215
+ vols = svc_def.get("volumes")
216
+ if vols:
217
+ rewritten: list[str] = []
218
+ for v in vols:
219
+ if isinstance(v, str) and ":" in v:
220
+ src = v.split(":")[0]
221
+ rest = v[len(src) :]
222
+ if src.startswith("./"):
223
+ rewritten.append(f"../{src[2:]}{rest}")
224
+ elif src.startswith("/") or not src.startswith("."):
225
+ rewritten.append(v)
226
+ if not src.startswith("/"):
227
+ named_volumes[src] = None
228
+ else:
229
+ rewritten.append(v)
230
+ else:
231
+ rewritten.append(v)
232
+ svc["volumes"] = rewritten
233
+ elif svc_type == "persistence":
234
+ defaults = detect_persistence_defaults(image)
235
+ if defaults and defaults.get("default_volume"):
236
+ vol_name = f"{sid}-data"
237
+ svc["volumes"] = [f"{vol_name}:{defaults['default_volume']}"]
238
+ named_volumes[vol_name] = None
239
+
240
+ cmd = svc_def.get("command")
241
+ if cmd:
242
+ svc["command"] = cmd
243
+
244
+ if svc_type == "compute" and persistence_ids:
245
+ depends: dict[str, Any] = {}
246
+ for pid in persistence_ids:
247
+ # Use service_healthy only if the persistence service has a healthcheck
248
+ psvc = services.get(pid, {})
249
+ if "healthcheck" in psvc:
250
+ depends[pid] = {"condition": "service_healthy"}
251
+ else:
252
+ depends[pid] = {"condition": "service_started"}
253
+ svc["depends_on"] = depends
254
+
255
+ explicit_deps = svc_def.get("depends_on")
256
+ if explicit_deps:
257
+ svc["depends_on"] = list(explicit_deps)
258
+
259
+ svc["networks"] = ["forktex"]
260
+ services[sid] = svc
261
+
262
+ if observability:
263
+ _add_observability_services(services, named_volumes, project_root)
264
+
265
+ compose: dict[str, Any] = {"services": services}
266
+
267
+ if named_volumes:
268
+ compose["volumes"] = {name: {} for name in named_volumes}
269
+
270
+ compose["networks"] = {"forktex": {}}
271
+
272
+ return compose
273
+
274
+
275
+ def write_local_compose(
276
+ manifest: Manifest,
277
+ project_root: Path,
278
+ *,
279
+ secrets_provider: SecretsProvider | None = None,
280
+ observability: bool = True,
281
+ ) -> Path:
282
+ """Generate the local docker-compose file for the project and return its path."""
283
+ import yaml
284
+
285
+ compose = local_compose_from_manifest(
286
+ manifest,
287
+ project_root,
288
+ secrets_provider=secrets_provider,
289
+ observability=observability,
290
+ )
291
+ paths.ensure_project_dirs(project_root)
292
+ out_path = paths.compose_path(project_root, "local")
293
+ with open(out_path, "w") as f:
294
+ yaml.dump(compose, f, default_flow_style=False, sort_keys=False)
295
+ return out_path
@@ -0,0 +1,62 @@
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
+ """ANSI color formatting for log output."""
25
+
26
+ from __future__ import annotations
27
+
28
+ import time
29
+
30
+ # 8 distinct ANSI colors for service names
31
+ COLORS = [
32
+ "\033[36m", # cyan
33
+ "\033[32m", # green
34
+ "\033[33m", # yellow
35
+ "\033[35m", # magenta
36
+ "\033[34m", # blue
37
+ "\033[31m", # red
38
+ "\033[37m", # white
39
+ "\033[96m", # bright cyan
40
+ ]
41
+
42
+ DIM = "\033[2m"
43
+ RESET = "\033[0m"
44
+
45
+
46
+ def assign_colors(service_ids: list[str]) -> dict[str, str]:
47
+ """Assign a distinct ANSI color to each service (round-robin)."""
48
+ return {sid: COLORS[i % len(COLORS)] for i, sid in enumerate(sorted(service_ids))}
49
+
50
+
51
+ def format_line(
52
+ ts_ns: int,
53
+ service: str,
54
+ line: str,
55
+ color: str,
56
+ max_name_len: int,
57
+ ) -> str:
58
+ """Format a single log line with timestamp, colored service name, and separator."""
59
+ t = time.gmtime(ts_ns // 1_000_000_000)
60
+ ts_str = time.strftime("%H:%M:%S", t)
61
+ padded = service.ljust(max_name_len)
62
+ return f"{DIM}{ts_str}{RESET} {color}{padded}{RESET} {DIM}|{RESET} {line}"
@@ -0,0 +1,109 @@
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
+ """Thin Loki HTTP API client using stdlib only."""
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import time
30
+ import urllib.error
31
+ import urllib.parse
32
+ import urllib.request
33
+ from typing import Iterator
34
+
35
+
36
+ def loki_ready(base_url: str = "http://localhost:3100") -> bool:
37
+ """Return True if Loki is reachable and ready."""
38
+ try:
39
+ req = urllib.request.Request(f"{base_url}/ready", method="GET")
40
+ with urllib.request.urlopen(req, timeout=2) as resp:
41
+ return resp.status == 200
42
+ except (urllib.error.URLError, OSError):
43
+ return False
44
+
45
+
46
+ def build_logql(services: list[str] | None = None) -> str:
47
+ """Build a LogQL stream selector."""
48
+ if not services:
49
+ return '{job="docker"}'
50
+ if len(services) == 1:
51
+ return f'{{service="{services[0]}"}}'
52
+ return '{service=~"' + "|".join(services) + '"}'
53
+
54
+
55
+ def _parse_streams(data: dict) -> list[tuple[int, str, str]]:
56
+ """Parse Loki query_range response into (timestamp_ns, service, line) tuples."""
57
+ entries: list[tuple[int, str, str]] = []
58
+ for stream in data.get("data", {}).get("result", []):
59
+ labels = stream.get("stream", {})
60
+ service = labels.get("service", labels.get("container", "unknown"))
61
+ for ts_ns_str, line in stream.get("values", []):
62
+ entries.append((int(ts_ns_str), service, line))
63
+ entries.sort(key=lambda e: e[0])
64
+ return entries
65
+
66
+
67
+ def query_range(
68
+ base_url: str,
69
+ logql: str,
70
+ start_ns: int,
71
+ end_ns: int,
72
+ limit: int = 5000,
73
+ ) -> list[tuple[int, str, str]]:
74
+ """Query Loki for log entries in a time range."""
75
+ params = urllib.parse.urlencode(
76
+ {
77
+ "query": logql,
78
+ "start": str(start_ns),
79
+ "end": str(end_ns),
80
+ "limit": str(limit),
81
+ "direction": "forward",
82
+ }
83
+ )
84
+ url = f"{base_url}/loki/api/v1/query_range?{params}"
85
+ req = urllib.request.Request(url, method="GET")
86
+ with urllib.request.urlopen(req, timeout=10) as resp:
87
+ body = json.loads(resp.read().decode())
88
+ return _parse_streams(body)
89
+
90
+
91
+ def tail(
92
+ base_url: str,
93
+ logql: str,
94
+ start_ns: int,
95
+ poll_interval: float = 2.0,
96
+ ) -> Iterator[tuple[int, str, str]]:
97
+ """Poll Loki for new entries, yielding (timestamp_ns, service, line)."""
98
+ last_ts = start_ns
99
+ while True:
100
+ now_ns = int(time.time() * 1_000_000_000)
101
+ try:
102
+ entries = query_range(base_url, logql, last_ts + 1, now_ns)
103
+ except (urllib.error.URLError, OSError):
104
+ return
105
+ for ts_ns, service, line in entries:
106
+ yield (ts_ns, service, line)
107
+ if ts_ns > last_ts:
108
+ last_ts = ts_ns
109
+ time.sleep(poll_interval)