xenfra-sdk 0.2.5__py3-none-any.whl → 0.2.7__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.
- xenfra_sdk/__init__.py +46 -2
- xenfra_sdk/blueprints/base.py +150 -0
- xenfra_sdk/blueprints/factory.py +99 -0
- xenfra_sdk/blueprints/node.py +219 -0
- xenfra_sdk/blueprints/python.py +57 -0
- xenfra_sdk/blueprints/railpack.py +99 -0
- xenfra_sdk/blueprints/schema.py +70 -0
- xenfra_sdk/cli/main.py +175 -49
- xenfra_sdk/client.py +6 -2
- xenfra_sdk/constants.py +26 -0
- xenfra_sdk/db/session.py +8 -3
- xenfra_sdk/detection.py +262 -191
- xenfra_sdk/dockerizer.py +76 -120
- xenfra_sdk/engine.py +767 -172
- xenfra_sdk/events.py +254 -0
- xenfra_sdk/exceptions.py +9 -0
- xenfra_sdk/governance.py +150 -0
- xenfra_sdk/manifest.py +93 -138
- xenfra_sdk/mcp_client.py +7 -5
- xenfra_sdk/{models.py → models/__init__.py} +17 -1
- xenfra_sdk/models/context.py +61 -0
- xenfra_sdk/orchestrator.py +223 -99
- xenfra_sdk/privacy.py +11 -0
- xenfra_sdk/protocol.py +38 -0
- xenfra_sdk/railpack_adapter.py +357 -0
- xenfra_sdk/railpack_detector.py +587 -0
- xenfra_sdk/railpack_manager.py +312 -0
- xenfra_sdk/recipes.py +152 -19
- xenfra_sdk/resources/activity.py +45 -0
- xenfra_sdk/resources/build.py +157 -0
- xenfra_sdk/resources/deployments.py +22 -2
- xenfra_sdk/resources/intelligence.py +25 -0
- xenfra_sdk-0.2.7.dist-info/METADATA +118 -0
- xenfra_sdk-0.2.7.dist-info/RECORD +49 -0
- {xenfra_sdk-0.2.5.dist-info → xenfra_sdk-0.2.7.dist-info}/WHEEL +1 -1
- xenfra_sdk/templates/Caddyfile.j2 +0 -14
- xenfra_sdk/templates/Dockerfile.j2 +0 -41
- xenfra_sdk/templates/cloud-init.sh.j2 +0 -90
- xenfra_sdk/templates/docker-compose-multi.yml.j2 +0 -29
- xenfra_sdk/templates/docker-compose.yml.j2 +0 -30
- xenfra_sdk-0.2.5.dist-info/METADATA +0 -116
- xenfra_sdk-0.2.5.dist-info/RECORD +0 -38
xenfra_sdk/orchestrator.py
CHANGED
|
@@ -12,7 +12,18 @@ from typing import Callable, Dict, List, Optional
|
|
|
12
12
|
import yaml
|
|
13
13
|
|
|
14
14
|
from .manifest import ServiceDefinition, load_services_from_xenfra_yaml
|
|
15
|
-
from . import
|
|
15
|
+
from . import constants
|
|
16
|
+
from .governance import get_resource_limits
|
|
17
|
+
from .blueprints.factory import render_blueprint
|
|
18
|
+
from .blueprints.schema import (
|
|
19
|
+
ComposeModel,
|
|
20
|
+
ServiceDetail,
|
|
21
|
+
DeployModel,
|
|
22
|
+
DeployResourcesModel,
|
|
23
|
+
ResourceLimitsModel,
|
|
24
|
+
ResourceReservationsModel
|
|
25
|
+
)
|
|
26
|
+
from .models.context import DeploymentContext
|
|
16
27
|
|
|
17
28
|
|
|
18
29
|
class ServiceOrchestrator:
|
|
@@ -24,7 +35,7 @@ class ServiceOrchestrator:
|
|
|
24
35
|
- multi-droplet: Each service on separate droplets with private networking
|
|
25
36
|
"""
|
|
26
37
|
|
|
27
|
-
def __init__(self, engine, services: List[ServiceDefinition], project_name: str, mode: str = "single-droplet", file_manifest: List[dict] = None):
|
|
38
|
+
def __init__(self, engine, services: List[ServiceDefinition], project_name: str, mode: str = "single-droplet", file_manifest: List[dict] = None, tier: str = "FREE"):
|
|
28
39
|
"""
|
|
29
40
|
Initialize the orchestrator.
|
|
30
41
|
|
|
@@ -34,6 +45,7 @@ class ServiceOrchestrator:
|
|
|
34
45
|
project_name: Name of the project
|
|
35
46
|
mode: Deployment mode (single-droplet or multi-droplet)
|
|
36
47
|
file_manifest: List of files to be uploaded (delta upload)
|
|
48
|
+
tier: User tier for resource governance
|
|
37
49
|
"""
|
|
38
50
|
self.engine = engine
|
|
39
51
|
self.services = services
|
|
@@ -42,6 +54,7 @@ class ServiceOrchestrator:
|
|
|
42
54
|
self.file_manifest = file_manifest or []
|
|
43
55
|
self.token = engine.token
|
|
44
56
|
self.manager = engine.manager
|
|
57
|
+
self.tier = tier
|
|
45
58
|
|
|
46
59
|
def deploy(self, logger: Callable = print, **kwargs) -> Dict:
|
|
47
60
|
"""
|
|
@@ -89,7 +102,8 @@ class ServiceOrchestrator:
|
|
|
89
102
|
logger(f"\n[dim]Recommended droplet size: {size}[/dim]")
|
|
90
103
|
|
|
91
104
|
# Step 2: Generate multi-service docker-compose.yml
|
|
92
|
-
|
|
105
|
+
global_env = kwargs.get("env_vars") or {}
|
|
106
|
+
compose_content = self._generate_docker_compose(global_env=global_env)
|
|
93
107
|
logger(f"[dim]Generated docker-compose.yml ({len(compose_content)} bytes)[/dim]")
|
|
94
108
|
|
|
95
109
|
# Step 3: Generate Caddyfile for path-based routing
|
|
@@ -117,7 +131,7 @@ class ServiceOrchestrator:
|
|
|
117
131
|
explicit_args = [
|
|
118
132
|
"name", "size", "framework", "port", "is_dockerized",
|
|
119
133
|
"entrypoint", "multi_service_compose", "multi_service_caddy",
|
|
120
|
-
"services", "logger", "extra_assets"
|
|
134
|
+
"services", "logger", "extra_assets", "tier"
|
|
121
135
|
]
|
|
122
136
|
for arg in explicit_args:
|
|
123
137
|
safe_kwargs.pop(arg, None)
|
|
@@ -155,7 +169,7 @@ class ServiceOrchestrator:
|
|
|
155
169
|
|
|
156
170
|
# Find the file in manifest and get its content
|
|
157
171
|
existing_finfo = next(f for f in self.file_manifest if f.get("path") in [dockerfile_name, f"./{dockerfile_name}", f"{svc.path}/Dockerfile" if (svc.path and svc.path != ".") else "Dockerfile"])
|
|
158
|
-
from .
|
|
172
|
+
from .exceptions import DeploymentError
|
|
159
173
|
if not self.engine.get_file_content:
|
|
160
174
|
raise DeploymentError("Cannot inject deps into existing Dockerfile: get_file_content not available", stage="Asset Generation")
|
|
161
175
|
|
|
@@ -180,50 +194,118 @@ class ServiceOrchestrator:
|
|
|
180
194
|
extra_assets[dockerfile_name] = content
|
|
181
195
|
continue
|
|
182
196
|
|
|
183
|
-
#
|
|
184
|
-
|
|
185
|
-
if
|
|
186
|
-
if svc.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
197
|
+
# Filter file_manifest for this specific service path
|
|
198
|
+
service_manifest = []
|
|
199
|
+
if self.file_manifest:
|
|
200
|
+
prefix = f"{svc.path}/" if (svc.path and svc.path != ".") else ""
|
|
201
|
+
|
|
202
|
+
# Critical files that need content hydration for blueprint detection
|
|
203
|
+
CRITICAL_CONFIGS = {
|
|
204
|
+
"package.json", "package-lock.json",
|
|
205
|
+
"next.config.js", "next.config.ts", "next.config.mjs",
|
|
206
|
+
"nuxt.config.js", "nuxt.config.ts",
|
|
207
|
+
"vite.config.js", "vite.config.ts",
|
|
208
|
+
"requirements.txt", "pyproject.toml", "Pipfile",
|
|
209
|
+
"go.mod", "Cargo.toml", "deno.json"
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
for f in self.file_manifest:
|
|
213
|
+
fpath = f.get("path", "")
|
|
214
|
+
if fpath.startswith(prefix):
|
|
215
|
+
# Strip prefix for the blueprint logic
|
|
216
|
+
new_f = f.copy()
|
|
217
|
+
new_f["path"] = fpath[len(prefix):]
|
|
218
|
+
|
|
219
|
+
# Hydrate content for critical config files
|
|
220
|
+
# This allows RailpackBlueprint to inspect dependencies (e.g. detect "next" in package.json)
|
|
221
|
+
if new_f["path"] in CRITICAL_CONFIGS and "content" not in new_f and self.engine.get_file_content:
|
|
222
|
+
try:
|
|
223
|
+
logger(f" - [Zen Mode] Hydrating manifest content for {new_f['path']}...")
|
|
224
|
+
content_bytes = self.engine.get_file_content(f.get("sha"))
|
|
225
|
+
if content_bytes:
|
|
226
|
+
new_f["content"] = content_bytes.decode("utf-8", errors="ignore")
|
|
227
|
+
except Exception as e:
|
|
228
|
+
logger(f" [yellow]Warning: Failed to hydrate {new_f['path']}: {e}[/yellow]")
|
|
229
|
+
|
|
230
|
+
service_manifest.append(new_f)
|
|
231
|
+
|
|
232
|
+
# Protocol Compliance: Use Blueprint Factory instead of legacy dockerizer
|
|
233
|
+
# Build context for this specific service
|
|
234
|
+
# ZEN DEBUG: Log framework being used for Dockerfile generation
|
|
235
|
+
# Fix: Do not default to 'python'. Let factory.py resolve it (supports Railpack fallback)
|
|
236
|
+
framework_to_use = svc.framework
|
|
237
|
+
logger(f" - [Blueprint] Generating Dockerfile for {svc.name} (framework={framework_to_use})")
|
|
192
238
|
|
|
193
|
-
#
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
"
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
239
|
+
# Resolve source path for Railpack analysis
|
|
240
|
+
# Fixes "User Environment Leak" where Railpack defaults to CWD if no source_path is provided
|
|
241
|
+
source_path = None
|
|
242
|
+
local_root = self.engine.context.get('local_source_path')
|
|
243
|
+
if local_root:
|
|
244
|
+
if svc.path and svc.path != ".":
|
|
245
|
+
source_path = str(Path(local_root) / svc.path)
|
|
246
|
+
else:
|
|
247
|
+
source_path = local_root
|
|
248
|
+
|
|
249
|
+
ctx = DeploymentContext(
|
|
250
|
+
project_name=f"{self.project_name}-{svc.name}",
|
|
251
|
+
email=self.engine.get_user_info().email,
|
|
252
|
+
framework=framework_to_use,
|
|
253
|
+
port=svc.port,
|
|
254
|
+
entrypoint=svc.entrypoint,
|
|
255
|
+
env_vars={**(svc.env or {}), **global_env},
|
|
256
|
+
tier=self.tier,
|
|
257
|
+
file_manifest=service_manifest,
|
|
258
|
+
source_path=source_path # Pass explicit source path
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
assets = render_blueprint(ctx.model_dump())
|
|
203
262
|
if "Dockerfile" in assets:
|
|
204
263
|
extra_assets[dockerfile_name] = assets["Dockerfile"]
|
|
264
|
+
|
|
205
265
|
|
|
266
|
+
# Consolidate all environment variables into a single .env file
|
|
267
|
+
# This ensures they are available during 'docker build' for Next.js
|
|
268
|
+
master_env = {**(global_env or {})}
|
|
269
|
+
for svc in self.services:
|
|
270
|
+
if svc.env:
|
|
271
|
+
master_env.update(svc.env)
|
|
272
|
+
|
|
273
|
+
if master_env:
|
|
274
|
+
# Use double quotes for values to handle spaces and special chars safely
|
|
275
|
+
env_content = "\n".join([f'{k}="{v}"' for k, v in master_env.items()])
|
|
276
|
+
extra_assets[".env"] = env_content
|
|
277
|
+
logger(f" - [Zen Mode] Consolidated {len(master_env)} variables into .env asset")
|
|
278
|
+
|
|
206
279
|
if extra_assets:
|
|
207
|
-
logger(f"[dim]Generated {len(extra_assets)}
|
|
280
|
+
logger(f"[dim]Generated {len(extra_assets)} deployment assets[/dim]")
|
|
208
281
|
|
|
209
282
|
deployment_result = self.engine.deploy_server(
|
|
210
283
|
name=self.project_name,
|
|
211
284
|
size=size,
|
|
212
285
|
framework=primary_service.framework,
|
|
213
|
-
port=80, # Caddy listens on 80
|
|
286
|
+
port=80, # Caddy always listens on 80 for the gateway
|
|
214
287
|
is_dockerized=True,
|
|
215
|
-
entrypoint=None,
|
|
216
|
-
# Pass multi-service config
|
|
288
|
+
entrypoint=None,
|
|
217
289
|
multi_service_compose=compose_content,
|
|
218
290
|
multi_service_caddy=caddy_content,
|
|
219
|
-
extra_assets=extra_assets,
|
|
291
|
+
extra_assets=extra_assets,
|
|
220
292
|
services=self.services,
|
|
221
293
|
logger=logger,
|
|
294
|
+
tier=self.tier,
|
|
222
295
|
**safe_kwargs
|
|
223
296
|
)
|
|
224
297
|
|
|
225
|
-
|
|
226
|
-
|
|
298
|
+
# ZEN MODE: Propagate Dry Run
|
|
299
|
+
if isinstance(deployment_result, dict) and deployment_result.get("status") == "DRY_RUN":
|
|
300
|
+
return deployment_result
|
|
301
|
+
|
|
302
|
+
if isinstance(deployment_result, dict):
|
|
303
|
+
result["droplet"] = deployment_result.get("droplet")
|
|
304
|
+
result["url"] = f"http://{deployment_result.get('ip_address')}"
|
|
305
|
+
else:
|
|
306
|
+
# DigitalOcean Droplet object
|
|
307
|
+
result["droplet"] = deployment_result
|
|
308
|
+
result["url"] = f"http://{deployment_result.ip_address}"
|
|
227
309
|
|
|
228
310
|
# Step 5: Health check all services
|
|
229
311
|
if result["droplet"]:
|
|
@@ -254,82 +336,107 @@ class ServiceOrchestrator:
|
|
|
254
336
|
|
|
255
337
|
def _calculate_droplet_size(self) -> str:
|
|
256
338
|
"""
|
|
257
|
-
Recommend droplet size based on number of services.
|
|
258
|
-
|
|
259
|
-
Guidelines:
|
|
260
|
-
- 1-2 services: s-1vcpu-2gb ($12/month)
|
|
261
|
-
- 3-5 services: s-2vcpu-4gb ($24/month)
|
|
262
|
-
- 6+ services: s-4vcpu-8gb ($48/month)
|
|
339
|
+
Recommend droplet size based on number of services using constants.
|
|
263
340
|
"""
|
|
341
|
+
from . import constants
|
|
264
342
|
service_count = len(self.services)
|
|
265
343
|
|
|
266
344
|
if service_count <= 2:
|
|
267
|
-
return
|
|
345
|
+
return constants.SIZE_LIGHT
|
|
268
346
|
elif service_count <= 5:
|
|
269
|
-
return
|
|
347
|
+
return constants.SIZE_MEDIUM
|
|
270
348
|
else:
|
|
271
|
-
return
|
|
349
|
+
return constants.SIZE_HEAVY
|
|
272
350
|
|
|
273
|
-
def _generate_docker_compose(self) -> str:
|
|
351
|
+
def _generate_docker_compose(self, global_env: Dict[str, str] = None) -> str:
|
|
274
352
|
"""
|
|
275
|
-
Generate docker-compose.yml
|
|
276
|
-
|
|
277
|
-
Each service gets:
|
|
278
|
-
- Its own container
|
|
279
|
-
- Port mapping
|
|
280
|
-
- Environment variables
|
|
281
|
-
- Restart policy
|
|
353
|
+
Generate docker-compose.yml using ComposeModel Pydantic schema.
|
|
282
354
|
"""
|
|
283
|
-
|
|
355
|
+
services_map = {}
|
|
284
356
|
|
|
285
357
|
for svc in self.services:
|
|
286
|
-
#
|
|
358
|
+
# Resolve source path for Railpack analysis
|
|
359
|
+
source_path = None
|
|
360
|
+
local_root = self.engine.context.get('local_source_path')
|
|
361
|
+
if local_root:
|
|
362
|
+
if svc.path and svc.path != ".":
|
|
363
|
+
source_path = str(Path(local_root) / svc.path)
|
|
364
|
+
else:
|
|
365
|
+
source_path = local_root
|
|
366
|
+
|
|
367
|
+
# Build context for this service to get protocol-compliant command/limits
|
|
368
|
+
temp_ctx = DeploymentContext(
|
|
369
|
+
project_name=svc.name,
|
|
370
|
+
email="admin@xenfra.tech",
|
|
371
|
+
framework=svc.framework,
|
|
372
|
+
port=svc.port,
|
|
373
|
+
entrypoint=svc.entrypoint,
|
|
374
|
+
tier=self.tier,
|
|
375
|
+
source_path=source_path # Pass explicit source path
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
# Resolve command if not explicit (Zen Mode)
|
|
287
379
|
command = svc.command
|
|
288
380
|
if not command and svc.entrypoint:
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
command =
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
381
|
+
# Use standard blueprint-derived command logic
|
|
382
|
+
from .blueprints.python import PythonBlueprint
|
|
383
|
+
from .blueprints.node import NodeBlueprint
|
|
384
|
+
# This is still a bit coupled, but better than naked if-elifs
|
|
385
|
+
if svc.framework in ("fastapi", "flask", "django"):
|
|
386
|
+
command = PythonBlueprint(temp_ctx.model_dump())._get_default_command()
|
|
387
|
+
elif svc.framework == "nodejs":
|
|
388
|
+
command = NodeBlueprint(temp_ctx.model_dump())._get_default_command()
|
|
389
|
+
|
|
390
|
+
# Create ServiceDetail model
|
|
391
|
+
# Create ServiceDetail model
|
|
392
|
+
# ZEN FIX: Map flat ResourceLimits to nested DeployModel
|
|
393
|
+
tier_limits = get_resource_limits(self.tier)
|
|
394
|
+
service_detail = ServiceDetail(
|
|
395
|
+
build=svc.path or ".",
|
|
396
|
+
ports=[f"{svc.port}:{svc.port}"],
|
|
397
|
+
env_file=[".env"],
|
|
398
|
+
command=command,
|
|
399
|
+
deploy=DeployModel(
|
|
400
|
+
resources=DeployResourcesModel(
|
|
401
|
+
limits=ResourceLimitsModel(
|
|
402
|
+
memory=tier_limits.memory,
|
|
403
|
+
cpus=tier_limits.cpus
|
|
404
|
+
),
|
|
405
|
+
reservations=ResourceReservationsModel(
|
|
406
|
+
memory=tier_limits.memory_reserved,
|
|
407
|
+
cpus=tier_limits.cpus_reserved
|
|
408
|
+
)
|
|
409
|
+
)
|
|
410
|
+
)
|
|
411
|
+
)
|
|
310
412
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
413
|
+
# Use specific Dockerfile if generated
|
|
414
|
+
if f"Dockerfile.{svc.name}" in [f.get("path") for f in self.file_manifest] or True: # Force for orchestrator
|
|
415
|
+
service_detail.build_context = {
|
|
416
|
+
"context": svc.path or ".",
|
|
417
|
+
"dockerfile": f"Dockerfile.{svc.name}"
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
services_map[svc.name] = service_detail
|
|
316
421
|
|
|
317
|
-
|
|
422
|
+
compose = ComposeModel(services=services_map)
|
|
423
|
+
return yaml.dump(compose.model_dump(by_alias=True, exclude_none=True), sort_keys=False, default_flow_style=False)
|
|
318
424
|
|
|
319
425
|
def _generate_caddyfile(self) -> str:
|
|
320
426
|
"""
|
|
321
427
|
Generate Caddyfile for path-based routing.
|
|
322
428
|
|
|
323
429
|
Routes:
|
|
324
|
-
- /<service-name>/* →
|
|
430
|
+
- /<service-name>/* → <service-name>:<service-port> (Strip prefix)
|
|
325
431
|
- / → Gateway info page (Exact match only)
|
|
326
432
|
"""
|
|
327
433
|
routes = []
|
|
328
434
|
|
|
329
435
|
for svc in self.services:
|
|
330
436
|
# handle_path strips the prefix automatically
|
|
437
|
+
# Fix: Use service name instead of localhost for Docker networking
|
|
331
438
|
route = f""" handle_path /{svc.name}* {{
|
|
332
|
-
reverse_proxy
|
|
439
|
+
reverse_proxy {svc.name}:{svc.port}
|
|
333
440
|
}}"""
|
|
334
441
|
routes.append(route)
|
|
335
442
|
|
|
@@ -450,7 +557,8 @@ class ServiceOrchestrator:
|
|
|
450
557
|
logger(f"\n[cyan]Deploying {svc.name}...[/cyan]")
|
|
451
558
|
|
|
452
559
|
# Generate single-service docker-compose
|
|
453
|
-
|
|
560
|
+
global_env = kwargs.get("env_vars") or {}
|
|
561
|
+
compose_content = self._generate_single_service_compose(svc, global_env=global_env)
|
|
454
562
|
|
|
455
563
|
try:
|
|
456
564
|
droplet_result = self.engine.deploy_server(
|
|
@@ -559,7 +667,7 @@ class ServiceOrchestrator:
|
|
|
559
667
|
|
|
560
668
|
return result
|
|
561
669
|
|
|
562
|
-
def _generate_single_service_compose(self, svc: ServiceDefinition) -> str:
|
|
670
|
+
def _generate_single_service_compose(self, svc: ServiceDefinition, global_env: Dict[str, str] = None) -> str:
|
|
563
671
|
"""Generate docker-compose.yml for a single service."""
|
|
564
672
|
command = svc.command
|
|
565
673
|
if not command and svc.entrypoint:
|
|
@@ -574,13 +682,17 @@ class ServiceOrchestrator:
|
|
|
574
682
|
"build": svc.path or ".",
|
|
575
683
|
"ports": [f"{svc.port}:{svc.port}"],
|
|
576
684
|
"restart": "unless-stopped",
|
|
685
|
+
# ZEN GAP FIX: Inject resource governance
|
|
686
|
+
"deploy": {
|
|
687
|
+
"resources": get_resource_limits(self.tier).model_dump(by_alias=True, exclude_none=True)
|
|
688
|
+
}
|
|
577
689
|
}
|
|
578
690
|
|
|
579
691
|
if command:
|
|
580
692
|
service_entry["command"] = command
|
|
581
693
|
|
|
582
|
-
if svc.env:
|
|
583
|
-
service_entry["environment"] = svc.env
|
|
694
|
+
if svc.env or global_env:
|
|
695
|
+
service_entry["environment"] = {**(svc.env or {}), **(global_env or {})}
|
|
584
696
|
|
|
585
697
|
compose = {
|
|
586
698
|
"services": {
|
|
@@ -634,33 +746,45 @@ class ServiceOrchestrator:
|
|
|
634
746
|
return None
|
|
635
747
|
|
|
636
748
|
|
|
637
|
-
def get_orchestrator_for_project(engine, project_path: str = ".") -> Optional[ServiceOrchestrator]:
|
|
749
|
+
def get_orchestrator_for_project(engine, project_path: str = ".", tier: str = "FREE") -> Optional[ServiceOrchestrator]:
|
|
638
750
|
"""
|
|
639
|
-
Factory function to create an orchestrator if xenfra.yaml
|
|
751
|
+
Factory function to create an orchestrator if xenfra.yaml or auto-detection finds services.
|
|
640
752
|
|
|
641
753
|
Args:
|
|
642
754
|
engine: InfraEngine instance
|
|
643
755
|
project_path: Path to project directory
|
|
756
|
+
tier: User tier for resource governance
|
|
644
757
|
|
|
645
758
|
Returns:
|
|
646
|
-
ServiceOrchestrator if services found
|
|
759
|
+
ServiceOrchestrator if services found, None otherwise
|
|
647
760
|
"""
|
|
648
|
-
from .manifest import load_services_from_xenfra_yaml,
|
|
761
|
+
from .manifest import load_services_from_xenfra_yaml, create_services_from_detected
|
|
762
|
+
from .detection import auto_detect_services
|
|
649
763
|
|
|
764
|
+
# 1. Try manifest first (User intent)
|
|
650
765
|
services = load_services_from_xenfra_yaml(project_path)
|
|
766
|
+
yaml_path = Path(project_path) / "xenfra.yaml"
|
|
651
767
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
if yaml_path.exists():
|
|
659
|
-
with open(yaml_path) as f:
|
|
768
|
+
project_name = Path(project_path).name
|
|
769
|
+
mode = "single-droplet"
|
|
770
|
+
|
|
771
|
+
if yaml_path.exists():
|
|
772
|
+
with open(yaml_path) as f:
|
|
773
|
+
try:
|
|
660
774
|
data = yaml.safe_load(f)
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
775
|
+
if data:
|
|
776
|
+
project_name = data.get("project_name", project_name)
|
|
777
|
+
mode = data.get("mode", "single-droplet")
|
|
778
|
+
except Exception:
|
|
779
|
+
pass
|
|
780
|
+
|
|
781
|
+
# 2. Fallback to Autonomous Detection
|
|
782
|
+
if not services:
|
|
783
|
+
detected = auto_detect_services(project_path)
|
|
784
|
+
if detected and len(detected) > 1:
|
|
785
|
+
services = create_services_from_detected(detected)
|
|
786
|
+
|
|
787
|
+
if services and len(services) > 1:
|
|
788
|
+
return ServiceOrchestrator(engine, services, project_name, mode, tier=tier)
|
|
665
789
|
|
|
666
790
|
return None
|
xenfra_sdk/privacy.py
CHANGED
|
@@ -124,6 +124,17 @@ def scrub_logs(logs: str) -> str:
|
|
|
124
124
|
|
|
125
125
|
return scrubbed_logs
|
|
126
126
|
|
|
127
|
+
# Protocol Compliance Alias
|
|
128
|
+
scrub_pii = scrub_logs
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def scrubbed_print(message: str, **kwargs) -> None:
|
|
132
|
+
"""
|
|
133
|
+
Protocol-compliant print wrapper that scrubs PII before output.
|
|
134
|
+
Use this instead of raw print() throughout the SDK.
|
|
135
|
+
"""
|
|
136
|
+
print(scrub_pii(str(message)), **kwargs)
|
|
137
|
+
|
|
127
138
|
|
|
128
139
|
if __name__ == "__main__":
|
|
129
140
|
# Example Usage
|
xenfra_sdk/protocol.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Xenfra Protocol Registry - Centralized constants for compliance with XENFRA_PROTOCOL.md.
|
|
3
|
+
Eliminates hardcoded strings across SDK and Services.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Final
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ProtocolRegistry:
|
|
10
|
+
"""
|
|
11
|
+
Structured registry for all strings and constants used in the Xenfra stack.
|
|
12
|
+
Satisfies Anti-Pattern #4 (No Hardcoded Constants).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
# Log Prefixes
|
|
16
|
+
LOG_PREFIX_INFRA_ENGINE: Final[str] = "[InfraEngine]"
|
|
17
|
+
LOG_PREFIX_ZEN_MODE: Final[str] = "[ZenMode]"
|
|
18
|
+
LOG_PREFIX_AUTO_MIGRATION: Final[str] = "[AutoMigration]"
|
|
19
|
+
|
|
20
|
+
# Rate Limiting
|
|
21
|
+
RATE_LIMIT_USER_KEY: Final[str] = "user:{}" # f-string template
|
|
22
|
+
|
|
23
|
+
# Error Messages
|
|
24
|
+
MIGRATION_FAILED_MSG: Final[str] = "Auto-migration failed"
|
|
25
|
+
IDENTITY_SYNC_FAILED_MSG: Final[str] = "User identity cannot be synced"
|
|
26
|
+
|
|
27
|
+
# Header Names
|
|
28
|
+
HEADER_USER_ID: Final[str] = "X-User-ID"
|
|
29
|
+
HEADER_USER_EMAIL: Final[str] = "X-User-Email"
|
|
30
|
+
HEADER_REQUEST_ID: Final[str] = "X-Request-ID"
|
|
31
|
+
|
|
32
|
+
# JWT Claims
|
|
33
|
+
JWT_ISSUER: Final[str] = "xenfra-sso"
|
|
34
|
+
JWT_AUDIENCE: Final[str] = "xenfra-api"
|
|
35
|
+
|
|
36
|
+
# Default Deployment Values
|
|
37
|
+
DEFAULT_DOCKER_IMAGE: Final[str] = "ubuntu-22-04-x64"
|
|
38
|
+
DEFAULT_PORT: Final[int] = 8000
|