forktex-cloud 1.0.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 (32) hide show
  1. {forktex_cloud-1.0.0 → forktex_cloud-2.0.0}/PKG-INFO +1 -1
  2. {forktex_cloud-1.0.0 → forktex_cloud-2.0.0}/pyproject.toml +1 -1
  3. forktex_cloud-2.0.0/src/forktex_cloud/__init__.py +148 -0
  4. {forktex_cloud-1.0.0 → forktex_cloud-2.0.0}/src/forktex_cloud/bridge/local_compose.py +88 -52
  5. {forktex_cloud-1.0.0 → forktex_cloud-2.0.0}/src/forktex_cloud/client/client.py +13 -2
  6. {forktex_cloud-1.0.0 → forktex_cloud-2.0.0}/src/forktex_cloud/client/generated/__init__.py +15 -2
  7. {forktex_cloud-1.0.0 → forktex_cloud-2.0.0}/src/forktex_cloud/secrets/factory.py +6 -9
  8. forktex_cloud-1.0.0/src/forktex_cloud/__init__.py +0 -76
  9. forktex_cloud-1.0.0/src/forktex_cloud/paths.py +0 -274
  10. {forktex_cloud-1.0.0 → forktex_cloud-2.0.0}/LICENSE +0 -0
  11. {forktex_cloud-1.0.0 → forktex_cloud-2.0.0}/README.md +0 -0
  12. {forktex_cloud-1.0.0 → forktex_cloud-2.0.0}/src/forktex_cloud/bridge/__init__.py +0 -0
  13. {forktex_cloud-1.0.0 → forktex_cloud-2.0.0}/src/forktex_cloud/bridge/log_formatter.py +0 -0
  14. {forktex_cloud-1.0.0 → forktex_cloud-2.0.0}/src/forktex_cloud/bridge/loki.py +0 -0
  15. {forktex_cloud-1.0.0 → forktex_cloud-2.0.0}/src/forktex_cloud/bridge/persistence_defaults.py +0 -0
  16. {forktex_cloud-1.0.0 → forktex_cloud-2.0.0}/src/forktex_cloud/client/__init__.py +0 -0
  17. {forktex_cloud-1.0.0 → forktex_cloud-2.0.0}/src/forktex_cloud/config.py +0 -0
  18. {forktex_cloud-1.0.0 → forktex_cloud-2.0.0}/src/forktex_cloud/manifest/__init__.py +0 -0
  19. {forktex_cloud-1.0.0 → forktex_cloud-2.0.0}/src/forktex_cloud/manifest/errors.py +0 -0
  20. {forktex_cloud-1.0.0 → forktex_cloud-2.0.0}/src/forktex_cloud/manifest/loader.py +0 -0
  21. {forktex_cloud-1.0.0 → forktex_cloud-2.0.0}/src/forktex_cloud/manifest/merge.py +0 -0
  22. {forktex_cloud-1.0.0 → forktex_cloud-2.0.0}/src/forktex_cloud/manifest/schema.py +0 -0
  23. {forktex_cloud-1.0.0 → forktex_cloud-2.0.0}/src/forktex_cloud/scaffold/__init__.py +0 -0
  24. {forktex_cloud-1.0.0 → forktex_cloud-2.0.0}/src/forktex_cloud/scaffold/templates.py +0 -0
  25. {forktex_cloud-1.0.0 → forktex_cloud-2.0.0}/src/forktex_cloud/secrets/__init__.py +0 -0
  26. {forktex_cloud-1.0.0 → forktex_cloud-2.0.0}/src/forktex_cloud/secrets/base.py +0 -0
  27. {forktex_cloud-1.0.0 → forktex_cloud-2.0.0}/src/forktex_cloud/secrets/fernet.py +0 -0
  28. {forktex_cloud-1.0.0 → forktex_cloud-2.0.0}/src/forktex_cloud/secrets/resolver.py +0 -0
  29. {forktex_cloud-1.0.0 → forktex_cloud-2.0.0}/src/forktex_cloud/templates/observability/loki.yml +0 -0
  30. {forktex_cloud-1.0.0 → forktex_cloud-2.0.0}/src/forktex_cloud/templates/observability/promtail.yml +0 -0
  31. {forktex_cloud-1.0.0 → forktex_cloud-2.0.0}/src/forktex_cloud/vpn/__init__.py +0 -0
  32. {forktex_cloud-1.0.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: 1.0.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forktex-cloud"
3
- version = "1.0.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
@@ -895,12 +895,23 @@ class Cloud:
895
895
  svc_type = svc.get("type", "compute")
896
896
  service_prefix = f"services/{svc_id}"
897
897
 
898
- # 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.
899
910
  for vol in svc.get("volumes", []):
900
911
  if isinstance(vol, str) and ":" in vol:
901
912
  parts = vol.split(":")
902
913
  local_part = parts[0]
903
- if local_part.startswith("./") or local_part.startswith("/"):
914
+ if local_part.startswith("./") or local_part.startswith("../"):
904
915
  local_path = (project_dir / local_part).resolve()
905
916
  if local_path.is_dir() or local_path.is_file():
906
917
  source_basename = Path(local_part).name
@@ -4,13 +4,13 @@ DO NOT EDIT — regenerate with: make codegen
4
4
 
5
5
  Source: GET /api/openapi.json
6
6
  API version: 0.6.0
7
- Spec hash: 0739bc915ed92cad
7
+ Spec hash: 8d93387d1a2e1ae4
8
8
  """
9
9
  from __future__ import annotations
10
10
 
11
11
 
12
12
  SPEC_VERSION = "0.6.0"
13
- SPEC_HASH = "0739bc915ed92cad"
13
+ SPEC_HASH = "8d93387d1a2e1ae4"
14
14
 
15
15
 
16
16
  from enum import StrEnum
@@ -656,6 +656,14 @@ class ManifestVersionRead(BaseModel):
656
656
  version: Annotated[int, Field(title="Version")]
657
657
 
658
658
 
659
+ class MemberRead(BaseModel):
660
+ createdAt: Annotated[AwareDatetime, Field(title="Createdat")]
661
+ id: Annotated[UUID, Field(title="Id")]
662
+ orgId: Annotated[UUID, Field(title="Orgid")]
663
+ role: Annotated[str, Field(title="Role")]
664
+ userId: Annotated[UUID, Field(title="Userid")]
665
+
666
+
659
667
  class MetadataDef(BaseModel):
660
668
  model_config = ConfigDict(
661
669
  extra="forbid",
@@ -741,6 +749,7 @@ class OpsResponse(BaseModel):
741
749
  class OrgBrief(BaseModel):
742
750
  id: Annotated[UUID, Field(title="Id")]
743
751
  name: Annotated[str, Field(title="Name")]
752
+ role: Annotated[str | None, Field(title="Role")] = None
744
753
  slug: Annotated[str, Field(title="Slug")]
745
754
 
746
755
 
@@ -1190,6 +1199,10 @@ class ServiceDef(BaseModel):
1190
1199
  """
1191
1200
  Log format this service writes to stdout. 'json' enables structured field extraction in Loki (level, request_id). 'auto' (default when omitted) infers from the image name.
1192
1201
  """
1202
+ pid: Annotated[str | None, Field(title="Pid")] = None
1203
+ """
1204
+ Docker-compose ``pid:`` passthrough. Common values: ``service:<other-service-id>`` to share another service's PID namespace (useful for sidecars that need to signal their primary, e.g. ``kill -HUP``), or ``host`` for full host PID namespace. When omitted, the container runs in its own PID namespace (compose default).
1205
+ """
1193
1206
  port: Annotated[int | None, Field(title="Port")] = None
1194
1207
  resources: Annotated[dict[str, Any] | None, Field(title="Resources")] = None
1195
1208
  routePrefix: Annotated[str | None, Field(title="Routeprefix")] = None
@@ -5,30 +5,27 @@ from __future__ import annotations
5
5
  import os
6
6
  from pathlib import Path
7
7
 
8
- from forktex_cloud import paths
9
8
  from forktex_cloud.secrets.base import SecretsProvider
10
9
 
11
10
 
12
11
  def get_secrets_provider(
13
12
  *,
14
- project_root: Path | None = None,
13
+ vault_root: Path,
15
14
  provider_name: str | None = None,
16
15
  master_key: str | None = None,
17
16
  ) -> SecretsProvider:
18
17
  """Create and return the configured SecretsProvider.
19
18
 
20
- Resolution order for provider name:
21
- 1. Explicit ``provider_name`` argument
22
- 2. ``FORKTEX_SECRETS_PROVIDER`` environment variable
23
- 3. Defaults to ``"fernet"``
19
+ ``vault_root`` is supplied by the caller — forktex-py owns the ``.forktex/``
20
+ layout; the SDK never computes filesystem paths. Resolution order for the
21
+ provider name: explicit ``provider_name`` → ``FORKTEX_SECRETS_PROVIDER``
22
+ ``"fernet"``.
24
23
  """
25
24
  name = provider_name or os.environ.get("FORKTEX_SECRETS_PROVIDER", "fernet")
26
25
 
27
26
  if name == "fernet":
28
27
  from forktex_cloud.secrets.fernet import FernetVault
29
28
 
30
- root = project_root or Path.cwd()
31
- vault_root = paths.project_dir(root) / "vault"
32
- return FernetVault(vault_root, master_key=master_key)
29
+ return FernetVault(Path(vault_root), master_key=master_key)
33
30
 
34
31
  raise ValueError(f"Unknown secrets provider: {name}")
@@ -1,76 +0,0 @@
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
-
20
- __version__ = "1.0.0"
21
-
22
- from forktex_cloud import paths
23
- from forktex_cloud.client.client import Cloud, CloudAPIError
24
- from forktex_cloud.client.generated import (
25
- SPEC_HASH,
26
- SPEC_VERSION,
27
- ApiKeyCreated,
28
- ApiKeyRead,
29
- AuditEventRead,
30
- EnvironmentRead,
31
- HealthRead,
32
- JobResponse,
33
- MeResponse,
34
- OrgRead,
35
- ProjectRead,
36
- ServerRead,
37
- StatusResponse,
38
- TokenResponse,
39
- UserRead,
40
- VaultGetResponse,
41
- WorkspaceRead,
42
- )
43
- from forktex_cloud.config import CloudContext
44
- from forktex_cloud.manifest.loader import Manifest, ManifestError
45
-
46
- __all__ = [
47
- # Filesystem layout spec (V1)
48
- "paths",
49
- # Codegen contract (wire-compatibility markers)
50
- "SPEC_VERSION",
51
- "SPEC_HASH",
52
- # Client
53
- "Cloud",
54
- "CloudAPIError",
55
- # Config
56
- "CloudContext",
57
- # Manifest
58
- "Manifest",
59
- "ManifestError",
60
- # Models (from OpenAPI codegen — the single source of truth)
61
- "ApiKeyCreated",
62
- "ApiKeyRead",
63
- "AuditEventRead",
64
- "EnvironmentRead",
65
- "HealthRead",
66
- "JobResponse",
67
- "MeResponse",
68
- "OrgRead",
69
- "ProjectRead",
70
- "ServerRead",
71
- "StatusResponse",
72
- "TokenResponse",
73
- "UserRead",
74
- "VaultGetResponse",
75
- "WorkspaceRead",
76
- ]
@@ -1,274 +0,0 @@
1
- """Canonical filesystem layout for the forktex ecosystem (V1).
2
-
3
- Every subsystem that reads or writes under ``.forktex/`` (project scope) or
4
- ``~/.forktex/`` (user/OS scope) MUST go through this module. Hardcoding path
5
- literals elsewhere is enforced against by ``tests/test_paths_contract.py``.
6
-
7
- Cross-platform rules:
8
- - All returned values are ``pathlib.Path`` (never ``str``).
9
- - Project scope directory is always lowercase ``.forktex``.
10
- - User scope directory is ``~/.forktex`` on POSIX and ``%APPDATA%/forktex`` on Windows.
11
- - Secrets-bearing directories are created with mode ``0o700`` on POSIX; Windows
12
- relies on per-user ACLs on ``%APPDATA%``.
13
-
14
- See ``cloud/docs/forktex-directory-spec.md`` for the full V1 spec.
15
- """
16
-
17
- from __future__ import annotations
18
-
19
- import os
20
- import sys
21
- from pathlib import Path
22
-
23
- #: Bump when the on-disk layout changes incompatibly. Written to
24
- #: ``.forktex/.version`` by :func:`ensure_project_dirs` on init.
25
- SCHEMA_VERSION = 1
26
-
27
- #: Directory name at project scope.
28
- PROJECT_DIRNAME = ".forktex"
29
-
30
- #: Directory name at user/OS scope (stripped of leading dot on Windows).
31
- _USER_DIRNAME_POSIX = ".forktex"
32
- _USER_DIRNAME_WINDOWS = "forktex"
33
-
34
- #: Marker comment used to idempotently detect the canonical ``.gitignore`` block.
35
- _GITIGNORE_MARKER = "# forktex — generated + secrets (keep .forktex/.version committed)"
36
- _GITIGNORE_BLOCK = f"""
37
- {_GITIGNORE_MARKER}
38
- .forktex/**
39
- !.forktex/.version
40
- """
41
-
42
-
43
- # ── Project-scope paths ──────────────────────────────────────────────────────
44
-
45
-
46
- def project_dir(root: Path) -> Path:
47
- return root / PROJECT_DIRNAME
48
-
49
-
50
- def version_file(root: Path) -> Path:
51
- return project_dir(root) / ".version"
52
-
53
-
54
- def compose_path(root: Path, env: str) -> Path:
55
- return project_dir(root) / f"docker-compose.{env}.yml"
56
-
57
-
58
- def observability_dir(root: Path) -> Path:
59
- return project_dir(root) / "observability"
60
-
61
-
62
- def vault_dir(root: Path, env: str) -> Path:
63
- return project_dir(root) / "vault" / env
64
-
65
-
66
- def vault_secrets_file(root: Path, env: str) -> Path:
67
- return vault_dir(root, env) / "secrets.enc"
68
-
69
-
70
- def state_dir(root: Path) -> Path:
71
- return project_dir(root) / "state"
72
-
73
-
74
- def servers_json(root: Path) -> Path:
75
- return state_dir(root) / "servers.json"
76
-
77
-
78
- def server_keys_dir(root: Path) -> Path:
79
- return state_dir(root) / "keys"
80
-
81
-
82
- def generated_dir(root: Path) -> Path:
83
- return project_dir(root) / "generated"
84
-
85
-
86
- def data_dir(root: Path, service_id: str) -> Path:
87
- return project_dir(root) / "data" / service_id
88
-
89
-
90
- def custom_ssl_dir(root: Path) -> Path:
91
- return project_dir(root) / "ssl" / "custom"
92
-
93
-
94
- def fsd_evidence_dir(root: Path) -> Path:
95
- return project_dir(root) / "fsd" / "evidence"
96
-
97
-
98
- def architecture_dir(root: Path) -> Path:
99
- return project_dir(root) / "architecture"
100
-
101
-
102
- def agents_history_dir(root: Path) -> Path:
103
- return project_dir(root) / "agents" / "history"
104
-
105
-
106
- def agents_history_file(root: Path, agent_id: str) -> Path:
107
- return agents_history_dir(root) / f"{agent_id}.jsonl"
108
-
109
-
110
- def agents_types_file(root: Path) -> Path:
111
- return project_dir(root) / "agents" / "types.json"
112
-
113
-
114
- def scraper_truths_dir(root: Path) -> Path:
115
- return project_dir(root) / "scraper" / "truths"
116
-
117
-
118
- def scraper_truths_file(root: Path, domain: str) -> Path:
119
- return scraper_truths_dir(root) / f"{domain}.json"
120
-
121
-
122
- def scraper_output_dir(root: Path) -> Path:
123
- return project_dir(root) / "scraper" / "output"
124
-
125
-
126
- def project_config_file(root: Path) -> Path:
127
- return project_dir(root) / "config.json"
128
-
129
-
130
- def project_cloud_file(root: Path) -> Path:
131
- return project_dir(root) / "cloud" / "config.json"
132
-
133
-
134
- def project_intelligence_file(root: Path) -> Path:
135
- return project_dir(root) / "intelligence.json"
136
-
137
-
138
- def project_network_file(root: Path) -> Path:
139
- return project_dir(root) / "network.json"
140
-
141
-
142
- # ── User/OS-scope paths ──────────────────────────────────────────────────────
143
-
144
-
145
- def global_dir() -> Path:
146
- """Return the global forktex directory, cross-platform.
147
-
148
- POSIX: ``~/.forktex/``. Windows: ``%APPDATA%/forktex/`` (roaming user profile).
149
- """
150
- if sys.platform == "win32":
151
- appdata = os.environ.get("APPDATA")
152
- base = Path(appdata) if appdata else Path.home()
153
- return base / _USER_DIRNAME_WINDOWS
154
- return Path.home() / _USER_DIRNAME_POSIX
155
-
156
-
157
- def global_cloud_file() -> Path:
158
- return global_dir() / "cloud.json"
159
-
160
-
161
- def global_intelligence_file() -> Path:
162
- return global_dir() / "intelligence.json"
163
-
164
-
165
- def global_network_file() -> Path:
166
- return global_dir() / "network.json"
167
-
168
-
169
- def global_config_file() -> Path:
170
- return global_dir() / "config.toml"
171
-
172
-
173
- # ── Lifecycle helpers ────────────────────────────────────────────────────────
174
-
175
-
176
- def ensure_project_dirs(root: Path) -> None:
177
- """Create ``.forktex/`` under *root*, write the schema version marker, and
178
- ensure the project ``.gitignore`` has the canonical forktex block.
179
-
180
- Safe to call repeatedly. Does not touch existing files.
181
- """
182
- pdir = project_dir(root)
183
- pdir.mkdir(parents=True, exist_ok=True)
184
- write_schema_version(root)
185
- _ensure_gitignore_block(root)
186
-
187
-
188
- def ensure_global_dir() -> None:
189
- """Create the user/OS-scope forktex dir with secure permissions on POSIX."""
190
- gdir = global_dir()
191
- gdir.mkdir(parents=True, exist_ok=True)
192
- if sys.platform != "win32":
193
- try:
194
- gdir.chmod(0o700)
195
- except OSError:
196
- # Non-fatal: existing dir with stricter perms is fine.
197
- pass
198
-
199
-
200
- def read_schema_version(root: Path) -> int | None:
201
- """Return the on-disk ``.forktex/.version`` as int, or ``None`` if missing."""
202
- vf = version_file(root)
203
- if not vf.is_file():
204
- return None
205
- try:
206
- return int(vf.read_text().strip())
207
- except (ValueError, OSError):
208
- return None
209
-
210
-
211
- def write_schema_version(root: Path) -> None:
212
- """Write ``SCHEMA_VERSION`` to ``.forktex/.version`` if not already correct."""
213
- vf = version_file(root)
214
- if read_schema_version(root) == SCHEMA_VERSION:
215
- return
216
- vf.parent.mkdir(parents=True, exist_ok=True)
217
- vf.write_text(f"{SCHEMA_VERSION}\n")
218
-
219
-
220
- def _ensure_gitignore_block(root: Path) -> None:
221
- gi = root / ".gitignore"
222
- if gi.is_file():
223
- existing = gi.read_text()
224
- if _GITIGNORE_MARKER in existing:
225
- return
226
- # Append, ensuring a trailing newline separator.
227
- if not existing.endswith("\n"):
228
- existing += "\n"
229
- gi.write_text(existing + _GITIGNORE_BLOCK)
230
- else:
231
- gi.write_text(_GITIGNORE_BLOCK.lstrip("\n"))
232
-
233
-
234
- __all__ = [
235
- # Constants
236
- "SCHEMA_VERSION",
237
- "PROJECT_DIRNAME",
238
- # Project-scope
239
- "project_dir",
240
- "version_file",
241
- "compose_path",
242
- "observability_dir",
243
- "vault_dir",
244
- "vault_secrets_file",
245
- "state_dir",
246
- "servers_json",
247
- "server_keys_dir",
248
- "generated_dir",
249
- "data_dir",
250
- "custom_ssl_dir",
251
- "fsd_evidence_dir",
252
- "architecture_dir",
253
- "agents_history_dir",
254
- "agents_history_file",
255
- "agents_types_file",
256
- "scraper_truths_dir",
257
- "scraper_truths_file",
258
- "scraper_output_dir",
259
- "project_config_file",
260
- "project_cloud_file",
261
- "project_intelligence_file",
262
- "project_network_file",
263
- # User/OS-scope
264
- "global_dir",
265
- "global_cloud_file",
266
- "global_intelligence_file",
267
- "global_network_file",
268
- "global_config_file",
269
- # Lifecycle
270
- "ensure_project_dirs",
271
- "ensure_global_dir",
272
- "read_schema_version",
273
- "write_schema_version",
274
- ]
File without changes
File without changes