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.
- forktex_cloud/__init__.py +92 -0
- forktex_cloud/bridge/__init__.py +24 -0
- forktex_cloud/bridge/local_compose.py +295 -0
- forktex_cloud/bridge/log_formatter.py +62 -0
- forktex_cloud/bridge/loki.py +109 -0
- forktex_cloud/bridge/persistence_defaults.py +120 -0
- forktex_cloud/client/__init__.py +83 -0
- forktex_cloud/client/client.py +599 -0
- forktex_cloud/client/generated/__init__.py +1057 -0
- forktex_cloud/config.py +63 -0
- forktex_cloud/manifest/__init__.py +58 -0
- forktex_cloud/manifest/errors.py +34 -0
- forktex_cloud/manifest/loader.py +296 -0
- forktex_cloud/manifest/merge.py +52 -0
- forktex_cloud/manifest/schema.py +88 -0
- forktex_cloud/paths.py +297 -0
- forktex_cloud/scaffold/__init__.py +24 -0
- forktex_cloud/scaffold/templates.py +143 -0
- forktex_cloud/secrets/__init__.py +24 -0
- forktex_cloud/secrets/base.py +52 -0
- forktex_cloud/secrets/factory.py +57 -0
- forktex_cloud/secrets/fernet.py +96 -0
- forktex_cloud/secrets/resolver.py +59 -0
- forktex_cloud/templates/observability/loki.yml +55 -0
- forktex_cloud/templates/observability/promtail.yml +46 -0
- forktex_cloud-0.2.3.dist-info/METADATA +226 -0
- forktex_cloud-0.2.3.dist-info/RECORD +30 -0
- forktex_cloud-0.2.3.dist-info/WHEEL +4 -0
- forktex_cloud-0.2.3.dist-info/licenses/LICENSE +45 -0
- forktex_cloud-0.2.3.dist-info/licenses/NOTICE +23 -0
forktex_cloud/config.py
ADDED
|
@@ -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
|
+
]
|