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,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)
|