xenfra-sdk 0.2.5__py3-none-any.whl → 0.2.6__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.
Files changed (42) hide show
  1. xenfra_sdk/__init__.py +46 -2
  2. xenfra_sdk/blueprints/base.py +150 -0
  3. xenfra_sdk/blueprints/factory.py +99 -0
  4. xenfra_sdk/blueprints/node.py +219 -0
  5. xenfra_sdk/blueprints/python.py +57 -0
  6. xenfra_sdk/blueprints/railpack.py +99 -0
  7. xenfra_sdk/blueprints/schema.py +70 -0
  8. xenfra_sdk/cli/main.py +175 -49
  9. xenfra_sdk/client.py +6 -2
  10. xenfra_sdk/constants.py +26 -0
  11. xenfra_sdk/db/session.py +8 -3
  12. xenfra_sdk/detection.py +262 -191
  13. xenfra_sdk/dockerizer.py +76 -120
  14. xenfra_sdk/engine.py +758 -172
  15. xenfra_sdk/events.py +254 -0
  16. xenfra_sdk/exceptions.py +9 -0
  17. xenfra_sdk/governance.py +150 -0
  18. xenfra_sdk/manifest.py +93 -138
  19. xenfra_sdk/mcp_client.py +7 -5
  20. xenfra_sdk/{models.py → models/__init__.py} +17 -1
  21. xenfra_sdk/models/context.py +61 -0
  22. xenfra_sdk/orchestrator.py +223 -99
  23. xenfra_sdk/privacy.py +11 -0
  24. xenfra_sdk/protocol.py +38 -0
  25. xenfra_sdk/railpack_adapter.py +357 -0
  26. xenfra_sdk/railpack_detector.py +587 -0
  27. xenfra_sdk/railpack_manager.py +312 -0
  28. xenfra_sdk/recipes.py +152 -19
  29. xenfra_sdk/resources/activity.py +45 -0
  30. xenfra_sdk/resources/build.py +157 -0
  31. xenfra_sdk/resources/deployments.py +22 -2
  32. xenfra_sdk/resources/intelligence.py +25 -0
  33. xenfra_sdk-0.2.6.dist-info/METADATA +118 -0
  34. xenfra_sdk-0.2.6.dist-info/RECORD +49 -0
  35. {xenfra_sdk-0.2.5.dist-info → xenfra_sdk-0.2.6.dist-info}/WHEEL +1 -1
  36. xenfra_sdk/templates/Caddyfile.j2 +0 -14
  37. xenfra_sdk/templates/Dockerfile.j2 +0 -41
  38. xenfra_sdk/templates/cloud-init.sh.j2 +0 -90
  39. xenfra_sdk/templates/docker-compose-multi.yml.j2 +0 -29
  40. xenfra_sdk/templates/docker-compose.yml.j2 +0 -30
  41. xenfra_sdk-0.2.5.dist-info/METADATA +0 -116
  42. xenfra_sdk-0.2.5.dist-info/RECORD +0 -38
