forktex-cloud 2.0.0__tar.gz → 2.3.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 (37) hide show
  1. {forktex_cloud-2.0.0 → forktex_cloud-2.3.0}/PKG-INFO +12 -11
  2. {forktex_cloud-2.0.0 → forktex_cloud-2.3.0}/README.md +9 -7
  3. {forktex_cloud-2.0.0 → forktex_cloud-2.3.0}/pyproject.toml +16 -5
  4. {forktex_cloud-2.0.0 → forktex_cloud-2.3.0}/src/forktex_cloud/__init__.py +8 -6
  5. forktex_cloud-2.3.0/src/forktex_cloud/bridge/__init__.py +25 -0
  6. {forktex_cloud-2.0.0 → forktex_cloud-2.3.0}/src/forktex_cloud/bridge/local_compose.py +127 -20
  7. {forktex_cloud-2.0.0 → forktex_cloud-2.3.0}/src/forktex_cloud/bridge/loki.py +2 -2
  8. {forktex_cloud-2.0.0 → forktex_cloud-2.3.0}/src/forktex_cloud/bridge/persistence_defaults.py +17 -4
  9. {forktex_cloud-2.0.0 → forktex_cloud-2.3.0}/src/forktex_cloud/client/__init__.py +4 -3
  10. {forktex_cloud-2.0.0 → forktex_cloud-2.3.0}/src/forktex_cloud/client/client.py +316 -625
  11. forktex_cloud-2.3.0/src/forktex_cloud/client/facade.py +393 -0
  12. forktex_cloud-2.3.0/src/forktex_cloud/client/generated/__init__.py +2643 -0
  13. {forktex_cloud-2.0.0 → forktex_cloud-2.3.0}/src/forktex_cloud/config.py +6 -2
  14. forktex_cloud-2.3.0/src/forktex_cloud/local.py +178 -0
  15. {forktex_cloud-2.0.0 → forktex_cloud-2.3.0}/src/forktex_cloud/manifest/loader.py +3 -0
  16. {forktex_cloud-2.0.0 → forktex_cloud-2.3.0}/src/forktex_cloud/manifest/schema.py +11 -2
  17. forktex_cloud-2.3.0/src/forktex_cloud/manifest/semantics.py +130 -0
  18. forktex_cloud-2.3.0/src/forktex_cloud/scaffold/__init__.py +5 -0
  19. {forktex_cloud-2.0.0 → forktex_cloud-2.3.0}/src/forktex_cloud/scaffold/templates.py +1 -1
  20. forktex_cloud-2.3.0/src/forktex_cloud/secrets/__init__.py +14 -0
  21. forktex_cloud-2.0.0/src/forktex_cloud/bridge/__init__.py +0 -1
  22. forktex_cloud-2.0.0/src/forktex_cloud/client/generated/__init__.py +0 -1907
  23. forktex_cloud-2.0.0/src/forktex_cloud/scaffold/__init__.py +0 -1
  24. forktex_cloud-2.0.0/src/forktex_cloud/secrets/__init__.py +0 -1
  25. forktex_cloud-2.0.0/src/forktex_cloud/vpn/__init__.py +0 -23
  26. forktex_cloud-2.0.0/src/forktex_cloud/vpn/local.py +0 -86
  27. {forktex_cloud-2.0.0 → forktex_cloud-2.3.0}/LICENSE +0 -0
  28. {forktex_cloud-2.0.0 → forktex_cloud-2.3.0}/src/forktex_cloud/bridge/log_formatter.py +0 -0
  29. {forktex_cloud-2.0.0 → forktex_cloud-2.3.0}/src/forktex_cloud/manifest/__init__.py +0 -0
  30. {forktex_cloud-2.0.0 → forktex_cloud-2.3.0}/src/forktex_cloud/manifest/errors.py +0 -0
  31. {forktex_cloud-2.0.0 → forktex_cloud-2.3.0}/src/forktex_cloud/manifest/merge.py +0 -0
  32. {forktex_cloud-2.0.0 → forktex_cloud-2.3.0}/src/forktex_cloud/secrets/base.py +0 -0
  33. {forktex_cloud-2.0.0 → forktex_cloud-2.3.0}/src/forktex_cloud/secrets/factory.py +0 -0
  34. {forktex_cloud-2.0.0 → forktex_cloud-2.3.0}/src/forktex_cloud/secrets/fernet.py +0 -0
  35. {forktex_cloud-2.0.0 → forktex_cloud-2.3.0}/src/forktex_cloud/secrets/resolver.py +0 -0
  36. {forktex_cloud-2.0.0 → forktex_cloud-2.3.0}/src/forktex_cloud/templates/observability/loki.yml +0 -0
  37. {forktex_cloud-2.0.0 → forktex_cloud-2.3.0}/src/forktex_cloud/templates/observability/promtail.yml +0 -0
