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.
- 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 +758 -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.6.dist-info/METADATA +118 -0
- xenfra_sdk-0.2.6.dist-info/RECORD +49 -0
- {xenfra_sdk-0.2.5.dist-info → xenfra_sdk-0.2.6.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
|
@@ -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
|