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.
Files changed (35) hide show
  1. {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/PKG-INFO +2 -2
  2. {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/README.md +1 -1
  3. {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/pyproject.toml +1 -1
  4. forktex_cloud-2.0.0/src/forktex_cloud/__init__.py +148 -0
  5. {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/bridge/local_compose.py +88 -52
  6. {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/client/client.py +63 -2
  7. {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/client/generated/__init__.py +550 -195
  8. forktex_cloud-2.0.0/src/forktex_cloud/manifest/__init__.py +20 -0
  9. {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/manifest/loader.py +74 -43
  10. forktex_cloud-2.0.0/src/forktex_cloud/manifest/schema.py +27 -0
  11. forktex_cloud-2.0.0/src/forktex_cloud/scaffold/templates.py +73 -0
  12. {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/secrets/factory.py +6 -9
  13. forktex_cloud-0.5.0/src/forktex_cloud/__init__.py +0 -76
  14. forktex_cloud-0.5.0/src/forktex_cloud/manifest/__init__.py +0 -35
  15. forktex_cloud-0.5.0/src/forktex_cloud/manifest/schema.py +0 -65
  16. forktex_cloud-0.5.0/src/forktex_cloud/paths.py +0 -274
  17. forktex_cloud-0.5.0/src/forktex_cloud/scaffold/templates.py +0 -120
  18. {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/LICENSE +0 -0
  19. {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/bridge/__init__.py +0 -0
  20. {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/bridge/log_formatter.py +0 -0
  21. {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/bridge/loki.py +0 -0
  22. {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/bridge/persistence_defaults.py +0 -0
  23. {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/client/__init__.py +0 -0
  24. {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/config.py +0 -0
  25. {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/manifest/errors.py +0 -0
  26. {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/manifest/merge.py +0 -0
  27. {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/scaffold/__init__.py +0 -0
  28. {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/secrets/__init__.py +0 -0
  29. {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/secrets/base.py +0 -0
  30. {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/secrets/fernet.py +0 -0
  31. {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/secrets/resolver.py +0 -0
  32. {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/templates/observability/loki.yml +0 -0
  33. {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/templates/observability/promtail.yml +0 -0
  34. {forktex_cloud-0.5.0 → forktex_cloud-2.0.0}/src/forktex_cloud/vpn/__init__.py +0 -0
  35. {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.5.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 (v1beta2), deep-merge for env overlays, `ManifestError` |
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 (v1beta2), deep-merge for env overlays, `ManifestError` |
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.5.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 _write_observability_configs(project_root: Path) -> Path:
62
- """Copy observability config files to the canonical observability dir."""
63
- out_dir = paths.observability_dir(project_root)
64
- out_dir.mkdir(parents=True, exist_ok=True)
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
- shutil.copy2(src_file, out_dir / src_file.name)
70
- return out_dir
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
- f"../{obs_dir.relative_to(project_root)}/loki.yml:/etc/loki/local-config.yaml:ro",
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
- f"../{obs_dir.relative_to(project_root)}/promtail.yml:/etc/promtail/config.yml:ro",
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 .forktex/ dir
154
- build_entry["context"] = f"../{ctx.removeprefix('./')}" if ctx.startswith("./") else ctx
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"../{sid}"}
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
- if svc_type in ("persistence", "observability"):
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"../{src[2:]}{rest}")
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" and persistence_ids:
281
+ if svc_type == "compute":
226
282
  depends: dict[str, Any] = {}
227
283
  for pid in persistence_ids:
228
- # Use service_healthy only if the persistence service has a healthcheck
229
- psvc = services.get(pid, {})
230
- if "healthcheck" in psvc:
231
- depends[pid] = {"condition": "service_healthy"}
232
- else:
233
- depends[pid] = {"condition": "service_started"}
234
- svc["depends_on"] = depends
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, project_root)
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