@@ -1,21 +1,20 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forktex-cloud
3
- Version: 2.0.0
3
+ Version: 2.3.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
7
7
  Keywords: forktex,cloud,deployment,vps,hetzner,ansible,blue-green,iac,infrastructure-as-code,sdk
8
8
  Author: ForkTex
9
9
  Author-email: info@forktex.com
10
- Requires-Python: >=3.11
10
+ Requires-Python: >=3.14,<4.0
11
11
  Classifier: Development Status :: 5 - Production/Stable
12
12
  Classifier: Intended Audience :: Developers
13
13
  Classifier: Intended Audience :: System Administrators
14
14
  Classifier: Operating System :: OS Independent
15
15
  Classifier: Programming Language :: Python :: 3
16
16
  Classifier: Programming Language :: Python :: 3 :: Only
17
- Classifier: Programming Language :: Python :: 3.11
18
- Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.14
19
18
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
19
  Classifier: Topic :: System :: Distributed Computing
21
20
  Classifier: Topic :: System :: Installation/Setup
@@ -40,9 +39,7 @@ Description-Content-Type: text/markdown
40
39
 
41
40
  Standalone Python SDK for the [ForkTex Cloud](https://cloud.forktex.com) platform.
42
41
 
43
- `forktex-cloud` is the typed `httpx` client + manifest plumbing that the `forktex` CLI uses to talk to the ForkTex Cloud control plane: project provisioning, server management, deployments, vault, manifest validation, and the docker-compose / Hetzner / Ansible bridge.
44
-
45
- You can use it directly from any Python application — no `forktex` CLI required.
42
+ `forktex-cloud` is the typed `httpx` client + manifest plumbing for the ForkTex Cloud control plane: project provisioning, server management, deployments, vault, manifest validation, and the docker-compose / Hetzner / Ansible bridge. It was the library behind the retired `forktex cloud …` CLI; today it is used directly from Python applications, the platform's own runner, and CI.
46
43
 
47
44
  ## Install
48
45
 
@@ -50,7 +47,7 @@ You can use it directly from any Python application — no `forktex` CLI require
50
47
  pip install forktex-cloud
51
48
  ```
52
49
 
53
- Requires Python ≥ 3.11.
50
+ Requires Python ≥ 3.14 (`requires-python = ">=3.14,<4.0"`).
54
51
 
55
52
  ## Quick Start
56
53
 
@@ -93,6 +90,10 @@ client = Cloud(
93
90
  account_key="ftx-dev-key-2026",
94
91
  org_id="<your-org-uuid>",
95
92
  )
93
+
94
+ For integration tests or proxying through a mock/live transport, pass the
95
+ optional `transport=` argument to `Cloud(...)` and provide any `httpx`
96
+ transport implementation.
96
97
  ```
97
98
 
98
99
  ### Trigger a deploy pipeline
@@ -155,8 +156,8 @@ client.vault_delete("POSTGRES_PASSWORD")
155
156
  | `forktex_cloud.client` | Typed sync httpx client (`Cloud`) + all OpenAPI-codegenned Pydantic models (`ServerRead`, `ProjectRead`, `EventRead`, `VaultGetResponse`, ...) |
156
157
  | `forktex_cloud.manifest` | `Manifest` loader, discriminated-union schema (v1), deep-merge for env overlays, `ManifestError` |
157
158
  | `forktex_cloud.config` | `CloudContext` — controller URL, JWT / account-key, current org + project keys |
158
- | `forktex_cloud.scaffold` | `forktex cloud init` template generator (ProjectDeployment / StaticSite / SingleContainer / NativeBuild) |
159
- | `forktex_cloud.bridge` | docker-compose generator (local mode), Loki config, log formatters used by `forktex cloud apply --env local` |
159
+ | `forktex_cloud.scaffold` | `forktex.json` template generator (formerly driven by the retired `forktex cloud init` CLI) |
160
+ | `forktex_cloud.bridge` | docker-compose generator (local mode: `local_compose_from_manifest`, used by the repo's `scripts/local_stack.py` / `make start`; formerly by the retired `forktex cloud apply --env local` CLI), Loki config, log formatters |
160
161
  | `forktex_cloud.secrets` | Fernet vault + `${vault:KEY}` resolver for compile-time secret injection |
161
162
  | `forktex_cloud.paths` | Cross-platform `.forktex/` + `~/.forktex/` filesystem spec (V1). See [docs/forktex-directory-spec.md](https://github.com/forktex/cloud/blob/master/docs/forktex-directory-spec.md) |
162
163
 
@@ -191,7 +192,7 @@ The SDK follows [SemVer](https://semver.org/). The client's response models are
191
192
  This SDK lives inside the [`forktex/cloud`](https://github.com/forktex/cloud) monorepo alongside the API server (`api/`) and React Native client (`client/`). The SDK package is independently versioned and published to PyPI.
192
193
 
193
194
  - Docs: [https://github.com/forktex/cloud/tree/master/docs](https://github.com/forktex/cloud/tree/master/docs)
194
- - Production runbook: [production-runbook.md](https://github.com/forktex/cloud/blob/master/docs/production-runbook.md)
195
+ - Operations guide: [operations.md](https://github.com/forktex/cloud/blob/master/docs/operations.md)
195
196
  - Issues: [https://github.com/forktex/cloud/issues](https://github.com/forktex/cloud/issues)
196
197
 
197
198
  ## License
@@ -6,9 +6,7 @@
6
6
 
7
7
  Standalone Python SDK for the [ForkTex Cloud](https://cloud.forktex.com) platform.
8
8
 
9
- `forktex-cloud` is the typed `httpx` client + manifest plumbing that the `forktex` CLI uses to talk to the ForkTex Cloud control plane: project provisioning, server management, deployments, vault, manifest validation, and the docker-compose / Hetzner / Ansible bridge.
10
-
11
- You can use it directly from any Python application — no `forktex` CLI required.
9
+ `forktex-cloud` is the typed `httpx` client + manifest plumbing for the ForkTex Cloud control plane: project provisioning, server management, deployments, vault, manifest validation, and the docker-compose / Hetzner / Ansible bridge. It was the library behind the retired `forktex cloud …` CLI; today it is used directly from Python applications, the platform's own runner, and CI.
12
10
 
13
11
  ## Install
14
12
 
@@ -16,7 +14,7 @@ You can use it directly from any Python application — no `forktex` CLI require
16
14
  pip install forktex-cloud
17
15
  ```
18
16
 
19
- Requires Python ≥ 3.11.
17
+ Requires Python ≥ 3.14 (`requires-python = ">=3.14,<4.0"`).
20
18
 
21
19
  ## Quick Start
22
20
 
@@ -59,6 +57,10 @@ client = Cloud(
59
57
  account_key="ftx-dev-key-2026",
60
58
  org_id="<your-org-uuid>",
61
59
  )
60
+
61
+ For integration tests or proxying through a mock/live transport, pass the
62
+ optional `transport=` argument to `Cloud(...)` and provide any `httpx`
63
+ transport implementation.
62
64
  ```
63
65
 
64
66
  ### Trigger a deploy pipeline
@@ -121,8 +123,8 @@ client.vault_delete("POSTGRES_PASSWORD")
121
123
  | `forktex_cloud.client` | Typed sync httpx client (`Cloud`) + all OpenAPI-codegenned Pydantic models (`ServerRead`, `ProjectRead`, `EventRead`, `VaultGetResponse`, ...) |
122
124
  | `forktex_cloud.manifest` | `Manifest` loader, discriminated-union schema (v1), deep-merge for env overlays, `ManifestError` |
123
125
  | `forktex_cloud.config` | `CloudContext` — controller URL, JWT / account-key, current org + project keys |
124
- | `forktex_cloud.scaffold` | `forktex cloud init` template generator (ProjectDeployment / StaticSite / SingleContainer / NativeBuild) |
125
- | `forktex_cloud.bridge` | docker-compose generator (local mode), Loki config, log formatters used by `forktex cloud apply --env local` |
126
+ | `forktex_cloud.scaffold` | `forktex.json` template generator (formerly driven by the retired `forktex cloud init` CLI) |
127
+ | `forktex_cloud.bridge` | docker-compose generator (local mode: `local_compose_from_manifest`, used by the repo's `scripts/local_stack.py` / `make start`; formerly by the retired `forktex cloud apply --env local` CLI), Loki config, log formatters |
126
128
  | `forktex_cloud.secrets` | Fernet vault + `${vault:KEY}` resolver for compile-time secret injection |
127
129
  | `forktex_cloud.paths` | Cross-platform `.forktex/` + `~/.forktex/` filesystem spec (V1). See [docs/forktex-directory-spec.md](https://github.com/forktex/cloud/blob/master/docs/forktex-directory-spec.md) |
128
130
 
@@ -157,7 +159,7 @@ The SDK follows [SemVer](https://semver.org/). The client's response models are
157
159
  This SDK lives inside the [`forktex/cloud`](https://github.com/forktex/cloud) monorepo alongside the API server (`api/`) and React Native client (`client/`). The SDK package is independently versioned and published to PyPI.
158
160
 
159
161
  - Docs: [https://github.com/forktex/cloud/tree/master/docs](https://github.com/forktex/cloud/tree/master/docs)
160
- - Production runbook: [production-runbook.md](https://github.com/forktex/cloud/blob/master/docs/production-runbook.md)
162
+ - Operations guide: [operations.md](https://github.com/forktex/cloud/blob/master/docs/operations.md)
161
163
  - Issues: [https://github.com/forktex/cloud/issues](https://github.com/forktex/cloud/issues)
162
164
 
163
165
  ## License
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forktex-cloud"
3
- version = "2.0.0"
3
+ version = "2.3.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"}
@@ -8,7 +8,7 @@ authors = [
8
8
  readme = "README.md"
9
9
  license = "MIT"
10
10
  license-files = ["LICENSE"]
11
- requires-python = ">=3.11"
11
+ requires-python = ">=3.14,<4.0"
12
12
  keywords = [
13
13
  "forktex",
14
14
  "cloud",
@@ -28,8 +28,7 @@ classifiers = [
28
28
  "Operating System :: OS Independent",
29
29
  "Programming Language :: Python :: 3",
30
30
  "Programming Language :: Python :: 3 :: Only",
31
- "Programming Language :: Python :: 3.11",
32
- "Programming Language :: Python :: 3.12",
31
+ "Programming Language :: Python :: 3.14",
33
32
  "Topic :: Software Development :: Libraries :: Python Modules",
34
33
  "Topic :: System :: Distributed Computing",
35
34
  "Topic :: System :: Installation/Setup",
@@ -61,10 +60,15 @@ dev = [
61
60
  "ruff (>=0.8.0)",
62
61
  "pyright (>=1.1.0,<2.0.0)",
63
62
  "mypy (>=1.11.0,<2.0.0)",
63
+ # Release tooling. `make build` and `make publish` shell out to these, so
64
+ # undeclared they turn a release into "No module named twine" at the moment
65
+ # you try to ship. Same pair, same versions, as ../forktex-py.
66
+ "build (>=1.2.0)",
67
+ "twine (>=5.0.0)",
64
68
  ]
65
69
 
66
70
  [tool.ruff]
67
- target-version = "py311"
71
+ target-version = "py314"
68
72
  line-length = 100
69
73
  extend-exclude = [
70
74
  # Auto-generated by OpenAPI codegen. Canonical import shape comes from
@@ -76,6 +80,13 @@ extend-exclude = [
76
80
  [tool.ruff.lint]
77
81
  select = ["E", "F", "I", "W"]
78
82
 
83
+ [tool.mypy]
84
+ plugins = ["pydantic.mypy"]
85
+ ignore_missing_imports = true
86
+ # Auto-generated from the OpenAPI spec — committed for drift-checking but not
87
+ # subject to hand-typing (mirrors the api codegen/build exclude).
88
+ exclude = ["client/generated"]
89
+
79
90
  [tool.pytest.ini_options]
80
91
  asyncio_mode = "auto"
81
92
  testpaths = ["tests"]
@@ -32,7 +32,7 @@ from __future__ import annotations
32
32
 
33
33
  from typing import TYPE_CHECKING, Any
34
34
 
35
- __version__ = "2.0.0"
35
+ __version__ = "2.1.0"
36
36
 
37
37
  # ── Lazy attribute map ──────────────────────────────────────────────────────
38
38
  #
@@ -41,8 +41,10 @@ __version__ = "2.0.0"
41
41
  # model lookups hit the module-level cache populated by __getattr__.
42
42
  _LAZY_ATTRS: dict[str, tuple[str, str]] = {
43
43
  # Client (httpx-backed, heavy)
44
- "Cloud": ("forktex_cloud.client.client", "Cloud"),
44
+ "Cloud": ("forktex_cloud.client.facade", "Cloud"),
45
+ "CloudClient": ("forktex_cloud.client.client", "CloudClient"),
45
46
  "CloudAPIError": ("forktex_cloud.client.client", "CloudAPIError"),
47
+ "DeployBlocked": ("forktex_cloud.client.client", "DeployBlocked"),
46
48
  # Config
47
49
  "CloudContext": ("forktex_cloud.config", "CloudContext"),
48
50
  # Manifest
@@ -65,7 +67,6 @@ _LAZY_ATTRS: dict[str, tuple[str, str]] = {
65
67
  "TokenResponse": ("forktex_cloud.client.generated", "TokenResponse"),
66
68
  "UserRead": ("forktex_cloud.client.generated", "UserRead"),
67
69
  "VaultGetResponse": ("forktex_cloud.client.generated", "VaultGetResponse"),
68
- "WorkspaceRead": ("forktex_cloud.client.generated", "WorkspaceRead"),
69
70
  }
70
71
 
71
72
 
@@ -93,7 +94,8 @@ def __dir__() -> list[str]:
93
94
  # Static type-checker hint — declared as a TYPE_CHECKING block so it costs
94
95
  # nothing at runtime but lets editors / mypy see the eventual symbol types.
95
96
  if TYPE_CHECKING: # pragma: no cover
96
- from forktex_cloud.client.client import Cloud, CloudAPIError
97
+ from forktex_cloud.client.client import CloudAPIError, CloudClient, DeployBlocked
98
+ from forktex_cloud.client.facade import Cloud
97
99
  from forktex_cloud.client.generated import (
98
100
  SPEC_HASH,
99
101
  SPEC_VERSION,
@@ -111,7 +113,6 @@ if TYPE_CHECKING: # pragma: no cover
111
113
  TokenResponse,
112
114
  UserRead,
113
115
  VaultGetResponse,
114
- WorkspaceRead,
115
116
  )
116
117
  from forktex_cloud.config import CloudContext
117
118
  from forktex_cloud.manifest.loader import Manifest, ManifestError
@@ -123,7 +124,9 @@ __all__ = [
123
124
  "SPEC_HASH",
124
125
  # Client — lazy
125
126
  "Cloud",
127
+ "CloudClient",
126
128
  "CloudAPIError",
129
+ "DeployBlocked",
127
130
  # Config — lazy
128
131
  "CloudContext",
129
132
  # Manifest — lazy
@@ -144,5 +147,4 @@ __all__ = [
144
147
  "TokenResponse",
145
148
  "UserRead",
146
149
  "VaultGetResponse",
147
- "WorkspaceRead",
148
150
  ]
@@ -0,0 +1,25 @@
1
+ """Bridge modules: manifest → docker compose for local dev, plus log helpers.
2
+
3
+ ``local_compose_from_manifest`` is the pure renderer the cloud repo's
4
+ ``scripts/local_stack.py`` drives; it returns a compose dict and writes nothing.
5
+ """
6
+
7
+ from forktex_cloud.bridge.local_compose import (
8
+ local_compose_from_manifest,
9
+ render_observability_configs,
10
+ )
11
+ from forktex_cloud.bridge.log_formatter import assign_colors, format_line
12
+ from forktex_cloud.bridge.loki import build_logql, loki_ready, query_range, tail
13
+ from forktex_cloud.bridge.persistence_defaults import detect_persistence_defaults
14
+
15
+ __all__ = [
16
+ "assign_colors",
17
+ "build_logql",
18
+ "detect_persistence_defaults",
19
+ "format_line",
20
+ "local_compose_from_manifest",
21
+ "loki_ready",
22
+ "query_range",
23
+ "render_observability_configs",
24
+ "tail",
25
+ ]
@@ -2,6 +2,13 @@
2
2
 
3
3
  Generates a simple local-oriented docker-compose.local.yml from a forktex manifest.
4
4
  No proxy, no blue-green, no SSL -- just plain containers with direct port mapping.
5
+
6
+ Build-context convention: a service's ``build.context`` is interpreted relative
7
+ to the PROJECT ROOT (a leading ``./`` is optional; ``.`` means the root). The
8
+ generator re-bases it onto the generated compose file's directory via
9
+ ``root_prefix``. A context may not escape the project root (``..``) — each
10
+ service builds from inside its own project. ``build.dockerfile`` is relative to
11
+ the resolved context and must exist (both are validated at generation time).
5
12
  """
6
13
 
7
14
  from __future__ import annotations
@@ -10,6 +17,7 @@ from pathlib import Path
10
17
  from typing import Any
11
18
 
12
19
  from forktex_cloud.bridge.persistence_defaults import detect_persistence_defaults
20
+ from forktex_cloud.manifest.errors import ManifestError
13
21
  from forktex_cloud.manifest.loader import Manifest
14
22
  from forktex_cloud.secrets.base import SecretsProvider
15
23
 
@@ -17,6 +25,76 @@ from forktex_cloud.secrets.base import SecretsProvider
17
25
  _OBSERVABILITY_PORTS = {3100}
18
26
 
19
27
 
28
+ def _project_relative(path: str, root_prefix: str) -> str:
29
+ """Express a project-root-relative ``path`` relative to the compose dir.
30
+
31
+ The compose file is generated below the project root (``root_prefix`` is the
32
+ hop back up — e.g. ``../..`` for ``.forktex/cache/``), so a context or bind
33
+ source the manifest states *relative to the project root* must be re-based
34
+ onto it. A leading ``./`` is optional; ``.`` / ``""`` mean the root itself.
35
+ """
36
+ stripped = path.removeprefix("./")
37
+ if stripped in (".", ""):
38
+ return root_prefix
39
+ return f"{root_prefix}/{stripped}".rstrip("/")
40
+
41
+
42
+ def _resolve_build_context(
43
+ ctx: str, *, sid: str, project_root: Path, root_prefix: str
44
+ ) -> tuple[str, Path]:
45
+ """Resolve a manifest build ``context`` to ``(compose-relative, on-disk dir)``.
46
+
47
+ Relative contexts are project-root-relative (leading ``./`` optional);
48
+ absolute contexts pass through untouched. A relative context that escapes
49
+ the project root (via ``..``) is rejected — every service builds from inside
50
+ its own project (see ``standard.forktex-architecture``); a context reaching
51
+ the surrounding workspace is the smell that produced the ``network/network``
52
+ double-path bug.
53
+ """
54
+ if ctx.startswith("/"):
55
+ return ctx, Path(ctx)
56
+ stripped = ctx.removeprefix("./")
57
+ on_disk = (project_root / stripped).resolve()
58
+ root = project_root.resolve()
59
+ if on_disk != root and root not in on_disk.parents:
60
+ raise ManifestError(
61
+ f"service {sid!r}: build context {ctx!r} escapes the project root "
62
+ f"(resolves to {on_disk}, outside {root}). Build contexts must stay "
63
+ f"inside the project — build the service from its own directory."
64
+ )
65
+ return _project_relative(ctx, root_prefix), on_disk
66
+
67
+
68
+ def _require_dockerfile(ctx_dir: Path, dockerfile: str | None, *, sid: str, context: str) -> None:
69
+ """Fail with a clear error if the Dockerfile is missing under the context.
70
+
71
+ The ``dockerfile`` path is interpreted relative to ``context``; when the two
72
+ desync Docker emits only a cryptic ``lstat`` failure at build time. Surface
73
+ it at generation time instead.
74
+ """
75
+ rel = dockerfile or "Dockerfile"
76
+ target = ctx_dir / rel
77
+ if not target.is_file():
78
+ raise ManifestError(
79
+ f"service {sid!r}: dockerfile {rel!r} not found under build context "
80
+ f"{context!r} (looked for {target}). `dockerfile` is relative to "
81
+ f"`context`."
82
+ )
83
+
84
+
85
+ def _autodetect_dockerfile(svc_dir: Path, env_name: str) -> Path | None:
86
+ """Find a buildable Dockerfile in ``svc_dir`` — env-specific first.
87
+
88
+ A local build wants ``Dockerfile.local`` over the prod ``Dockerfile`` when
89
+ both exist; fall back to the plain name otherwise.
90
+ """
91
+ for name in (f"Dockerfile.{env_name}", "Dockerfile"):
92
+ candidate = svc_dir / name
93
+ if candidate.is_file():
94
+ return candidate
95
+ return None
96
+
97
+
20
98
  def _allocate_host_ports(
21
99
  services: list[dict[str, Any]],
22
100
  *,
@@ -116,6 +194,22 @@ def _add_observability_services(
116
194
  }
117
195
 
118
196
 
197
+ def _observability_enabled(manifest: Manifest) -> bool:
198
+ """Whether the manifest asks for the local Loki/Promtail pair.
199
+
200
+ The `observability` argument is the caller's veto, not the answer: a
201
+ manifest that sets `observability.enabled: false` — as a project whose
202
+ local stack runs beside another one does, to keep Loki's 3100 free — still
203
+ got both containers, because the flag defaulted to True and nothing
204
+ consulted the manifest.
205
+ """
206
+ cloud = getattr(manifest, "cloud", None)
207
+ obs = getattr(cloud, "observability", None)
208
+ if obs is None:
209
+ return True
210
+ return bool(getattr(obs, "enabled", True))
211
+
212
+
119
213
  def local_compose_from_manifest(
120
214
  manifest: Manifest,
121
215
  project_root: Path,
@@ -167,8 +261,8 @@ def local_compose_from_manifest(
167
261
  # behind the initializer's start. We can't wait on `service_completed`
168
262
  # for a true oneshot the way k8s init-containers do, but the
169
263
  # ``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.
264
+ # an initializer writes state the primary reads on its first call).
265
+ # The initializer itself self-polls its dependencies.
172
266
  oneshot_ids: list[str] = [
173
267
  svc_def["id"]
174
268
  for svc_def in local_services
@@ -186,35 +280,45 @@ def local_compose_from_manifest(
186
280
  # Build context — explicit overlay first, else auto-detect a sibling
187
281
  # Dockerfile for compute services. Persistence services only opt in
188
282
  # when the manifest explicitly declares `build` (zot is the canonical
189
- # case — persistence-typed but first-party).
283
+ # case — persistence-typed but first-party). `context` is interpreted
284
+ # relative to the PROJECT ROOT (leading `./` optional) and re-based onto
285
+ # the compose dir via root_prefix; a `dockerfile` is relative to it.
190
286
  build_cfg = svc_def.get("build")
191
287
  if build_cfg and isinstance(build_cfg, dict):
192
288
  build_entry: dict[str, str] = {}
193
289
  ctx = build_cfg.get("context", f"./{sid}")
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
290
+ compose_ctx, ctx_dir = _resolve_build_context(
291
+ ctx, sid=sid, project_root=project_root, root_prefix=root_prefix
198
292
  )
199
- if build_cfg.get("dockerfile"):
200
- build_entry["dockerfile"] = build_cfg["dockerfile"]
293
+ build_entry["context"] = compose_ctx
294
+ dockerfile = build_cfg.get("dockerfile")
295
+ if dockerfile:
296
+ build_entry["dockerfile"] = dockerfile
297
+ _require_dockerfile(ctx_dir, dockerfile, sid=sid, context=ctx)
201
298
  svc["build"] = build_entry
202
- elif svc_type == "compute":
203
- dockerfile = project_root / sid / "Dockerfile"
204
- if dockerfile.is_file():
205
- svc["build"] = {"context": f"{root_prefix}/{sid}"}
299
+ elif svc_type in ("compute", "daemon"):
300
+ found = _autodetect_dockerfile(project_root / sid, env_name)
301
+ if found is not None:
302
+ build_entry = {"context": _project_relative(f"./{sid}", root_prefix)}
303
+ if found.name != "Dockerfile":
304
+ build_entry["dockerfile"] = found.name
305
+ svc["build"] = build_entry
206
306
 
207
307
  if sid in host_ports:
208
308
  host_port = host_ports[sid]
209
309
  svc["ports"] = [f"{host_port}:{port}"]
310
+ # Additional host:container mappings (e.g. mailhog UI 8025:8025).
311
+ extra_ports = svc_def.get("extraPorts") or []
312
+ if extra_ports:
313
+ svc.setdefault("ports", []).extend(str(p) for p in extra_ports)
210
314
 
211
- # One-shot init services (e.g. `gitea-init`) carry the
315
+ # One-shot init services carry the
212
316
  # `forktex.oneshot=true` label so the generator skips the implicit
213
317
  # restart-always; they should exit cleanly after their seed step
214
318
  # and stay exited. The label lives on the existing ServiceDef
215
319
  # `labels` field — no schema/codegen change required.
216
320
  is_oneshot = (svc_def.get("labels") or {}).get("forktex.oneshot") == "true"
217
- if svc_type in ("persistence", "observability") and not is_oneshot:
321
+ if svc_type in ("persistence", "daemon", "observability") and not is_oneshot:
218
322
  svc["restart"] = "always"
219
323
  if svc_def.get("labels"):
220
324
  svc["labels"] = dict(svc_def["labels"])
@@ -252,7 +356,7 @@ def local_compose_from_manifest(
252
356
  src = v.split(":")[0]
253
357
  rest = v[len(src) :]
254
358
  if src.startswith("./"):
255
- rewritten.append(f"{root_prefix}/{src[2:]}{rest}")
359
+ rewritten.append(f"{_project_relative(src, root_prefix)}{rest}")
256
360
  elif src.startswith("/") or not src.startswith("."):
257
361
  rewritten.append(v)
258
362
  if not src.startswith("/"):
@@ -265,7 +369,7 @@ def local_compose_from_manifest(
265
369
  if svc_type == "persistence":
266
370
  # Bind-mount persistence data under .forktex/data/{sid}/ so it
267
371
  # survives `docker compose down -v` and is visible on the host
268
- # for inspection + backups. Mirrors api/src/bridge/local_compose.py.
372
+ # for inspection + backups.
269
373
  defaults = detect_persistence_defaults(image)
270
374
  if defaults and defaults.get("default_volume"):
271
375
  target = defaults["default_volume"]
@@ -278,7 +382,7 @@ def local_compose_from_manifest(
278
382
  if cmd:
279
383
  svc["command"] = cmd
280
384
 
281
- if svc_type == "compute":
385
+ if svc_type in ("compute", "daemon"):
282
386
  depends: dict[str, Any] = {}
283
387
  for pid in persistence_ids:
284
388
  healthy = persistence_has_healthcheck.get(pid)
@@ -287,7 +391,10 @@ def local_compose_from_manifest(
287
391
  for iid in oneshot_ids:
288
392
  # Initializers run side-by-side; we only need start-ordering,
289
393
  # not completion. The initializer's contract is "write its
290
- # output before the first compute request needs it."
394
+ # output before the first compute request needs it." A oneshot
395
+ # that is itself a daemon must not wait on itself.
396
+ if iid == sid:
397
+ continue
291
398
  depends[iid] = {"condition": "service_started"}
292
399
  if depends:
293
400
  svc["depends_on"] = depends
@@ -299,7 +406,7 @@ def local_compose_from_manifest(
299
406
  svc["networks"] = ["forktex"]
300
407
  services[sid] = svc
301
408
 
302
- if observability:
409
+ if observability and _observability_enabled(manifest):
303
410
  _add_observability_services(services, named_volumes)
304
411
 
305
412
  compose: dict[str, Any] = {"services": services}
@@ -16,7 +16,7 @@ def loki_ready(base_url: str = "http://localhost:3100") -> bool:
16
16
  req = urllib.request.Request(f"{base_url}/ready", method="GET")
17
17
  with urllib.request.urlopen(req, timeout=2) as resp:
18
18
  return resp.status == 200
19
- except (urllib.error.URLError, OSError):
19
+ except urllib.error.URLError, OSError:
20
20
  return False
21
21
 
22
22
 
@@ -77,7 +77,7 @@ def tail(
77
77
  now_ns = int(time.time() * 1_000_000_000)
78
78
  try:
79
79
  entries = query_range(base_url, logql, last_ts + 1, now_ns)
80
- except (urllib.error.URLError, OSError):
80
+ except urllib.error.URLError, OSError:
81
81
  return
82
82
  for ts_ns, service, line in entries:
83
83
  yield (ts_ns, service, line)
@@ -60,11 +60,24 @@ PERSISTENCE_DEFAULTS: dict[str, dict[str, Any]] = {
60
60
  "default_volume": "/data/db",
61
61
  },
62
62
  "qdrant": {
63
+ # The qdrant image ships no wget, curl or nc — only bash. So the probe
64
+ # speaks HTTP over /dev/tcp rather than shelling out to a client that is
65
+ # not there. It must be `CMD` + an explicit `bash -c`: /dev/tcp is a bash
66
+ # builtin, and `CMD-SHELL` runs /bin/sh, where it fails with "cannot
67
+ # create /dev/tcp/..." — indistinguishable from a down node.
68
+ # `/readyz` rather than a bare port open: a bound socket is not a node
69
+ # that is ready to serve.
63
70
  "healthcheck": {
64
- "test": ["CMD-SHELL", "bash -c 'echo > /dev/tcp/localhost/6333'"],
65
- "interval": "5s",
66
- "timeout": "5s",
67
- "retries": 10,
71
+ "test": [
72
+ "CMD",
73
+ "bash",
74
+ "-c",
75
+ 'exec 3<>/dev/tcp/localhost/6333; printf "GET /readyz HTTP/1.0\\r\\n\\r\\n" >&3; '
76
+ 'head -1 <&3 | grep -q "200 OK"',
77
+ ],
78
+ "interval": "30s",
79
+ "timeout": "10s",
80
+ "retries": 5,
68
81
  },
69
82
  "default_port": 6333,
70
83
  "default_volume": "/qdrant/storage",
@@ -6,7 +6,8 @@ commonly used ones so call sites can ``from forktex_cloud.client import X``
6
6
  without touching the ``generated`` namespace directly.
7
7
  """
8
8
 
9
- from forktex_cloud.client.client import Cloud, CloudAPIError
9
+ from forktex_cloud.client.client import CloudAPIError, CloudClient, DeployBlocked
10
+ from forktex_cloud.client.facade import Cloud
10
11
  from forktex_cloud.client.generated import (
11
12
  ApiKeyCreated,
12
13
  ApiKeyRead,
@@ -31,12 +32,13 @@ from forktex_cloud.client.generated import (
31
32
  TokenResponse,
32
33
  UserRead,
33
34
  VaultGetResponse,
34
- WorkspaceRead,
35
35
  )
36
36
 
37
37
  __all__ = [
38
38
  "Cloud",
39
+ "CloudClient",
39
40
  "CloudAPIError",
41
+ "DeployBlocked",
40
42
  "ApiKeyCreated",
41
43
  "ApiKeyRead",
42
44
  "CapacityOrgLimits",
@@ -60,5 +62,4 @@ __all__ = [
60
62
  "TokenResponse",
61
63
  "UserRead",
62
64
  "VaultGetResponse",
63
- "WorkspaceRead",
64
65
  ]