@@ -0,0 +1,357 @@
1
+ """Railpack Adapter - Converts Railpack build plans to Xenfra deployment assets."""
2
+
3
+ import json
4
+ import shlex
5
+ from dataclasses import dataclass
6
+ from typing import Dict, List, Optional, Any
7
+
8
+ from xenfra_sdk.blueprints.schema import (
9
+ DeploymentBlueprintManifest,
10
+ DockerfileModel,
11
+ ComposeModel,
12
+ ServiceDetail,
13
+ DeployModel,
14
+ DeployResourcesModel,
15
+ ResourceLimitsModel,
16
+ ResourceReservationsModel,
17
+ )
18
+
19
+
20
+ @dataclass
21
+ class RailpackPlan:
22
+ version: str
23
+ runtime: Dict[str, Any]
24
+ phases: Dict[str, Dict[str, Any]]
25
+ packages: Dict[str, List[str]]
26
+ copy: List[str]
27
+ secrets: List[str]
28
+ caches: Dict[str, Any]
29
+ deploy: Dict[str, Any]
30
+
31
+ @classmethod
32
+ def from_dict(cls, data: dict):
33
+ return cls(
34
+ version=data.get('version', '1.0'),
35
+ runtime=data.get('runtime', {}),
36
+ phases=data.get('phases', {}),
37
+ packages=data.get('packages', {}),
38
+ copy=data.get('copy', ['.']),
39
+ secrets=data.get('secrets', []),
40
+ caches=data.get('caches', {}),
41
+ deploy=data.get('deploy', {})
42
+ )
43
+
44
+
45
+ class RailpackAdapter:
46
+ def __init__(self, plan: dict, context: Optional[dict] = None):
47
+ self.plan = RailpackPlan.from_dict(plan)
48
+ self.context = context or {}
49
+ self.port = self.context.get('port', 8000)
50
+ self.tier = self.context.get('tier', 'FREE')
51
+
52
+ def convert(self):
53
+ dockerfile = self._build_dockerfile()
54
+ compose = self._build_compose()
55
+ caddyfile = self._generate_caddyfile()
56
+ return DeploymentBlueprintManifest(
57
+ dockerfile=dockerfile,
58
+ compose=compose,
59
+ env_file=self.context.get('env_vars', {}),
60
+ caddyfile=caddyfile
61
+ )
62
+
63
+ def _build_dockerfile(self):
64
+ runtime = self.plan.runtime
65
+ phases = self.plan.phases
66
+
67
+ # 1. Base Image Logic
68
+ # Railpack usually provides a specific base image (e.g. nixpacks).
69
+ # If not, we MUST fallback to a language-specific image, not plain Ubuntu,
70
+ # because the plan might rely on language tools (npm, pip) being present.
71
+ detected_lang = self.get_detected_language()
72
+ default_base = 'ubuntu:22.04'
73
+
74
+ # Smart Map for Base Images
75
+ BASE_IMAGE_MAP = {
76
+ 'node': 'node:20-bullseye', # Bumped to 20 for modern stack (React Email/Resend)
77
+ 'python': 'python:3.11-slim', # Includes pip
78
+ 'go': 'golang:1.21-bullseye', # Includes go
79
+ 'rust': 'rust:1.75-bullseye', # Includes cargo
80
+ 'java': 'openjdk:17-slim', # Includes java/javac
81
+ 'php': 'php:8.2-apache', # Includes composer (usually)
82
+ 'ruby': 'ruby:3.2', # Includes bundle
83
+ 'deno': 'denoland/deno:latest',
84
+ }
85
+
86
+ if detected_lang and detected_lang in BASE_IMAGE_MAP:
87
+ default_base = BASE_IMAGE_MAP[detected_lang]
88
+
89
+ base_image = runtime.get('image') or default_base
90
+
91
+ # 2. System Packages (APT)
92
+ # Railpack plans often list required system libs in 'packages' or 'apt'
93
+ system_packages = []
94
+ if self.plan.packages:
95
+ # Handle list format ["libpq-dev"] or dict format {"apt": [...]}
96
+ if isinstance(self.plan.packages, dict):
97
+ system_packages.extend(self.plan.packages.get('apt', []))
98
+ system_packages.extend(self.plan.packages.get('system', []))
99
+ elif isinstance(self.plan.packages, list):
100
+ system_packages.extend(self.plan.packages)
101
+
102
+ env_vars = {'PORT': str(self.port)}
103
+
104
+ # Inject Runtime Envs from Plan
105
+ for env in runtime.get('env', []):
106
+ if '=' in env:
107
+ key, value = env.split('=', 1)
108
+ env_vars[key] = value
109
+
110
+ # User-Defined Envs from Context
111
+ # We NO LONGER inject these directly into the Dockerfile as ENV/ARG lines
112
+ # to prevent secrets from leaking into image layers and to avoid shell quoting issues.
113
+ # Instead, they are passed via the 'env_file' manifest property and written as a .env file.
114
+ user_envs = self.context.get('env_vars', {})
115
+ if user_envs:
116
+ print(f"[RailpackAdapter] Found {len(user_envs)} user environment variables. These will be written to .env asset.")
117
+
118
+ run_commands = []
119
+
120
+ for phase_name in ['setup', 'install', 'build']:
121
+ phase = phases.get(phase_name, {})
122
+ # Handle plural 'cmds' (standard)
123
+ for cmd in phase.get('cmds', []):
124
+ run_commands.append(cmd)
125
+ # Handle singular 'cmd' (potential variance)
126
+ if phase.get('cmd'):
127
+ run_commands.append(phase.get('cmd'))
128
+
129
+ # 3. Safety Net: Ensure Dependencies are Installed
130
+ # If Railpack plan was empty (common with some detection edge cases),
131
+ # we must manually ensure dependencies are installed.
132
+ if detected_lang == 'node':
133
+ # Check if we have any install commands
134
+ has_install = any('npm install' in cmd or 'npm ci' in cmd or 'yarn' in cmd or 'pnpm' in cmd for cmd in run_commands)
135
+
136
+ if not has_install:
137
+ print(f"[RailpackAdapter] ⚠️ Warning: No install command detected for Node. Injecting 'npm install'.")
138
+ run_commands.append("npm install")
139
+
140
+ # Check for build command if Next.js
141
+ framework = self.context.get('framework', '').lower()
142
+ if "next" in framework:
143
+ has_build = any('build' in cmd for cmd in run_commands)
144
+ if not has_build:
145
+ print(f"[RailpackAdapter] ⚠️ Warning: No build command detected for Next.js. Injecting 'npm run build'.")
146
+ run_commands.append("npm run build")
147
+
148
+ elif detected_lang == 'python':
149
+ # Check if we have any install commands
150
+ has_install = any('pip install' in cmd or 'poetry install' in cmd or 'uv sync' in cmd or 'pipenv install' in cmd for cmd in run_commands)
151
+
152
+ if not has_install:
153
+ cache_keys = self.plan.caches.keys()
154
+ print(f"[RailpackAdapter] ⚠️ Warning: No install command detected for Python. Checking caches: {list(cache_keys)}")
155
+
156
+ if "uv" in cache_keys:
157
+ # UV requires installation first since we use slim-python image
158
+ run_commands.append("pip install uv")
159
+ run_commands.append("uv sync --frozen")
160
+ print("[RailpackAdapter] Injected 'uv sync'")
161
+ elif "poetry" in cache_keys:
162
+ run_commands.append("pip install poetry")
163
+ run_commands.append("poetry config virtualenvs.create false")
164
+ run_commands.append("poetry install --no-interaction --no-ansi")
165
+ print("[RailpackAdapter] Injected 'poetry install'")
166
+ elif "pipenv" in cache_keys:
167
+ run_commands.append("pip install pipenv")
168
+ run_commands.append("pipenv install --deploy --system")
169
+ print("[RailpackAdapter] Injected 'pipenv install'")
170
+ else:
171
+ # Default to pip / requirements.txt
172
+ run_commands.append("pip install -r requirements.txt")
173
+ print("[RailpackAdapter] Injected 'pip install -r requirements.txt'")
174
+
175
+ start = phases.get('start', {})
176
+ start_cmd = start.get('cmd', '')
177
+
178
+ if start_cmd:
179
+ # Check if command needs port injection
180
+ if "$PORT" not in start_cmd and "PORT" not in start_cmd:
181
+ # If command doesn't use PORT var, we might need to inject it
182
+ pass
183
+ command = shlex.split(start_cmd)
184
+ else:
185
+ # Smart fallback based on detected language
186
+ lang = detected_lang
187
+
188
+ # Check for binary output paths (Go/Rust)
189
+ # Railpack 0.17.x puts binaries in specific paths
190
+ deploy_paths = self.plan.deploy.get("paths", {})
191
+ bin_path = deploy_paths.get("bin") or deploy_paths.get("main")
192
+
193
+ if lang == "node":
194
+ # Check for Next.js specifically
195
+ framework = self.context.get('framework', '').lower()
196
+ if "next" in framework:
197
+ command = ["npm", "run", "start"]
198
+ else:
199
+ command = ["npm", "start"]
200
+ elif lang == "python":
201
+ # Check for specific frameworks in context
202
+ framework = self.context.get('framework', '').lower()
203
+ if "django" in framework:
204
+ app_name = self.context.get('project_name', 'project').split('-')[-1]
205
+ command = ["sh", "-c", f"python manage.py migrate && gunicorn {app_name}.wsgi:application --bind 0.0.0.0:$PORT"]
206
+ elif "flask" in framework:
207
+ command = ["sh", "-c", "gunicorn --bind 0.0.0.0:$PORT main:app"]
208
+ elif "fastapi" in framework:
209
+ command = ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port $PORT"]
210
+ else:
211
+ command = ["python", "main.py"]
212
+ elif lang == "go":
213
+ if bin_path:
214
+ command = [f"./{bin_path}"]
215
+ else:
216
+ command = ["./main"]
217
+ elif lang == "rust":
218
+ if bin_path:
219
+ command = [f"./{bin_path}"]
220
+ else:
221
+ command = ["./target/release/app"]
222
+ elif lang == "bun":
223
+ command = ["bun", "run", "start"]
224
+ else:
225
+ # Fail loudly so the user knows detection failed (vs silent success and immediate exit)
226
+ command = ["sh", "-c", "echo '❌ Error: No start command detected. Please provide an explicit command.' && exit 1"]
227
+
228
+ # DEBUG: Trace start command decision
229
+ print(f"[RailpackAdapter] Detected lang: {detected_lang}")
230
+ print(f"[RailpackAdapter] Selected start command: {command}")
231
+
232
+
233
+ return DockerfileModel(
234
+ base_image=base_image,
235
+ workdir=runtime.get('workdir', '/app'),
236
+ copy_dirs=self.plan.copy,
237
+ run_commands=run_commands,
238
+ env_vars=env_vars,
239
+ args=[],
240
+ expose_port=self.port,
241
+ command=command,
242
+ system_packages=system_packages # Injected system packages
243
+ )
244
+
245
+ def get_detected_language(self) -> Optional[str]:
246
+ """
247
+ Extract detected language from plan metadata.
248
+
249
+ Priority:
250
+ 1. Runtime Name (The source of truth)
251
+ 2. Cache Keys (Reliable indicators)
252
+ 3. Variables (Deployment hints)
253
+ """
254
+ # 1. Trust Runtime Name (Primary Source of Truth)
255
+ runtime_name = self.plan.runtime.get('name', '').lower()
256
+ if runtime_name and runtime_name != "unknown":
257
+ print(f"[RailpackAdapter] Runtime name: {runtime_name}")
258
+ return runtime_name
259
+
260
+ # 2. Check Caches (Reliable Source)
261
+ # Railpack 0.17.x uses specific cache keys for each provider
262
+ caches = self.plan.caches
263
+ cache_keys = set(caches.keys())
264
+ print(f"[RailpackAdapter] Cache keys: {cache_keys}")
265
+
266
+ if any(k in cache_keys for k in ("next", "node-modules", "npm-install", "yarn", "pnpm", "bun")):
267
+ return 'node'
268
+ if any(k in cache_keys for k in ("python", "pip", "uv", "poetry", "pipenv")):
269
+ return 'python'
270
+ if "go-build" in cache_keys or "go-mod" in cache_keys:
271
+ return 'go'
272
+ if any(k in cache_keys for k in ("cargo", "cargo-cache", "rust")):
273
+ return 'rust'
274
+ if "bundle" in cache_keys or "ruby" in cache_keys:
275
+ return 'ruby'
276
+ if "composer" in cache_keys:
277
+ return 'php'
278
+ if any(k in cache_keys for k in ("maven", "gradle", "java")):
279
+ return 'java'
280
+ if "deno" in cache_keys:
281
+ return 'deno'
282
+ if "static" in cache_keys or "html" in cache_keys:
283
+ return 'static'
284
+
285
+ # 3. Fallback: Check variables (Deployment hints)
286
+ variables = str(self.plan.deploy.get("variables", {})).lower()
287
+ if "python" in variables:
288
+ return 'python'
289
+ if "node" in variables or "npm" in variables:
290
+ return 'node'
291
+
292
+ return None
293
+
294
+ def _build_compose(self):
295
+ from xenfra_sdk.governance import get_resource_limits
296
+ limits = get_resource_limits(self.tier)
297
+
298
+ env_vars = self.context.get('env_vars', {})
299
+
300
+ service = ServiceDetail(
301
+ build_context={
302
+ "context": ".",
303
+ "args": list(env_vars.keys())
304
+ },
305
+ ports=[f'{self.port}:{self.port}'],
306
+ # environment=env_vars, # Replaced by env_file for security and syntax safety
307
+ env_file=[".env"],
308
+ deploy=DeployModel(
309
+ resources=DeployResourcesModel(
310
+ limits=ResourceLimitsModel(
311
+ memory=limits.memory,
312
+ cpus=limits.cpus
313
+ ),
314
+ reservations=ResourceReservationsModel(
315
+ memory=limits.memory_reserved,
316
+ cpus=limits.cpus_reserved
317
+ )
318
+ )
319
+ )
320
+ )
321
+
322
+ return ComposeModel(services={'app': service})
323
+
324
+ def _generate_caddyfile(self) -> str:
325
+ """
326
+ Generate Caddyfile for single-service deployments.
327
+
328
+ Routes all traffic from port 80 to the application port.
329
+ """
330
+ domain = self.context.get('domain')
331
+ email = self.context.get('email', 'admin@xenfra.tech')
332
+
333
+ if domain:
334
+ # Production mode with custom domain and TLS
335
+ caddyfile = f"""{domain}:80, {domain}:443 {{
336
+ reverse_proxy app:{self.port}
337
+ tls {email}
338
+ }}
339
+
340
+ # Health check endpoint
341
+ :80/health {{
342
+ respond "OK" 200
343
+ }}
344
+ """
345
+ else:
346
+ # Development mode without domain - expose on port 80
347
+ caddyfile = f""":80 {{
348
+ reverse_proxy app:{self.port}
349
+
350
+ # Health check endpoint
351
+ handle /health {{
352
+ respond "OK" 200
353
+ }}
354
+ }}
355
+ """
356
+
357
+ return caddyfile