forktex-cloud 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,63 @@
1
+ # Copyright (C) 2026 FORKTEX S.R.L.
2
+ #
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-ForkTex-Commercial
4
+ #
5
+ # This file is part of forktex-cloud.
6
+ #
7
+ # For commercial licensing -- including use in proprietary products, SaaS
8
+ # deployments, or any context where AGPL obligations cannot be met -- you
9
+ # MUST obtain a commercial license from FORKTEX S.R.L. (info@forktex.com).
10
+ #
11
+ # This program is free software: you can redistribute it and/or modify
12
+ # it under the terms of the GNU Affero General Public License as published by
13
+ # the Free Software Foundation, either version 3 of the License, or
14
+ # (at your option) any later version.
15
+ #
16
+ # This program is distributed in the hope that it will be useful,
17
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
18
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
+ # GNU Affero General Public License for more details.
20
+ #
21
+ # You should have received a copy of the GNU Affero General Public License
22
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
23
+
24
+ """Cloud context model.
25
+
26
+ Pure data model — no filesystem I/O. The host application (e.g. forktex-py)
27
+ is responsible for discovering, loading, and persisting cloud context.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from typing import Any, Optional
33
+
34
+ from pydantic import BaseModel, Field
35
+
36
+
37
+ class CloudContext(BaseModel):
38
+ """Resolved cloud CLI state."""
39
+
40
+ controller: Optional[str] = None
41
+ account_key: Optional[str] = None
42
+ access_token: Optional[str] = None
43
+ org_id: Optional[str] = None
44
+ region: Optional[str] = None
45
+ project_keys: dict[str, str] = Field(default_factory=dict)
46
+ current_project: Optional[str] = None
47
+ current_server: Optional[str] = None
48
+ current_environment: Optional[str] = None
49
+
50
+ @property
51
+ def is_connected(self) -> bool:
52
+ """True if a controller URL and some auth credential are configured."""
53
+ return bool(self.controller and (self.access_token or self.account_key))
54
+
55
+ def require_connection(self) -> None:
56
+ """Raise if not connected to a cloud controller."""
57
+ if not self.controller:
58
+ raise RuntimeError("No cloud controller configured. Run: forktex cloud login")
59
+ if not self.access_token and not self.account_key:
60
+ raise RuntimeError("No credentials configured. Run: forktex cloud login")
61
+
62
+ def to_dict(self) -> dict[str, Any]:
63
+ return self.model_dump()
@@ -0,0 +1,58 @@
1
+ # Copyright (C) 2026 FORKTEX S.R.L.
2
+ #
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-ForkTex-Commercial
4
+ #
5
+ # This file is part of forktex-cloud.
6
+ #
7
+ # For commercial licensing -- including use in proprietary products, SaaS
8
+ # deployments, or any context where AGPL obligations cannot be met -- you
9
+ # MUST obtain a commercial license from FORKTEX S.R.L. (info@forktex.com).
10
+ #
11
+ # This program is free software: you can redistribute it and/or modify
12
+ # it under the terms of the GNU Affero General Public License as published by
13
+ # the Free Software Foundation, either version 3 of the License, or
14
+ # (at your option) any later version.
15
+ #
16
+ # This program is distributed in the hope that it will be useful,
17
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
18
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
+ # GNU Affero General Public License for more details.
20
+ #
21
+ # You should have received a copy of the GNU Affero General Public License
22
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
23
+
24
+ """Manifest loading, env-overlay merging, and typed validation.
25
+
26
+ The ``Manifest`` class is the public entry point for loading
27
+ ``forktex.json``. Construction validates the ``cloud:`` block eagerly via
28
+ ``parse_cloud_block`` against the canonical Pydantic schemas in
29
+ ``forktex_cloud.manifest.schema`` (which import from the OpenAPI-codegenned
30
+ ``forktex_cloud.client.generated`` module).
31
+
32
+ Validation happens at construction — there is no separate ``validate()``
33
+ function any more. Catch ``ManifestError`` to handle invalid manifests.
34
+ """
35
+
36
+ from forktex_cloud.manifest.errors import ManifestError
37
+ from forktex_cloud.manifest.loader import Manifest
38
+ from forktex_cloud.manifest.merge import deep_merge
39
+ from forktex_cloud.manifest.schema import (
40
+ CloudManifest,
41
+ NativeBuild,
42
+ ProjectDeployment,
43
+ SingleContainer,
44
+ StaticSite,
45
+ parse_cloud_block,
46
+ )
47
+
48
+ __all__ = [
49
+ "Manifest",
50
+ "ManifestError",
51
+ "deep_merge",
52
+ "parse_cloud_block",
53
+ "CloudManifest",
54
+ "ProjectDeployment",
55
+ "StaticSite",
56
+ "SingleContainer",
57
+ "NativeBuild",
58
+ ]
@@ -0,0 +1,34 @@
1
+ # Copyright (C) 2026 FORKTEX S.R.L.
2
+ #
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-ForkTex-Commercial
4
+ #
5
+ # This file is part of forktex-cloud.
6
+ #
7
+ # For commercial licensing -- including use in proprietary products, SaaS
8
+ # deployments, or any context where AGPL obligations cannot be met -- you
9
+ # MUST obtain a commercial license from FORKTEX S.R.L. (info@forktex.com).
10
+ #
11
+ # This program is free software: you can redistribute it and/or modify
12
+ # it under the terms of the GNU Affero General Public License as published by
13
+ # the Free Software Foundation, either version 3 of the License, or
14
+ # (at your option) any later version.
15
+ #
16
+ # This program is distributed in the hope that it will be useful,
17
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
18
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
+ # GNU Affero General Public License for more details.
20
+ #
21
+ # You should have received a copy of the GNU Affero General Public License
22
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
23
+
24
+ """Single exception type for manifest validation errors."""
25
+
26
+ from __future__ import annotations
27
+
28
+
29
+ class ManifestError(Exception):
30
+ """Raised when a manifest fails to parse or validate.
31
+
32
+ Wraps Pydantic ``ValidationError`` so call sites only need to catch
33
+ one exception type.
34
+ """
@@ -0,0 +1,296 @@
1
+ # Copyright (C) 2026 FORKTEX S.R.L.
2
+ #
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-ForkTex-Commercial
4
+ #
5
+ # This file is part of forktex-cloud.
6
+ #
7
+ # For commercial licensing -- including use in proprietary products, SaaS
8
+ # deployments, or any context where AGPL obligations cannot be met -- you
9
+ # MUST obtain a commercial license from FORKTEX S.R.L. (info@forktex.com).
10
+ #
11
+ # This program is free software: you can redistribute it and/or modify
12
+ # it under the terms of the GNU Affero General Public License as published by
13
+ # the Free Software Foundation, either version 3 of the License, or
14
+ # (at your option) any later version.
15
+ #
16
+ # This program is distributed in the hope that it will be useful,
17
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
18
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
+ # GNU Affero General Public License for more details.
20
+ #
21
+ # You should have received a copy of the GNU Affero General Public License
22
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
23
+
24
+ """Manifest loading, env-overlay merging, and dict-shaped accessors.
25
+
26
+ This module is the SDK's public manifest API. Internally it validates the
27
+ ``cloud:`` block against the canonical Pydantic schemas in
28
+ ``forktex_cloud.manifest.schema`` (which delegates to the OpenAPI-codegenned
29
+ models at ``forktex_cloud.client.generated``).
30
+
31
+ Public surface preserved for backward compatibility:
32
+
33
+ - ``Manifest.load(path, env=...)`` — read JSON, apply env overlay, validate.
34
+ - ``Manifest.from_dict(data, env=...)`` — same but from an in-memory dict.
35
+ - All ``@property`` accessors (``services``, ``infrastructure``, ``gateway``,
36
+ ``gateway_ssl_enabled``, ``primary_domain``, …) return the same dict /
37
+ string / bool shapes they always have.
38
+ - ``Manifest.cloud`` — NEW: the typed Pydantic model, for new call sites
39
+ that want typed attribute access.
40
+
41
+ ``ManifestError`` is raised eagerly during ``Manifest.__init__`` if the
42
+ ``cloud:`` block fails validation.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import json
48
+ from pathlib import Path
49
+ from typing import Any
50
+
51
+ from forktex_cloud.manifest.errors import ManifestError
52
+ from forktex_cloud.manifest.merge import deep_merge
53
+ from forktex_cloud.manifest.schema import CloudManifest, parse_cloud_block
54
+
55
+ __all__ = ["Manifest", "ManifestError"]
56
+
57
+
58
+ def _merge_services(
59
+ base_services: list[dict[str, Any]],
60
+ overlay_services: list[dict[str, Any]],
61
+ ) -> list[dict[str, Any]]:
62
+ """Merge service lists by matching ``id``.
63
+
64
+ Overlay entries whose ``id`` matches a base entry are shallow-merged
65
+ onto that entry (overlay wins). Overlay entries with no match are
66
+ appended. Base entries with no overlay counterpart are kept as-is.
67
+ """
68
+ seen: set[str] = set()
69
+ merged: list[dict[str, Any]] = []
70
+ for bc in base_services:
71
+ cid = bc["id"]
72
+ copy = dict(bc)
73
+ overlay_match = next((oc for oc in overlay_services if oc.get("id") == cid), None)
74
+ if overlay_match is not None:
75
+ copy.update(overlay_match)
76
+ seen.add(cid)
77
+ merged.append(copy)
78
+ for oc in overlay_services:
79
+ if oc.get("id") not in seen:
80
+ merged.append(dict(oc))
81
+ return merged
82
+
83
+
84
+ class Manifest:
85
+ """Loaded forktex.json wrapper.
86
+
87
+ Eagerly validates the ``cloud:`` block at construction. Exposes the
88
+ raw dict via accessor methods for compatibility with existing call
89
+ sites, AND the typed model via ``self.cloud`` for new code.
90
+ """
91
+
92
+ def __init__(
93
+ self,
94
+ data: dict[str, Any],
95
+ path: Path,
96
+ *,
97
+ env_name: str | None = None,
98
+ ) -> None:
99
+ self.data = data
100
+ self.path = path
101
+ self.env_name = env_name
102
+ # Eager validation — raises ManifestError on bad input.
103
+ self.cloud: CloudManifest = parse_cloud_block(self.root)
104
+
105
+ @classmethod
106
+ def from_dict(cls, data: dict[str, Any], *, env: str | None = None) -> "Manifest":
107
+ """Build a Manifest from an in-memory dict (no file needed)."""
108
+ return cls(data, path=Path("/dev/null"), env_name=env)
109
+
110
+ @classmethod
111
+ def load(cls, path: Path, *, env: str | None = None) -> "Manifest":
112
+ if not path.is_file():
113
+ raise ManifestError(f"Manifest not found: {path}")
114
+ with open(path) as f:
115
+ data = json.load(f)
116
+
117
+ if env:
118
+ overlay_path = path.parent / f"forktex.{env}.json"
119
+ if overlay_path.is_file():
120
+ with open(overlay_path) as f:
121
+ overlay = json.load(f)
122
+ base_root = data.get("cloud", data)
123
+ overlay_root = overlay.get("cloud", overlay)
124
+ if "services" in overlay_root and "services" in base_root:
125
+ overlay = dict(overlay)
126
+ if "cloud" in overlay:
127
+ overlay["cloud"] = dict(overlay["cloud"])
128
+ overlay["cloud"]["services"] = _merge_services(
129
+ base_root["services"], overlay_root["services"]
130
+ )
131
+ else:
132
+ overlay["services"] = _merge_services(
133
+ base_root["services"], overlay_root["services"]
134
+ )
135
+ data = deep_merge(data, overlay)
136
+
137
+ return cls(data, path, env_name=env)
138
+
139
+ # ── shape adapters ──
140
+
141
+ @property
142
+ def root(self) -> dict[str, Any]:
143
+ """Transparent unwrapper for both flat and ``cloud:``-nested shapes."""
144
+ return self.data.get("cloud", self.data)
145
+
146
+ # ── identity / metadata ──
147
+
148
+ @property
149
+ def api_version(self) -> str:
150
+ return self.root.get("apiVersion", "")
151
+
152
+ @property
153
+ def kind(self) -> str:
154
+ return self.root.get("kind", "")
155
+
156
+ @property
157
+ def metadata(self) -> dict[str, Any]:
158
+ return self.root.get("metadata", {})
159
+
160
+ @property
161
+ def name(self) -> str:
162
+ return self.metadata.get("name", "")
163
+
164
+ @property
165
+ def project_id(self) -> str:
166
+ return self.metadata.get("projectId", "")
167
+
168
+ # ── deployment ──
169
+
170
+ @property
171
+ def deployment(self) -> dict[str, Any]:
172
+ return self.root.get("deployment", {})
173
+
174
+ @property
175
+ def strategy(self) -> str:
176
+ return self.deployment.get("strategy", "blue-green")
177
+
178
+ @property
179
+ def retries(self) -> int:
180
+ return int(self.deployment.get("healthRetries", 30))
181
+
182
+ @property
183
+ def drain_delay(self) -> int:
184
+ return int(self.deployment.get("drainDelaySeconds", 5))
185
+
186
+ @property
187
+ def graceful_wait(self) -> int:
188
+ return int(self.deployment.get("gracefulStopSeconds", 30))
189
+
190
+ # ── infrastructure ──
191
+
192
+ @property
193
+ def infrastructure(self) -> dict[str, Any]:
194
+ return self.root.get("infrastructure", {})
195
+
196
+ @property
197
+ def infra_provider(self) -> str:
198
+ return self.infrastructure.get("provider", "hetzner")
199
+
200
+ @property
201
+ def infra_flavour(self) -> str:
202
+ return self.infrastructure.get("flavour", "starter")
203
+
204
+ @property
205
+ def infra_region(self) -> str:
206
+ return self.infrastructure.get("region", "eu-central")
207
+
208
+ @property
209
+ def infra_image(self) -> str:
210
+ return self.infrastructure.get("image", "ubuntu-24.04")
211
+
212
+ # ── services ──
213
+
214
+ @property
215
+ def services(self) -> list[dict[str, Any]]:
216
+ return self.root.get("services", [])
217
+
218
+ def service_by_id(self, sid: str) -> dict[str, Any] | None:
219
+ return next((s for s in self.services if s["id"] == sid), None)
220
+
221
+ def service_ids(self) -> list[str]:
222
+ return [s["id"] for s in self.services]
223
+
224
+ def compute_services(self) -> list[dict[str, Any]]:
225
+ return [s for s in self.services if s.get("type") == "compute"]
226
+
227
+ def persistence_services(self) -> list[dict[str, Any]]:
228
+ return [s for s in self.services if s.get("type") == "persistence"]
229
+
230
+ def observability_services(self) -> list[dict[str, Any]]:
231
+ return [s for s in self.services if s.get("type") == "observability"]
232
+
233
+ def services_for_env(self, env: str | None = None) -> list[dict[str, Any]]:
234
+ """Filter services by ``environments`` list; services without the field run in every env."""
235
+ result = []
236
+ for s in self.services:
237
+ envs = s.get("environments")
238
+ if envs is not None and env not in envs:
239
+ continue
240
+ result.append(s)
241
+ return result
242
+
243
+ # ── observability ──
244
+
245
+ @property
246
+ def observability(self) -> dict[str, Any]:
247
+ return self.root.get("observability", {})
248
+
249
+ @property
250
+ def observability_enabled(self) -> bool:
251
+ return self.observability.get("enabled", False)
252
+
253
+ # ── gateway ──
254
+
255
+ @property
256
+ def gateway(self) -> dict[str, Any]:
257
+ return self.root.get("gateway", {})
258
+
259
+ @property
260
+ def gateway_domains(self) -> list[dict[str, Any]]:
261
+ return self.gateway.get("domains", [])
262
+
263
+ @property
264
+ def gateway_ssl(self) -> dict[str, Any]:
265
+ return self.gateway.get("ssl", {})
266
+
267
+ @property
268
+ def gateway_ssl_enabled(self) -> bool:
269
+ if not self.gateway:
270
+ return False
271
+ return self.gateway_ssl.get("enabled", True)
272
+
273
+ @property
274
+ def gateway_ssl_provider(self) -> str:
275
+ return self.gateway_ssl.get("provider", "letsencrypt")
276
+
277
+ @property
278
+ def ssl_challenge(self) -> str:
279
+ return self.gateway_ssl.get("challenge", "dns-01")
280
+
281
+ @property
282
+ def ssl_dns_provider(self) -> str:
283
+ return self.gateway_ssl.get("dnsProvider", "cloudflare")
284
+
285
+ @property
286
+ def primary_domain(self) -> str | None:
287
+ domains = self.gateway_domains
288
+ return domains[0]["host"] if domains else None
289
+
290
+ # ── persistence ──
291
+
292
+ def save(self, path: Path | None = None) -> None:
293
+ target = path or self.path
294
+ with open(target, "w") as f:
295
+ json.dump(self.data, f, indent=2)
296
+ f.write("\n")
@@ -0,0 +1,52 @@
1
+ # Copyright (C) 2026 FORKTEX S.R.L.
2
+ #
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-ForkTex-Commercial
4
+ #
5
+ # This file is part of forktex-cloud.
6
+ #
7
+ # For commercial licensing -- including use in proprietary products, SaaS
8
+ # deployments, or any context where AGPL obligations cannot be met -- you
9
+ # MUST obtain a commercial license from FORKTEX S.R.L. (info@forktex.com).
10
+ #
11
+ # This program is free software: you can redistribute it and/or modify
12
+ # it under the terms of the GNU Affero General Public License as published by
13
+ # the Free Software Foundation, either version 3 of the License, or
14
+ # (at your option) any later version.
15
+ #
16
+ # This program is distributed in the hope that it will be useful,
17
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
18
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
+ # GNU Affero General Public License for more details.
20
+ #
21
+ # You should have received a copy of the GNU Affero General Public License
22
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
23
+
24
+ """Deep merge utility for manifest overlays."""
25
+
26
+ from __future__ import annotations
27
+
28
+ from typing import Any
29
+
30
+
31
+ def deep_merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
32
+ """Recursively merge *overlay* onto *base*, returning a new dict.
33
+
34
+ - Nested dicts merge recursively (overlay wins on conflict).
35
+ - Arrays replace entirely (no element-level merge).
36
+ - Scalars from overlay overwrite base.
37
+ - Neither input dict is mutated.
38
+ """
39
+ result: dict[str, Any] = {}
40
+ all_keys = set(base) | set(overlay)
41
+ for key in all_keys:
42
+ if key in overlay and key in base:
43
+ b_val, o_val = base[key], overlay[key]
44
+ if isinstance(b_val, dict) and isinstance(o_val, dict):
45
+ result[key] = deep_merge(b_val, o_val)
46
+ else:
47
+ result[key] = o_val
48
+ elif key in overlay:
49
+ result[key] = overlay[key]
50
+ else:
51
+ result[key] = base[key]
52
+ return result
@@ -0,0 +1,88 @@
1
+ # Copyright (C) 2026 FORKTEX S.R.L.
2
+ #
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-ForkTex-Commercial
4
+ #
5
+ # This file is part of forktex-cloud.
6
+ #
7
+ # For commercial licensing -- including use in proprietary products, SaaS
8
+ # deployments, or any context where AGPL obligations cannot be met -- you
9
+ # MUST obtain a commercial license from FORKTEX S.R.L. (info@forktex.com).
10
+ #
11
+ # This program is free software: you can redistribute it and/or modify
12
+ # it under the terms of the GNU Affero General Public License as published by
13
+ # the Free Software Foundation, either version 3 of the License, or
14
+ # (at your option) any later version.
15
+ #
16
+ # This program is distributed in the hope that it will be useful,
17
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
18
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
+ # GNU Affero General Public License for more details.
20
+ #
21
+ # You should have received a copy of the GNU Affero General Public License
22
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
23
+
24
+ """Discriminated union over the generated cloud manifest kinds.
25
+
26
+ The OpenAPI codegen at ``forktex_cloud.client.generated`` emits each kind
27
+ (``ProjectDeployment``, ``StaticSite``, ``SingleContainer``, ``NativeBuild``)
28
+ as an independent ``BaseModel``, but does not emit a top-level union since
29
+ that union only exists in OpenAPI as part of nested request bodies
30
+ (``UpRequest.manifest_data``). We assemble it here so the manifest layer can
31
+ validate raw dicts against the canonical schema in one shot.
32
+
33
+ Adding a new kind: import it from ``forktex_cloud.client.generated`` and
34
+ append it to the ``CloudManifest`` union below. No other change is needed
35
+ here as long as the new kind has a unique ``kind`` literal.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ from typing import Annotated, Any
41
+
42
+ from pydantic import Field, TypeAdapter, ValidationError
43
+
44
+ from forktex_cloud.client.generated import (
45
+ NativeBuild,
46
+ )
47
+ from forktex_cloud.client.generated import (
48
+ ProjectDeploymentInput as ProjectDeployment,
49
+ )
50
+ from forktex_cloud.client.generated import (
51
+ SingleContainerInput as SingleContainer,
52
+ )
53
+ from forktex_cloud.client.generated import (
54
+ StaticSiteInput as StaticSite,
55
+ )
56
+ from forktex_cloud.manifest.errors import ManifestError
57
+
58
+ CloudManifest = Annotated[
59
+ ProjectDeployment | StaticSite | SingleContainer | NativeBuild,
60
+ Field(discriminator="kind"),
61
+ ]
62
+
63
+ _adapter: TypeAdapter[CloudManifest] = TypeAdapter(CloudManifest)
64
+
65
+
66
+ def parse_cloud_block(raw: dict[str, Any]) -> CloudManifest:
67
+ """Validate the raw ``cloud:`` block and return the typed model.
68
+
69
+ Accepts either a top-level forktex.json dict (with a ``cloud:`` key) or
70
+ a flat cloud-only dict — auto-detects shape.
71
+ """
72
+ if not isinstance(raw, dict):
73
+ raise ManifestError(f"cloud block must be a dict, got {type(raw).__name__}")
74
+ block = raw.get("cloud", raw)
75
+ try:
76
+ return _adapter.validate_python(block)
77
+ except ValidationError as exc:
78
+ raise ManifestError(str(exc)) from exc
79
+
80
+
81
+ __all__ = [
82
+ "CloudManifest",
83
+ "parse_cloud_block",
84
+ "ProjectDeployment",
85
+ "StaticSite",
86
+ "SingleContainer",
87
+ "NativeBuild",
88
+ ]