forktex-cloud 0.5.0__tar.gz → 2.0.0__tar.gz
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-0.5.0 → forktex_cloud-2.0.0}/PKG-INFO +2 -2
- {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/README.md +1 -1
- {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/pyproject.toml +1 -1
- forktex_cloud-2.0.0/src/forktex_cloud/__init__.py +148 -0
- {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/bridge/local_compose.py +88 -52
- {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/client/client.py +63 -2
- {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/client/generated/__init__.py +550 -195
- forktex_cloud-2.0.0/src/forktex_cloud/manifest/__init__.py +20 -0
- {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/manifest/loader.py +74 -43
- forktex_cloud-2.0.0/src/forktex_cloud/manifest/schema.py +27 -0
- forktex_cloud-2.0.0/src/forktex_cloud/scaffold/templates.py +73 -0
- {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/secrets/factory.py +6 -9
- forktex_cloud-0.5.0/src/forktex_cloud/__init__.py +0 -76
- forktex_cloud-0.5.0/src/forktex_cloud/manifest/__init__.py +0 -35
- forktex_cloud-0.5.0/src/forktex_cloud/manifest/schema.py +0 -65
- forktex_cloud-0.5.0/src/forktex_cloud/paths.py +0 -274
- forktex_cloud-0.5.0/src/forktex_cloud/scaffold/templates.py +0 -120
- {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/LICENSE +0 -0
- {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/bridge/__init__.py +0 -0
- {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/bridge/log_formatter.py +0 -0
- {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/bridge/loki.py +0 -0
- {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/bridge/persistence_defaults.py +0 -0
- {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/client/__init__.py +0 -0
- {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/config.py +0 -0
- {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/manifest/errors.py +0 -0
- {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/manifest/merge.py +0 -0
- {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/scaffold/__init__.py +0 -0
- {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/secrets/__init__.py +0 -0
- {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/secrets/base.py +0 -0
- {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/secrets/fernet.py +0 -0
- {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/secrets/resolver.py +0 -0
- {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/templates/observability/loki.yml +0 -0
- {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/templates/observability/promtail.yml +0 -0
- {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/vpn/__init__.py +0 -0
- {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/vpn/local.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: forktex-cloud
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 2.0.0
|
|
4
4
|
Summary: Typed Python SDK for the ForkTex Cloud platform — provision, deploy, and manage VPS-backed apps via a declarative manifest.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -153,7 +153,7 @@ client.vault_delete("POSTGRES_PASSWORD")
|
|
|
153
153
|
| Module | Purpose |
|
|
154
154
|
|---|---|
|
|
155
155
|
| `forktex_cloud.client` | Typed sync httpx client (`Cloud`) + all OpenAPI-codegenned Pydantic models (`ServerRead`, `ProjectRead`, `EventRead`, `VaultGetResponse`, ...) |
|
|
156
|
-
| `forktex_cloud.manifest` | `Manifest` loader, discriminated-union schema (
|
|
156
|
+
| `forktex_cloud.manifest` | `Manifest` loader, discriminated-union schema (v1), deep-merge for env overlays, `ManifestError` |
|
|
157
157
|
| `forktex_cloud.config` | `CloudContext` — controller URL, JWT / account-key, current org + project keys |
|
|
158
158
|
| `forktex_cloud.scaffold` | `forktex cloud init` template generator (ProjectDeployment / StaticSite / SingleContainer / NativeBuild) |
|
|
159
159
|
| `forktex_cloud.bridge` | docker-compose generator (local mode), Loki config, log formatters used by `forktex cloud apply --env local` |
|
|
@@ -119,7 +119,7 @@ client.vault_delete("POSTGRES_PASSWORD")
|
|
|
119
119
|
| Module | Purpose |
|
|
120
120
|
|---|---|
|
|
121
121
|
| `forktex_cloud.client` | Typed sync httpx client (`Cloud`) + all OpenAPI-codegenned Pydantic models (`ServerRead`, `ProjectRead`, `EventRead`, `VaultGetResponse`, ...) |
|
|
122
|
-
| `forktex_cloud.manifest` | `Manifest` loader, discriminated-union schema (
|
|
122
|
+
| `forktex_cloud.manifest` | `Manifest` loader, discriminated-union schema (v1), deep-merge for env overlays, `ManifestError` |
|
|
123
123
|
| `forktex_cloud.config` | `CloudContext` — controller URL, JWT / account-key, current org + project keys |
|
|
124
124
|
| `forktex_cloud.scaffold` | `forktex cloud init` template generator (ProjectDeployment / StaticSite / SingleContainer / NativeBuild) |
|
|
125
125
|
| `forktex_cloud.bridge` | docker-compose generator (local mode), Loki config, log formatters used by `forktex cloud apply --env local` |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "forktex-cloud"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "2.0.0"
|
|
4
4
|
description = "Typed Python SDK for the ForkTex Cloud platform — provision, deploy, and manage VPS-backed apps via a declarative manifest."
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "ForkTex", email = "info@forktex.com"}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""forktex_cloud — Standalone Python SDK for the ForkTex Cloud platform.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
from forktex_cloud import Cloud
|
|
6
|
+
|
|
7
|
+
with Cloud("https://cloud.forktex.com", account_key="ftx-...") as cloud:
|
|
8
|
+
projects = cloud.list_projects()
|
|
9
|
+
servers = cloud.list_servers()
|
|
10
|
+
|
|
11
|
+
Or, when you already hold a ``CloudContext``::
|
|
12
|
+
|
|
13
|
+
from forktex_cloud import Cloud, CloudContext
|
|
14
|
+
|
|
15
|
+
ctx = CloudContext(controller="https://cloud.forktex.com", account_key="ftx-...")
|
|
16
|
+
with Cloud.from_context(ctx) as cloud:
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
Loading model — lazy
|
|
20
|
+
--------------------
|
|
21
|
+
|
|
22
|
+
The SDK is **filesystem-independent**: it deals in data (manifests, compose
|
|
23
|
+
dicts, cipher bytes), never in ``.forktex/`` paths — forktex-py owns the
|
|
24
|
+
on-disk layout. The HTTP client (``Cloud``), the auth/config types
|
|
25
|
+
(``CloudContext``), the manifest loader, and the 17 OpenAPI-codegen models are
|
|
26
|
+
loaded **lazily** via PEP 562 ``__getattr__`` on first access, keeping
|
|
27
|
+
``import forktex_cloud`` cheap. The public surface is unchanged:
|
|
28
|
+
``from forktex_cloud import Cloud`` still works.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from typing import TYPE_CHECKING, Any
|
|
34
|
+
|
|
35
|
+
__version__ = "2.0.0"
|
|
36
|
+
|
|
37
|
+
# ── Lazy attribute map ──────────────────────────────────────────────────────
|
|
38
|
+
#
|
|
39
|
+
# Name → (submodule, attribute). Anything not listed raises AttributeError.
|
|
40
|
+
# Codegen models share one submodule, so first access pays once — subsequent
|
|
41
|
+
# model lookups hit the module-level cache populated by __getattr__.
|
|
42
|
+
_LAZY_ATTRS: dict[str, tuple[str, str]] = {
|
|
43
|
+
# Client (httpx-backed, heavy)
|
|
44
|
+
"Cloud": ("forktex_cloud.client.client", "Cloud"),
|
|
45
|
+
"CloudAPIError": ("forktex_cloud.client.client", "CloudAPIError"),
|
|
46
|
+
# Config
|
|
47
|
+
"CloudContext": ("forktex_cloud.config", "CloudContext"),
|
|
48
|
+
# Manifest
|
|
49
|
+
"Manifest": ("forktex_cloud.manifest.loader", "Manifest"),
|
|
50
|
+
"ManifestError": ("forktex_cloud.manifest.loader", "ManifestError"),
|
|
51
|
+
# Codegen contract + OpenAPI models (all from forktex_cloud.client.generated)
|
|
52
|
+
"SPEC_HASH": ("forktex_cloud.client.generated", "SPEC_HASH"),
|
|
53
|
+
"SPEC_VERSION": ("forktex_cloud.client.generated", "SPEC_VERSION"),
|
|
54
|
+
"ApiKeyCreated": ("forktex_cloud.client.generated", "ApiKeyCreated"),
|
|
55
|
+
"ApiKeyRead": ("forktex_cloud.client.generated", "ApiKeyRead"),
|
|
56
|
+
"AuditEventRead": ("forktex_cloud.client.generated", "AuditEventRead"),
|
|
57
|
+
"EnvironmentRead": ("forktex_cloud.client.generated", "EnvironmentRead"),
|
|
58
|
+
"HealthRead": ("forktex_cloud.client.generated", "HealthRead"),
|
|
59
|
+
"JobResponse": ("forktex_cloud.client.generated", "JobResponse"),
|
|
60
|
+
"MeResponse": ("forktex_cloud.client.generated", "MeResponse"),
|
|
61
|
+
"OrgRead": ("forktex_cloud.client.generated", "OrgRead"),
|
|
62
|
+
"ProjectRead": ("forktex_cloud.client.generated", "ProjectRead"),
|
|
63
|
+
"ServerRead": ("forktex_cloud.client.generated", "ServerRead"),
|
|
64
|
+
"StatusResponse": ("forktex_cloud.client.generated", "StatusResponse"),
|
|
65
|
+
"TokenResponse": ("forktex_cloud.client.generated", "TokenResponse"),
|
|
66
|
+
"UserRead": ("forktex_cloud.client.generated", "UserRead"),
|
|
67
|
+
"VaultGetResponse": ("forktex_cloud.client.generated", "VaultGetResponse"),
|
|
68
|
+
"WorkspaceRead": ("forktex_cloud.client.generated", "WorkspaceRead"),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def __getattr__(name: str) -> Any:
|
|
73
|
+
"""PEP 562 lazy attribute resolver — imports a submodule on first access.
|
|
74
|
+
|
|
75
|
+
Once resolved, the attribute is bound on the module so subsequent accesses
|
|
76
|
+
skip this function entirely (Python looks it up in ``globals()`` first).
|
|
77
|
+
"""
|
|
78
|
+
if name in _LAZY_ATTRS:
|
|
79
|
+
import importlib
|
|
80
|
+
|
|
81
|
+
module_path, attr_name = _LAZY_ATTRS[name]
|
|
82
|
+
value = getattr(importlib.import_module(module_path), attr_name)
|
|
83
|
+
globals()[name] = value
|
|
84
|
+
return value
|
|
85
|
+
raise AttributeError(f"module 'forktex_cloud' has no attribute {name!r}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def __dir__() -> list[str]:
|
|
89
|
+
"""Include lazy attributes in ``dir()`` so REPL completion still works."""
|
|
90
|
+
return sorted(set(__all__) | set(globals()))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# Static type-checker hint — declared as a TYPE_CHECKING block so it costs
|
|
94
|
+
# nothing at runtime but lets editors / mypy see the eventual symbol types.
|
|
95
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
96
|
+
from forktex_cloud.client.client import Cloud, CloudAPIError
|
|
97
|
+
from forktex_cloud.client.generated import (
|
|
98
|
+
SPEC_HASH,
|
|
99
|
+
SPEC_VERSION,
|
|
100
|
+
ApiKeyCreated,
|
|
101
|
+
ApiKeyRead,
|
|
102
|
+
AuditEventRead,
|
|
103
|
+
EnvironmentRead,
|
|
104
|
+
HealthRead,
|
|
105
|
+
JobResponse,
|
|
106
|
+
MeResponse,
|
|
107
|
+
OrgRead,
|
|
108
|
+
ProjectRead,
|
|
109
|
+
ServerRead,
|
|
110
|
+
StatusResponse,
|
|
111
|
+
TokenResponse,
|
|
112
|
+
UserRead,
|
|
113
|
+
VaultGetResponse,
|
|
114
|
+
WorkspaceRead,
|
|
115
|
+
)
|
|
116
|
+
from forktex_cloud.config import CloudContext
|
|
117
|
+
from forktex_cloud.manifest.loader import Manifest, ManifestError
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
__all__ = [
|
|
121
|
+
# Codegen contract (wire-compatibility markers) — lazy
|
|
122
|
+
"SPEC_VERSION",
|
|
123
|
+
"SPEC_HASH",
|
|
124
|
+
# Client — lazy
|
|
125
|
+
"Cloud",
|
|
126
|
+
"CloudAPIError",
|
|
127
|
+
# Config — lazy
|
|
128
|
+
"CloudContext",
|
|
129
|
+
# Manifest — lazy
|
|
130
|
+
"Manifest",
|
|
131
|
+
"ManifestError",
|
|
132
|
+
# Models (from OpenAPI codegen — the single source of truth) — lazy
|
|
133
|
+
"ApiKeyCreated",
|
|
134
|
+
"ApiKeyRead",
|
|
135
|
+
"AuditEventRead",
|
|
136
|
+
"EnvironmentRead",
|
|
137
|
+
"HealthRead",
|
|
138
|
+
"JobResponse",
|
|
139
|
+
"MeResponse",
|
|
140
|
+
"OrgRead",
|
|
141
|
+
"ProjectRead",
|
|
142
|
+
"ServerRead",
|
|
143
|
+
"StatusResponse",
|
|
144
|
+
"TokenResponse",
|
|
145
|
+
"UserRead",
|
|
146
|
+
"VaultGetResponse",
|
|
147
|
+
"WorkspaceRead",
|
|
148
|
+
]
|
|
@@ -6,11 +6,9 @@ No proxy, no blue-green, no SSL -- just plain containers with direct port mappin
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
-
import shutil
|
|
10
9
|
from pathlib import Path
|
|
11
10
|
from typing import Any
|
|
12
11
|
|
|
13
|
-
from forktex_cloud import paths
|
|
14
12
|
from forktex_cloud.bridge.persistence_defaults import detect_persistence_defaults
|
|
15
13
|
from forktex_cloud.manifest.loader import Manifest
|
|
16
14
|
from forktex_cloud.secrets.base import SecretsProvider
|
|
@@ -58,31 +56,37 @@ def _templates_dir() -> Path:
|
|
|
58
56
|
return Path(__file__).resolve().parent.parent / "templates"
|
|
59
57
|
|
|
60
58
|
|
|
61
|
-
def
|
|
62
|
-
"""
|
|
63
|
-
|
|
64
|
-
|
|
59
|
+
def render_observability_configs() -> dict[str, str]:
|
|
60
|
+
"""Return the observability config files as ``{filename: content}``.
|
|
61
|
+
|
|
62
|
+
Pure: reads the bundled SDK templates and returns their text. The caller
|
|
63
|
+
(forktex-py) writes them next to the generated compose file. No ``.forktex/``
|
|
64
|
+
knowledge here — the SDK only produces data.
|
|
65
|
+
"""
|
|
66
|
+
out: dict[str, str] = {}
|
|
65
67
|
src_dir = _templates_dir() / "observability"
|
|
66
68
|
if src_dir.is_dir():
|
|
67
|
-
for src_file in src_dir.iterdir():
|
|
69
|
+
for src_file in sorted(src_dir.iterdir()):
|
|
68
70
|
if src_file.is_file():
|
|
69
|
-
|
|
70
|
-
return
|
|
71
|
+
out[src_file.name] = src_file.read_text(encoding="utf-8")
|
|
72
|
+
return out
|
|
71
73
|
|
|
72
74
|
|
|
73
75
|
def _add_observability_services(
|
|
74
76
|
services: dict[str, Any],
|
|
75
77
|
named_volumes: dict[str, Any],
|
|
76
|
-
project_root: Path,
|
|
77
78
|
) -> None:
|
|
78
|
-
"""Add Loki + Promtail services.
|
|
79
|
-
obs_dir = _write_observability_configs(project_root)
|
|
79
|
+
"""Add Loki + Promtail services.
|
|
80
80
|
|
|
81
|
+
The compose file and the ``observability/`` configs are written into the
|
|
82
|
+
same directory by the caller, so the bind mounts are siblings
|
|
83
|
+
(``./observability/...``).
|
|
84
|
+
"""
|
|
81
85
|
services["loki"] = {
|
|
82
86
|
"image": "grafana/loki:2.9.0",
|
|
83
87
|
"ports": ["3100:3100"],
|
|
84
88
|
"volumes": [
|
|
85
|
-
|
|
89
|
+
"./observability/loki.yml:/etc/loki/local-config.yaml:ro",
|
|
86
90
|
"loki-data:/loki",
|
|
87
91
|
],
|
|
88
92
|
"healthcheck": {
|
|
@@ -101,7 +105,7 @@ def _add_observability_services(
|
|
|
101
105
|
services["promtail"] = {
|
|
102
106
|
"image": "grafana/promtail:2.9.0",
|
|
103
107
|
"volumes": [
|
|
104
|
-
|
|
108
|
+
"./observability/promtail.yml:/etc/promtail/config.yml:ro",
|
|
105
109
|
"/var/run/docker.sock:/var/run/docker.sock:ro",
|
|
106
110
|
"/var/lib/docker/containers:/var/lib/docker/containers:ro",
|
|
107
111
|
],
|
|
@@ -118,8 +122,16 @@ def local_compose_from_manifest(
|
|
|
118
122
|
*,
|
|
119
123
|
secrets_provider: SecretsProvider | None = None,
|
|
120
124
|
observability: bool = True,
|
|
125
|
+
root_prefix: str = "../..",
|
|
121
126
|
) -> dict[str, Any]:
|
|
122
|
-
"""Build a docker-compose dict suitable for local development.
|
|
127
|
+
"""Build a docker-compose dict suitable for local development.
|
|
128
|
+
|
|
129
|
+
``root_prefix`` is the relative path from the compose file's directory up to
|
|
130
|
+
the project root. forktex-py writes the compose into ``.forktex/cache/``
|
|
131
|
+
(two levels under the root), so the default is ``../..``; build contexts and
|
|
132
|
+
source bind-mounts are emitted relative to it. (cache-sibling artefacts —
|
|
133
|
+
``./observability``, ``./data`` — are unaffected.)
|
|
134
|
+
"""
|
|
123
135
|
services: dict[str, Any] = {}
|
|
124
136
|
named_volumes: dict[str, Any] = {}
|
|
125
137
|
env_name = getattr(manifest, "env_name", None) or "default"
|
|
@@ -134,6 +146,35 @@ def local_compose_from_manifest(
|
|
|
134
146
|
if svc_def.get("type") == "persistence":
|
|
135
147
|
persistence_ids.append(svc_def["id"])
|
|
136
148
|
|
|
149
|
+
# Precompute which persistence services WILL get a healthcheck (declared or
|
|
150
|
+
# from image defaults), independent of generation order. The depends_on
|
|
151
|
+
# condition derivation below used to read the not-yet-generated persistence
|
|
152
|
+
# service dict, so services appearing before their DB in the manifest (api,
|
|
153
|
+
# client, ...) wrongly got ``service_started`` and raced the DB on startup.
|
|
154
|
+
persistence_has_healthcheck: dict[str, bool] = {}
|
|
155
|
+
for svc_def in local_services:
|
|
156
|
+
if svc_def.get("type") != "persistence":
|
|
157
|
+
continue
|
|
158
|
+
pid = svc_def["id"]
|
|
159
|
+
if svc_def.get("healthcheck"):
|
|
160
|
+
persistence_has_healthcheck[pid] = True
|
|
161
|
+
else:
|
|
162
|
+
defaults = detect_persistence_defaults(svc_def.get("image", ""))
|
|
163
|
+
persistence_has_healthcheck[pid] = bool(defaults and "healthcheck" in defaults)
|
|
164
|
+
|
|
165
|
+
# One-shot init services (`labels.forktex.oneshot=true`) — compute
|
|
166
|
+
# services get an auto-depends_on on each so they're at least ordered
|
|
167
|
+
# behind the initializer's start. We can't wait on `service_completed`
|
|
168
|
+
# for a true oneshot the way k8s init-containers do, but the
|
|
169
|
+
# ``service_started`` ordering closes the most common race (e.g.
|
|
170
|
+
# gitea-init writes the token file → api can read it on its first
|
|
171
|
+
# gitea call). The initializer itself self-polls its dependencies.
|
|
172
|
+
oneshot_ids: list[str] = [
|
|
173
|
+
svc_def["id"]
|
|
174
|
+
for svc_def in local_services
|
|
175
|
+
if (svc_def.get("labels") or {}).get("forktex.oneshot") == "true"
|
|
176
|
+
]
|
|
177
|
+
|
|
137
178
|
for svc_def in local_services:
|
|
138
179
|
sid = svc_def["id"]
|
|
139
180
|
image = svc_def.get("image", "")
|
|
@@ -150,22 +191,37 @@ def local_compose_from_manifest(
|
|
|
150
191
|
if build_cfg and isinstance(build_cfg, dict):
|
|
151
192
|
build_entry: dict[str, str] = {}
|
|
152
193
|
ctx = build_cfg.get("context", f"./{sid}")
|
|
153
|
-
# Rewrite relative context to be relative to
|
|
154
|
-
|
|
194
|
+
# Rewrite a project-relative context to be relative to the compose
|
|
195
|
+
# file's directory (.forktex/cache/) via root_prefix.
|
|
196
|
+
build_entry["context"] = (
|
|
197
|
+
f"{root_prefix}/{ctx.removeprefix('./')}" if ctx.startswith("./") else ctx
|
|
198
|
+
)
|
|
155
199
|
if build_cfg.get("dockerfile"):
|
|
156
200
|
build_entry["dockerfile"] = build_cfg["dockerfile"]
|
|
157
201
|
svc["build"] = build_entry
|
|
158
202
|
elif svc_type == "compute":
|
|
159
203
|
dockerfile = project_root / sid / "Dockerfile"
|
|
160
204
|
if dockerfile.is_file():
|
|
161
|
-
svc["build"] = {"context": f"
|
|
205
|
+
svc["build"] = {"context": f"{root_prefix}/{sid}"}
|
|
162
206
|
|
|
163
207
|
if sid in host_ports:
|
|
164
208
|
host_port = host_ports[sid]
|
|
165
209
|
svc["ports"] = [f"{host_port}:{port}"]
|
|
166
210
|
|
|
167
|
-
|
|
211
|
+
# One-shot init services (e.g. `gitea-init`) carry the
|
|
212
|
+
# `forktex.oneshot=true` label so the generator skips the implicit
|
|
213
|
+
# restart-always; they should exit cleanly after their seed step
|
|
214
|
+
# and stay exited. The label lives on the existing ServiceDef
|
|
215
|
+
# `labels` field — no schema/codegen change required.
|
|
216
|
+
is_oneshot = (svc_def.get("labels") or {}).get("forktex.oneshot") == "true"
|
|
217
|
+
if svc_type in ("persistence", "observability") and not is_oneshot:
|
|
168
218
|
svc["restart"] = "always"
|
|
219
|
+
if svc_def.get("labels"):
|
|
220
|
+
svc["labels"] = dict(svc_def["labels"])
|
|
221
|
+
# docker-compose `pid:` passthrough — `service:<id>` shares another
|
|
222
|
+
# service's PID namespace so e.g. a sidecar can signal its primary.
|
|
223
|
+
if svc_def.get("pid"):
|
|
224
|
+
svc["pid"] = svc_def["pid"]
|
|
169
225
|
|
|
170
226
|
if svc_type == "persistence":
|
|
171
227
|
defaults = detect_persistence_defaults(image)
|
|
@@ -196,7 +252,7 @@ def local_compose_from_manifest(
|
|
|
196
252
|
src = v.split(":")[0]
|
|
197
253
|
rest = v[len(src) :]
|
|
198
254
|
if src.startswith("./"):
|
|
199
|
-
rewritten.append(f"
|
|
255
|
+
rewritten.append(f"{root_prefix}/{src[2:]}{rest}")
|
|
200
256
|
elif src.startswith("/") or not src.startswith("."):
|
|
201
257
|
rewritten.append(v)
|
|
202
258
|
if not src.startswith("/"):
|
|
@@ -222,16 +278,19 @@ def local_compose_from_manifest(
|
|
|
222
278
|
if cmd:
|
|
223
279
|
svc["command"] = cmd
|
|
224
280
|
|
|
225
|
-
if svc_type == "compute"
|
|
281
|
+
if svc_type == "compute":
|
|
226
282
|
depends: dict[str, Any] = {}
|
|
227
283
|
for pid in persistence_ids:
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
284
|
+
healthy = persistence_has_healthcheck.get(pid)
|
|
285
|
+
cond = "service_healthy" if healthy else "service_started"
|
|
286
|
+
depends[pid] = {"condition": cond}
|
|
287
|
+
for iid in oneshot_ids:
|
|
288
|
+
# Initializers run side-by-side; we only need start-ordering,
|
|
289
|
+
# not completion. The initializer's contract is "write its
|
|
290
|
+
# output before the first compute request needs it."
|
|
291
|
+
depends[iid] = {"condition": "service_started"}
|
|
292
|
+
if depends:
|
|
293
|
+
svc["depends_on"] = depends
|
|
235
294
|
|
|
236
295
|
explicit_deps = svc_def.get("depends_on")
|
|
237
296
|
if explicit_deps:
|
|
@@ -241,7 +300,7 @@ def local_compose_from_manifest(
|
|
|
241
300
|
services[sid] = svc
|
|
242
301
|
|
|
243
302
|
if observability:
|
|
244
|
-
_add_observability_services(services, named_volumes
|
|
303
|
+
_add_observability_services(services, named_volumes)
|
|
245
304
|
|
|
246
305
|
compose: dict[str, Any] = {"services": services}
|
|
247
306
|
|
|
@@ -251,26 +310,3 @@ def local_compose_from_manifest(
|
|
|
251
310
|
compose["networks"] = {"forktex": {}}
|
|
252
311
|
|
|
253
312
|
return compose
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
def write_local_compose(
|
|
257
|
-
manifest: Manifest,
|
|
258
|
-
project_root: Path,
|
|
259
|
-
*,
|
|
260
|
-
secrets_provider: SecretsProvider | None = None,
|
|
261
|
-
observability: bool = True,
|
|
262
|
-
) -> Path:
|
|
263
|
-
"""Generate the local docker-compose file for the project and return its path."""
|
|
264
|
-
import yaml
|
|
265
|
-
|
|
266
|
-
compose = local_compose_from_manifest(
|
|
267
|
-
manifest,
|
|
268
|
-
project_root,
|
|
269
|
-
secrets_provider=secrets_provider,
|
|
270
|
-
observability=observability,
|
|
271
|
-
)
|
|
272
|
-
paths.ensure_project_dirs(project_root)
|
|
273
|
-
out_path = paths.compose_path(project_root, "local")
|
|
274
|
-
with open(out_path, "w") as f:
|
|
275
|
-
yaml.dump(compose, f, default_flow_style=False, sort_keys=False)
|
|
276
|
-
return out_path
|
|
@@ -116,9 +116,42 @@ def _merge_fsd_manifest(base: dict, overlay: dict) -> dict:
|
|
|
116
116
|
merged["cloud"]["services"] = merge_services(
|
|
117
117
|
base_cloud["services"], over_cloud["services"]
|
|
118
118
|
)
|
|
119
|
+
# V1 multi-server: same id-aware merge for infrastructure.servers[].
|
|
120
|
+
# Without this, an overlay's servers[] replaces the base's, dropping
|
|
121
|
+
# fields the overlay didn't repeat (notably `primary: true`).
|
|
122
|
+
base_servers = base_cloud.get("infrastructure", {}).get("servers")
|
|
123
|
+
over_servers = over_cloud.get("infrastructure", {}).get("servers")
|
|
124
|
+
if isinstance(base_servers, list) and isinstance(over_servers, list):
|
|
125
|
+
merged.setdefault("cloud", {}).setdefault("infrastructure", {})
|
|
126
|
+
merged["cloud"]["infrastructure"]["servers"] = merge_services(
|
|
127
|
+
base_servers, over_servers
|
|
128
|
+
)
|
|
119
129
|
return merged
|
|
120
130
|
|
|
121
131
|
|
|
132
|
+
def _git_head_sha(project_dir: Path) -> str | None:
|
|
133
|
+
"""Return the current HEAD commit SHA if *project_dir* is a git working tree.
|
|
134
|
+
|
|
135
|
+
Used to auto-populate ``commit_sha`` on /apply so deploys are traceable to
|
|
136
|
+
a source revision without requiring the caller to wire it through. Silent
|
|
137
|
+
on failure (not a git tree, git missing, detached HEAD that errors out).
|
|
138
|
+
"""
|
|
139
|
+
try:
|
|
140
|
+
result = subprocess.run(
|
|
141
|
+
["git", "rev-parse", "HEAD"],
|
|
142
|
+
cwd=str(project_dir),
|
|
143
|
+
capture_output=True,
|
|
144
|
+
text=True,
|
|
145
|
+
timeout=5,
|
|
146
|
+
)
|
|
147
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
148
|
+
return None
|
|
149
|
+
if result.returncode != 0:
|
|
150
|
+
return None
|
|
151
|
+
sha = result.stdout.strip()
|
|
152
|
+
return sha if len(sha) == 40 else None
|
|
153
|
+
|
|
154
|
+
|
|
122
155
|
class CloudAPIError(Exception):
|
|
123
156
|
"""Raised when the cloud API returns a non-2xx response."""
|
|
124
157
|
|
|
@@ -633,6 +666,8 @@ class Cloud:
|
|
|
633
666
|
manifest_data: dict | None = None,
|
|
634
667
|
project_dir: Path | None = None,
|
|
635
668
|
image_artifacts: list[dict[str, Any]] | None = None,
|
|
669
|
+
code_repository_id: str | None = None,
|
|
670
|
+
commit_sha: str | None = None,
|
|
636
671
|
) -> JobResponse:
|
|
637
672
|
"""Trigger the apply pipeline via POST {org_prefix}/apply.
|
|
638
673
|
|
|
@@ -644,6 +679,12 @@ class Cloud:
|
|
|
644
679
|
the manifest's compute-service ``image:`` fields are rewritten to
|
|
645
680
|
registry-pinned digest refs, and the index ids are forwarded to the
|
|
646
681
|
controller as ``image_artifact_ids``.
|
|
682
|
+
|
|
683
|
+
Lineage: ``commit_sha`` is captured automatically from ``git rev-parse
|
|
684
|
+
HEAD`` when *project_dir* points at a git working tree and the kwarg
|
|
685
|
+
isn't already set. ``code_repository_id`` is only meaningful for
|
|
686
|
+
runner-driven deploys (the runner knows which CodeRepository row it
|
|
687
|
+
cloned) and is left None for operator invocations.
|
|
647
688
|
"""
|
|
648
689
|
body: dict[str, Any] = {
|
|
649
690
|
"skip_dns": skip_dns,
|
|
@@ -697,6 +738,15 @@ class Cloud:
|
|
|
697
738
|
if tarball:
|
|
698
739
|
body["assets_tarball_b64"] = tarball
|
|
699
740
|
|
|
741
|
+
# Auto-detect commit_sha from a git working tree when not explicitly set.
|
|
742
|
+
if commit_sha is None and project_dir is not None:
|
|
743
|
+
commit_sha = _git_head_sha(project_dir)
|
|
744
|
+
|
|
745
|
+
if code_repository_id:
|
|
746
|
+
body["code_repository_id"] = code_repository_id
|
|
747
|
+
if commit_sha:
|
|
748
|
+
body["commit_sha"] = commit_sha
|
|
749
|
+
|
|
700
750
|
resp = self._check(self._client.post(f"{self._org_prefix}/apply", json=body))
|
|
701
751
|
return JobResponse.model_validate(resp.json())
|
|
702
752
|
|
|
@@ -845,12 +895,23 @@ class Cloud:
|
|
|
845
895
|
svc_type = svc.get("type", "compute")
|
|
846
896
|
service_prefix = f"services/{svc_id}"
|
|
847
897
|
|
|
848
|
-
# Volume-mounted local files and directories
|
|
898
|
+
# Volume-mounted local files and directories.
|
|
899
|
+
#
|
|
900
|
+
# Only RELATIVE local paths (./foo, ../bar) are treated as
|
|
901
|
+
# tarball-worthy assets — they're project-local source dirs the
|
|
902
|
+
# deploy needs to ship.
|
|
903
|
+
#
|
|
904
|
+
# Absolute paths in `volumes:` (e.g. /media/megatyres/db) are
|
|
905
|
+
# by convention **server-side host paths** on the deployment
|
|
906
|
+
# target, NOT local asset directories. Tarballing them is
|
|
907
|
+
# always wrong (they may not exist locally; if they do, the
|
|
908
|
+
# permissions are whatever the local OS happens to assign,
|
|
909
|
+
# which has nothing to do with the deploy). Skip them.
|
|
849
910
|
for vol in svc.get("volumes", []):
|
|
850
911
|
if isinstance(vol, str) and ":" in vol:
|
|
851
912
|
parts = vol.split(":")
|
|
852
913
|
local_part = parts[0]
|
|
853
|
-
if local_part.startswith("./") or local_part.startswith("
|
|
914
|
+
if local_part.startswith("./") or local_part.startswith("../"):
|
|
854
915
|
local_path = (project_dir / local_part).resolve()
|
|
855
916
|
if local_path.is_dir() or local_path.is_file():
|
|
856
917
|
source_basename = Path(local_part).name
|