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/paths.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
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
|
+
"""Canonical filesystem layout for the forktex ecosystem (V1).
|
|
25
|
+
|
|
26
|
+
Every subsystem that reads or writes under ``.forktex/`` (project scope) or
|
|
27
|
+
``~/.forktex/`` (user/OS scope) MUST go through this module. Hardcoding path
|
|
28
|
+
literals elsewhere is enforced against by ``tests/test_paths_contract.py``.
|
|
29
|
+
|
|
30
|
+
Cross-platform rules:
|
|
31
|
+
- All returned values are ``pathlib.Path`` (never ``str``).
|
|
32
|
+
- Project scope directory is always lowercase ``.forktex``.
|
|
33
|
+
- User scope directory is ``~/.forktex`` on POSIX and ``%APPDATA%/forktex`` on Windows.
|
|
34
|
+
- Secrets-bearing directories are created with mode ``0o700`` on POSIX; Windows
|
|
35
|
+
relies on per-user ACLs on ``%APPDATA%``.
|
|
36
|
+
|
|
37
|
+
See ``cloud/docs/forktex-directory-spec.md`` for the full V1 spec.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import os
|
|
43
|
+
import sys
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
|
|
46
|
+
#: Bump when the on-disk layout changes incompatibly. Written to
|
|
47
|
+
#: ``.forktex/.version`` by :func:`ensure_project_dirs` on init.
|
|
48
|
+
SCHEMA_VERSION = 1
|
|
49
|
+
|
|
50
|
+
#: Directory name at project scope.
|
|
51
|
+
PROJECT_DIRNAME = ".forktex"
|
|
52
|
+
|
|
53
|
+
#: Directory name at user/OS scope (stripped of leading dot on Windows).
|
|
54
|
+
_USER_DIRNAME_POSIX = ".forktex"
|
|
55
|
+
_USER_DIRNAME_WINDOWS = "forktex"
|
|
56
|
+
|
|
57
|
+
#: Marker comment used to idempotently detect the canonical ``.gitignore`` block.
|
|
58
|
+
_GITIGNORE_MARKER = "# forktex — generated + secrets (keep .forktex/.version committed)"
|
|
59
|
+
_GITIGNORE_BLOCK = f"""
|
|
60
|
+
{_GITIGNORE_MARKER}
|
|
61
|
+
.forktex/**
|
|
62
|
+
!.forktex/.version
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ── Project-scope paths ──────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def project_dir(root: Path) -> Path:
|
|
70
|
+
return root / PROJECT_DIRNAME
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def version_file(root: Path) -> Path:
|
|
74
|
+
return project_dir(root) / ".version"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def compose_path(root: Path, env: str) -> Path:
|
|
78
|
+
return project_dir(root) / f"docker-compose.{env}.yml"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def observability_dir(root: Path) -> Path:
|
|
82
|
+
return project_dir(root) / "observability"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def vault_dir(root: Path, env: str) -> Path:
|
|
86
|
+
return project_dir(root) / "vault" / env
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def vault_secrets_file(root: Path, env: str) -> Path:
|
|
90
|
+
return vault_dir(root, env) / "secrets.enc"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def state_dir(root: Path) -> Path:
|
|
94
|
+
return project_dir(root) / "state"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def servers_json(root: Path) -> Path:
|
|
98
|
+
return state_dir(root) / "servers.json"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def server_keys_dir(root: Path) -> Path:
|
|
102
|
+
return state_dir(root) / "keys"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def generated_dir(root: Path) -> Path:
|
|
106
|
+
return project_dir(root) / "generated"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def data_dir(root: Path, service_id: str) -> Path:
|
|
110
|
+
return project_dir(root) / "data" / service_id
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def custom_ssl_dir(root: Path) -> Path:
|
|
114
|
+
return project_dir(root) / "ssl" / "custom"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def fsd_evidence_dir(root: Path) -> Path:
|
|
118
|
+
return project_dir(root) / "fsd" / "evidence"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def architecture_dir(root: Path) -> Path:
|
|
122
|
+
return project_dir(root) / "architecture"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def agents_history_dir(root: Path) -> Path:
|
|
126
|
+
return project_dir(root) / "agents" / "history"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def agents_history_file(root: Path, agent_id: str) -> Path:
|
|
130
|
+
return agents_history_dir(root) / f"{agent_id}.jsonl"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def agents_types_file(root: Path) -> Path:
|
|
134
|
+
return project_dir(root) / "agents" / "types.json"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def scraper_truths_dir(root: Path) -> Path:
|
|
138
|
+
return project_dir(root) / "scraper" / "truths"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def scraper_truths_file(root: Path, domain: str) -> Path:
|
|
142
|
+
return scraper_truths_dir(root) / f"{domain}.json"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def scraper_output_dir(root: Path) -> Path:
|
|
146
|
+
return project_dir(root) / "scraper" / "output"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def project_config_file(root: Path) -> Path:
|
|
150
|
+
return project_dir(root) / "config.json"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def project_cloud_file(root: Path) -> Path:
|
|
154
|
+
return project_dir(root) / "cloud" / "config.json"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def project_intelligence_file(root: Path) -> Path:
|
|
158
|
+
return project_dir(root) / "intelligence.json"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def project_network_file(root: Path) -> Path:
|
|
162
|
+
return project_dir(root) / "network.json"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ── User/OS-scope paths ──────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def global_dir() -> Path:
|
|
169
|
+
"""Return the global forktex directory, cross-platform.
|
|
170
|
+
|
|
171
|
+
POSIX: ``~/.forktex/``. Windows: ``%APPDATA%/forktex/`` (roaming user profile).
|
|
172
|
+
"""
|
|
173
|
+
if sys.platform == "win32":
|
|
174
|
+
appdata = os.environ.get("APPDATA")
|
|
175
|
+
base = Path(appdata) if appdata else Path.home()
|
|
176
|
+
return base / _USER_DIRNAME_WINDOWS
|
|
177
|
+
return Path.home() / _USER_DIRNAME_POSIX
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def global_cloud_file() -> Path:
|
|
181
|
+
return global_dir() / "cloud.json"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def global_intelligence_file() -> Path:
|
|
185
|
+
return global_dir() / "intelligence.json"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def global_network_file() -> Path:
|
|
189
|
+
return global_dir() / "network.json"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def global_config_file() -> Path:
|
|
193
|
+
return global_dir() / "config.toml"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ── Lifecycle helpers ────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def ensure_project_dirs(root: Path) -> None:
|
|
200
|
+
"""Create ``.forktex/`` under *root*, write the schema version marker, and
|
|
201
|
+
ensure the project ``.gitignore`` has the canonical forktex block.
|
|
202
|
+
|
|
203
|
+
Safe to call repeatedly. Does not touch existing files.
|
|
204
|
+
"""
|
|
205
|
+
pdir = project_dir(root)
|
|
206
|
+
pdir.mkdir(parents=True, exist_ok=True)
|
|
207
|
+
write_schema_version(root)
|
|
208
|
+
_ensure_gitignore_block(root)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def ensure_global_dir() -> None:
|
|
212
|
+
"""Create the user/OS-scope forktex dir with secure permissions on POSIX."""
|
|
213
|
+
gdir = global_dir()
|
|
214
|
+
gdir.mkdir(parents=True, exist_ok=True)
|
|
215
|
+
if sys.platform != "win32":
|
|
216
|
+
try:
|
|
217
|
+
gdir.chmod(0o700)
|
|
218
|
+
except OSError:
|
|
219
|
+
# Non-fatal: existing dir with stricter perms is fine.
|
|
220
|
+
pass
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def read_schema_version(root: Path) -> int | None:
|
|
224
|
+
"""Return the on-disk ``.forktex/.version`` as int, or ``None`` if missing."""
|
|
225
|
+
vf = version_file(root)
|
|
226
|
+
if not vf.is_file():
|
|
227
|
+
return None
|
|
228
|
+
try:
|
|
229
|
+
return int(vf.read_text().strip())
|
|
230
|
+
except (ValueError, OSError):
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def write_schema_version(root: Path) -> None:
|
|
235
|
+
"""Write ``SCHEMA_VERSION`` to ``.forktex/.version`` if not already correct."""
|
|
236
|
+
vf = version_file(root)
|
|
237
|
+
if read_schema_version(root) == SCHEMA_VERSION:
|
|
238
|
+
return
|
|
239
|
+
vf.parent.mkdir(parents=True, exist_ok=True)
|
|
240
|
+
vf.write_text(f"{SCHEMA_VERSION}\n")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _ensure_gitignore_block(root: Path) -> None:
|
|
244
|
+
gi = root / ".gitignore"
|
|
245
|
+
if gi.is_file():
|
|
246
|
+
existing = gi.read_text()
|
|
247
|
+
if _GITIGNORE_MARKER in existing:
|
|
248
|
+
return
|
|
249
|
+
# Append, ensuring a trailing newline separator.
|
|
250
|
+
if not existing.endswith("\n"):
|
|
251
|
+
existing += "\n"
|
|
252
|
+
gi.write_text(existing + _GITIGNORE_BLOCK)
|
|
253
|
+
else:
|
|
254
|
+
gi.write_text(_GITIGNORE_BLOCK.lstrip("\n"))
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
__all__ = [
|
|
258
|
+
# Constants
|
|
259
|
+
"SCHEMA_VERSION",
|
|
260
|
+
"PROJECT_DIRNAME",
|
|
261
|
+
# Project-scope
|
|
262
|
+
"project_dir",
|
|
263
|
+
"version_file",
|
|
264
|
+
"compose_path",
|
|
265
|
+
"observability_dir",
|
|
266
|
+
"vault_dir",
|
|
267
|
+
"vault_secrets_file",
|
|
268
|
+
"state_dir",
|
|
269
|
+
"servers_json",
|
|
270
|
+
"server_keys_dir",
|
|
271
|
+
"generated_dir",
|
|
272
|
+
"data_dir",
|
|
273
|
+
"custom_ssl_dir",
|
|
274
|
+
"fsd_evidence_dir",
|
|
275
|
+
"architecture_dir",
|
|
276
|
+
"agents_history_dir",
|
|
277
|
+
"agents_history_file",
|
|
278
|
+
"agents_types_file",
|
|
279
|
+
"scraper_truths_dir",
|
|
280
|
+
"scraper_truths_file",
|
|
281
|
+
"scraper_output_dir",
|
|
282
|
+
"project_config_file",
|
|
283
|
+
"project_cloud_file",
|
|
284
|
+
"project_intelligence_file",
|
|
285
|
+
"project_network_file",
|
|
286
|
+
# User/OS-scope
|
|
287
|
+
"global_dir",
|
|
288
|
+
"global_cloud_file",
|
|
289
|
+
"global_intelligence_file",
|
|
290
|
+
"global_network_file",
|
|
291
|
+
"global_config_file",
|
|
292
|
+
# Lifecycle
|
|
293
|
+
"ensure_project_dirs",
|
|
294
|
+
"ensure_global_dir",
|
|
295
|
+
"read_schema_version",
|
|
296
|
+
"write_schema_version",
|
|
297
|
+
]
|
|
@@ -0,0 +1,24 @@
|
|
|
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
|
+
"""Project scaffolding templates."""
|
|
@@ -0,0 +1,143 @@
|
|
|
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
|
+
"""forktex cloud init -- scaffold a new forktex.json manifest."""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
TEMPLATES: dict[str, dict] = {
|
|
32
|
+
"ProjectDeployment": {
|
|
33
|
+
"cloud": {
|
|
34
|
+
"apiVersion": "forktex.cloud/v1beta2",
|
|
35
|
+
"kind": "ProjectDeployment",
|
|
36
|
+
"metadata": {
|
|
37
|
+
"name": "",
|
|
38
|
+
"environment": "production",
|
|
39
|
+
},
|
|
40
|
+
"deployment": {
|
|
41
|
+
"strategy": "blue-green",
|
|
42
|
+
"router": "haproxy",
|
|
43
|
+
"defaultComponent": "",
|
|
44
|
+
"retries": 45,
|
|
45
|
+
"drainDelaySeconds": 5,
|
|
46
|
+
"gracefulStopSeconds": 30,
|
|
47
|
+
},
|
|
48
|
+
"gateway": {
|
|
49
|
+
"domains": [],
|
|
50
|
+
"ssl": {
|
|
51
|
+
"enabled": False,
|
|
52
|
+
"provider": "letsencrypt",
|
|
53
|
+
"email": "",
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
"services": [],
|
|
57
|
+
"observability": {
|
|
58
|
+
"enabled": True,
|
|
59
|
+
"logging": {"enabled": True},
|
|
60
|
+
"metrics": {"enabled": True},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
"StaticSite": {
|
|
65
|
+
"cloud": {
|
|
66
|
+
"apiVersion": "forktex.cloud/v1beta2",
|
|
67
|
+
"kind": "StaticSite",
|
|
68
|
+
"metadata": {
|
|
69
|
+
"name": "",
|
|
70
|
+
},
|
|
71
|
+
"build": {
|
|
72
|
+
"command": "npm run build",
|
|
73
|
+
"outputDir": "dist",
|
|
74
|
+
"installCommand": "npm install",
|
|
75
|
+
},
|
|
76
|
+
"serve": {
|
|
77
|
+
"router": "nginx",
|
|
78
|
+
"spa": True,
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
"SingleContainer": {
|
|
83
|
+
"cloud": {
|
|
84
|
+
"apiVersion": "forktex.cloud/v1beta2",
|
|
85
|
+
"kind": "SingleContainer",
|
|
86
|
+
"metadata": {
|
|
87
|
+
"name": "",
|
|
88
|
+
},
|
|
89
|
+
"container": {
|
|
90
|
+
"image": "",
|
|
91
|
+
"port": 3000,
|
|
92
|
+
"healthPath": "/health",
|
|
93
|
+
},
|
|
94
|
+
"deployment": {
|
|
95
|
+
"strategy": "blue-green",
|
|
96
|
+
"router": "haproxy",
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
"NativeBuild": {
|
|
101
|
+
"cloud": {
|
|
102
|
+
"apiVersion": "forktex.cloud/v1beta2",
|
|
103
|
+
"kind": "NativeBuild",
|
|
104
|
+
"metadata": {
|
|
105
|
+
"name": "",
|
|
106
|
+
},
|
|
107
|
+
"build": {
|
|
108
|
+
"command": "",
|
|
109
|
+
"outputBinary": "",
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def scaffold_manifest(
|
|
117
|
+
project_root: Path,
|
|
118
|
+
*,
|
|
119
|
+
kind: str = "ProjectDeployment",
|
|
120
|
+
name: str | None = None,
|
|
121
|
+
force: bool = False,
|
|
122
|
+
) -> Path:
|
|
123
|
+
"""Create forktex.json at the project root.
|
|
124
|
+
|
|
125
|
+
Returns the path to the created manifest.
|
|
126
|
+
"""
|
|
127
|
+
target = project_root / "forktex.json"
|
|
128
|
+
|
|
129
|
+
if target.exists() and not force:
|
|
130
|
+
raise FileExistsError(f"forktex.json already exists: {target}. Use --force to overwrite.")
|
|
131
|
+
|
|
132
|
+
template = TEMPLATES.get(kind)
|
|
133
|
+
if template is None:
|
|
134
|
+
raise ValueError(f"Unknown kind: {kind}. Available: {', '.join(TEMPLATES.keys())}")
|
|
135
|
+
|
|
136
|
+
manifest = json.loads(json.dumps(template)) # deep copy
|
|
137
|
+
project_name = name or project_root.name
|
|
138
|
+
manifest["name"] = project_name
|
|
139
|
+
if "cloud" in manifest and "metadata" in manifest["cloud"]:
|
|
140
|
+
manifest["cloud"]["metadata"]["name"] = project_name
|
|
141
|
+
|
|
142
|
+
target.write_text(json.dumps(manifest, indent=2) + "\n")
|
|
143
|
+
return target
|
|
@@ -0,0 +1,24 @@
|
|
|
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
|
+
"""Secrets management: Fernet vault, provider factory, and vault reference resolver."""
|
|
@@ -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
|
+
"""Secrets provider abstract base class."""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from abc import ABC, abstractmethod
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SecretsProvider(ABC):
|
|
32
|
+
"""Abstract base for secrets providers."""
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def get(self, key: str, env: str = "default") -> str:
|
|
36
|
+
"""Retrieve a secret value by key."""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def set(self, key: str, value: str, env: str = "default") -> None:
|
|
41
|
+
"""Store a secret value."""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def list_keys(self, env: str = "default") -> list[str]:
|
|
46
|
+
"""List all secret keys for an environment."""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def delete(self, key: str, env: str = "default") -> None:
|
|
51
|
+
"""Delete a secret by key."""
|
|
52
|
+
...
|
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
"""Secrets provider factory."""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from forktex_cloud import paths
|
|
32
|
+
from forktex_cloud.secrets.base import SecretsProvider
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_secrets_provider(
|
|
36
|
+
*,
|
|
37
|
+
project_root: Path | None = None,
|
|
38
|
+
provider_name: str | None = None,
|
|
39
|
+
master_key: str | None = None,
|
|
40
|
+
) -> SecretsProvider:
|
|
41
|
+
"""Create and return the configured SecretsProvider.
|
|
42
|
+
|
|
43
|
+
Resolution order for provider name:
|
|
44
|
+
1. Explicit ``provider_name`` argument
|
|
45
|
+
2. ``FORKTEX_SECRETS_PROVIDER`` environment variable
|
|
46
|
+
3. Defaults to ``"fernet"``
|
|
47
|
+
"""
|
|
48
|
+
name = provider_name or os.environ.get("FORKTEX_SECRETS_PROVIDER", "fernet")
|
|
49
|
+
|
|
50
|
+
if name == "fernet":
|
|
51
|
+
from forktex_cloud.secrets.fernet import FernetVault
|
|
52
|
+
|
|
53
|
+
root = project_root or Path.cwd()
|
|
54
|
+
vault_root = paths.project_dir(root) / "vault"
|
|
55
|
+
return FernetVault(vault_root, master_key=master_key)
|
|
56
|
+
|
|
57
|
+
raise ValueError(f"Unknown secrets provider: {name}")
|
|
@@ -0,0 +1,96 @@
|
|
|
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
|
+
"""Built-in Fernet-encrypted file vault for secrets."""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from forktex_cloud.secrets.base import SecretsProvider
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class FernetVault(SecretsProvider):
|
|
36
|
+
"""File-backed secrets vault using Fernet symmetric encryption.
|
|
37
|
+
|
|
38
|
+
Storage layout: ``.forktex/vault/{env}/secrets.enc``
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, vault_root: Path, master_key: str | None = None) -> None:
|
|
42
|
+
self._root = vault_root
|
|
43
|
+
self._key = master_key or os.environ.get("FORKTEX_MASTER_KEY", "")
|
|
44
|
+
if not self._key:
|
|
45
|
+
raise ValueError(
|
|
46
|
+
"Master key required for vault operations. "
|
|
47
|
+
"Set FORKTEX_MASTER_KEY or pass master_key."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def _fernet(self):
|
|
51
|
+
from cryptography.fernet import Fernet
|
|
52
|
+
|
|
53
|
+
return Fernet(self._key.encode() if isinstance(self._key, str) else self._key)
|
|
54
|
+
|
|
55
|
+
def _vault_path(self, env: str) -> Path:
|
|
56
|
+
return self._root / env / "secrets.enc"
|
|
57
|
+
|
|
58
|
+
def _load(self, env: str) -> dict[str, str]:
|
|
59
|
+
path = self._vault_path(env)
|
|
60
|
+
if not path.exists():
|
|
61
|
+
return {}
|
|
62
|
+
encrypted = path.read_bytes()
|
|
63
|
+
try:
|
|
64
|
+
decrypted = self._fernet().decrypt(encrypted)
|
|
65
|
+
except Exception:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"Failed to decrypt vault ({path}). "
|
|
68
|
+
"Check that FORKTEX_MASTER_KEY matches the key used to encrypt."
|
|
69
|
+
)
|
|
70
|
+
return json.loads(decrypted)
|
|
71
|
+
|
|
72
|
+
def _save(self, env: str, data: dict[str, str]) -> None:
|
|
73
|
+
path = self._vault_path(env)
|
|
74
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
raw = json.dumps(data, indent=2).encode()
|
|
76
|
+
encrypted = self._fernet().encrypt(raw)
|
|
77
|
+
path.write_bytes(encrypted)
|
|
78
|
+
|
|
79
|
+
def get(self, key: str, env: str = "default") -> str:
|
|
80
|
+
data = self._load(env)
|
|
81
|
+
if key not in data:
|
|
82
|
+
raise KeyError(f"Secret not found: {key} (env={env})")
|
|
83
|
+
return data[key]
|
|
84
|
+
|
|
85
|
+
def set(self, key: str, value: str, env: str = "default") -> None:
|
|
86
|
+
data = self._load(env)
|
|
87
|
+
data[key] = value
|
|
88
|
+
self._save(env, data)
|
|
89
|
+
|
|
90
|
+
def list_keys(self, env: str = "default") -> list[str]:
|
|
91
|
+
return sorted(self._load(env).keys())
|
|
92
|
+
|
|
93
|
+
def delete(self, key: str, env: str = "default") -> None:
|
|
94
|
+
data = self._load(env)
|
|
95
|
+
data.pop(key, None)
|
|
96
|
+
self._save(env, data)
|