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
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Railpack Manager - Auto-downloads and manages Railpack CLI binary.
|
|
3
|
+
|
|
4
|
+
Railpack is Railway's open-source buildpack that auto-detects languages
|
|
5
|
+
and generates optimized container configurations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import platform
|
|
10
|
+
import shutil
|
|
11
|
+
import stat
|
|
12
|
+
import subprocess
|
|
13
|
+
import tarfile
|
|
14
|
+
import urllib.request
|
|
15
|
+
import zipfile
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
import logging
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RailpackManager:
|
|
24
|
+
"""
|
|
25
|
+
Manages Railpack CLI binary lifecycle.
|
|
26
|
+
|
|
27
|
+
Auto-downloads Railpack from GitHub releases if not present.
|
|
28
|
+
Stores binary in ~/.xenfra/bin/ for persistence across sessions.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
RAILPACK_VERSION = "0.17.1"
|
|
32
|
+
|
|
33
|
+
# GitHub release URLs for different platforms
|
|
34
|
+
DOWNLOAD_URLS = {
|
|
35
|
+
("Linux", "x86_64"): "https://github.com/railwayapp/railpack/releases/download/v{version}/railpack-v{version}-x86_64-unknown-linux-musl.tar.gz",
|
|
36
|
+
("Linux", "aarch64"): "https://github.com/railwayapp/railpack/releases/download/v{version}/railpack-v{version}-arm64-unknown-linux-musl.tar.gz",
|
|
37
|
+
("Darwin", "x86_64"): "https://github.com/railwayapp/railpack/releases/download/v{version}/railpack-v{version}-x86_64-apple-darwin.tar.gz",
|
|
38
|
+
("Darwin", "arm64"): "https://github.com/railwayapp/railpack/releases/download/v{version}/railpack-v{version}-arm64-apple-darwin.tar.gz",
|
|
39
|
+
("Windows", "AMD64"): "https://github.com/railwayapp/railpack/releases/download/v{version}/railpack-v{version}-x86_64-pc-windows-msvc.zip",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
def __init__(self, version: Optional[str] = None):
|
|
43
|
+
"""
|
|
44
|
+
Initialize Railpack manager.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
version: Specific Railpack version to use. Defaults to RAILPACK_VERSION.
|
|
48
|
+
"""
|
|
49
|
+
self.version = version or self.RAILPACK_VERSION
|
|
50
|
+
self.bin_dir = Path.home() / ".xenfra" / "bin"
|
|
51
|
+
self.bin_dir.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
|
|
53
|
+
# Determine binary name based on platform
|
|
54
|
+
system = platform.system()
|
|
55
|
+
self.binary_name = "railpack.exe" if system == "Windows" else "railpack"
|
|
56
|
+
self.binary_path = self.bin_dir / self.binary_name
|
|
57
|
+
|
|
58
|
+
# Cache availability check
|
|
59
|
+
self._is_available: Optional[bool] = None
|
|
60
|
+
|
|
61
|
+
def is_installed(self) -> bool:
|
|
62
|
+
"""Check if Railpack binary exists and is executable."""
|
|
63
|
+
if self._is_available is not None:
|
|
64
|
+
return self._is_available
|
|
65
|
+
|
|
66
|
+
if not self.binary_path.exists():
|
|
67
|
+
self._is_available = False
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
# Verify it's actually runnable
|
|
71
|
+
try:
|
|
72
|
+
result = subprocess.run(
|
|
73
|
+
[str(self.binary_path), "--version"],
|
|
74
|
+
capture_output=True,
|
|
75
|
+
timeout=5
|
|
76
|
+
)
|
|
77
|
+
self._is_available = result.returncode == 0
|
|
78
|
+
return self._is_available
|
|
79
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
80
|
+
self._is_available = False
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
def ensure_installed(self) -> str:
|
|
84
|
+
"""
|
|
85
|
+
Ensure Railpack is installed. Download if needed.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Absolute path to railpack binary.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
RuntimeError: If installation fails or platform unsupported.
|
|
92
|
+
"""
|
|
93
|
+
if self.is_installed():
|
|
94
|
+
return str(self.binary_path)
|
|
95
|
+
|
|
96
|
+
return self._download_and_install()
|
|
97
|
+
|
|
98
|
+
def _get_download_url(self) -> str:
|
|
99
|
+
"""Get download URL for current platform."""
|
|
100
|
+
system = platform.system()
|
|
101
|
+
machine = platform.machine()
|
|
102
|
+
|
|
103
|
+
# Normalize machine names
|
|
104
|
+
if machine in ("amd64", "x86_64"):
|
|
105
|
+
machine = "x86_64"
|
|
106
|
+
elif machine in ("arm64", "aarch64"):
|
|
107
|
+
machine = "arm64" if system == "Darwin" else "aarch64"
|
|
108
|
+
|
|
109
|
+
key = (system, machine)
|
|
110
|
+
|
|
111
|
+
if key not in self.DOWNLOAD_URLS:
|
|
112
|
+
# Try x86_64 as fallback for Linux
|
|
113
|
+
if system == "Linux":
|
|
114
|
+
key = ("Linux", "x86_64")
|
|
115
|
+
else:
|
|
116
|
+
raise RuntimeError(
|
|
117
|
+
f"Unsupported platform: {system} {machine}. "
|
|
118
|
+
f"Railpack supports: Linux (x86_64, arm64), macOS (x86_64, arm64), Windows (x86_64)"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return self.DOWNLOAD_URLS[key].format(version=self.version)
|
|
122
|
+
|
|
123
|
+
def _download_and_install(self) -> str:
|
|
124
|
+
"""Download and install Railpack binary."""
|
|
125
|
+
import tempfile
|
|
126
|
+
|
|
127
|
+
url = self._get_download_url()
|
|
128
|
+
system = platform.system()
|
|
129
|
+
|
|
130
|
+
print(f"📦 Downloading Railpack {self.version}...")
|
|
131
|
+
|
|
132
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
133
|
+
tmp_path = Path(tmpdir)
|
|
134
|
+
|
|
135
|
+
# Determine archive type
|
|
136
|
+
is_zip = url.endswith(".zip")
|
|
137
|
+
archive_name = "railpack.zip" if is_zip else "railpack.tar.gz"
|
|
138
|
+
download_path = tmp_path / archive_name
|
|
139
|
+
|
|
140
|
+
# Download with progress
|
|
141
|
+
try:
|
|
142
|
+
self._download_with_progress(url, download_path)
|
|
143
|
+
except urllib.error.HTTPError as e:
|
|
144
|
+
raise RuntimeError(f"Failed to download Railpack: {e}")
|
|
145
|
+
|
|
146
|
+
print(f"📂 Extracting {archive_name}...")
|
|
147
|
+
|
|
148
|
+
# Extract
|
|
149
|
+
if is_zip:
|
|
150
|
+
self._extract_zip(download_path, tmp_path)
|
|
151
|
+
else:
|
|
152
|
+
self._extract_tar(download_path, tmp_path)
|
|
153
|
+
|
|
154
|
+
# Find extracted binary recursively
|
|
155
|
+
extracted_binary = None
|
|
156
|
+
for root, dirs, files in os.walk(tmp_path):
|
|
157
|
+
if self.binary_name in files:
|
|
158
|
+
extracted_binary = Path(root) / self.binary_name
|
|
159
|
+
break
|
|
160
|
+
|
|
161
|
+
if not extracted_binary:
|
|
162
|
+
raise RuntimeError(f"Railpack binary '{self.binary_name}' not found in extracted archive")
|
|
163
|
+
|
|
164
|
+
# Move to bin directory (Robust cross-device move)
|
|
165
|
+
try:
|
|
166
|
+
shutil.copy2(str(extracted_binary), str(self.binary_path))
|
|
167
|
+
os.remove(str(extracted_binary))
|
|
168
|
+
except Exception as e:
|
|
169
|
+
# If copy/delete fails, try move as fallback but it might fail on cross-device
|
|
170
|
+
logger.warning(f"Copy/remove failed: {e}. Trying shutil.move...")
|
|
171
|
+
shutil.move(str(extracted_binary), str(self.binary_path))
|
|
172
|
+
|
|
173
|
+
# Make executable (Unix)
|
|
174
|
+
if system != "Windows":
|
|
175
|
+
self.binary_path.chmod(
|
|
176
|
+
self.binary_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# Reset availability cache so verification actually runs the new binary
|
|
180
|
+
self._is_available = None
|
|
181
|
+
|
|
182
|
+
# Verify installation
|
|
183
|
+
if not self.is_installed():
|
|
184
|
+
raise RuntimeError("Railpack installation verification failed")
|
|
185
|
+
|
|
186
|
+
print(f"✅ Railpack {self.version} installed at {self.binary_path}")
|
|
187
|
+
return str(self.binary_path)
|
|
188
|
+
|
|
189
|
+
def _download_with_progress(self, url: str, dest: Path):
|
|
190
|
+
"""Download file with simple progress indication."""
|
|
191
|
+
def report_progress(block_num, block_size, total_size):
|
|
192
|
+
downloaded = block_num * block_size
|
|
193
|
+
percent = min(downloaded * 100 // total_size, 100) if total_size > 0 else 0
|
|
194
|
+
if block_num % 10 == 0: # Update every 10 blocks
|
|
195
|
+
print(f" Downloading... {percent}%", end="\r")
|
|
196
|
+
|
|
197
|
+
urllib.request.urlretrieve(url, dest, reporthook=report_progress)
|
|
198
|
+
print() # Newline after progress
|
|
199
|
+
|
|
200
|
+
def _extract_zip(self, zip_path: Path, dest_dir: Path):
|
|
201
|
+
"""Extract zip archive."""
|
|
202
|
+
with zipfile.ZipFile(zip_path, 'r') as zf:
|
|
203
|
+
zf.extractall(dest_dir)
|
|
204
|
+
|
|
205
|
+
def _extract_tar(self, tar_path: Path, dest_dir: Path):
|
|
206
|
+
"""Extract tar.gz archive."""
|
|
207
|
+
with tarfile.open(tar_path, 'r:gz') as tf:
|
|
208
|
+
tf.extractall(dest_dir)
|
|
209
|
+
|
|
210
|
+
def run(self, *args, cwd: Optional[str] = None,
|
|
211
|
+
capture_output: bool = True, text: bool = True,
|
|
212
|
+
timeout: int = 60, **kwargs) -> subprocess.CompletedProcess:
|
|
213
|
+
"""
|
|
214
|
+
Run Railpack command.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
*args: Command arguments (e.g., "prepare", ".", "--plan-out", "plan.json")
|
|
218
|
+
cwd: Working directory
|
|
219
|
+
capture_output: Capture stdout/stderr
|
|
220
|
+
text: Return text instead of bytes
|
|
221
|
+
timeout: Command timeout in seconds
|
|
222
|
+
**kwargs: Additional subprocess arguments
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
CompletedProcess with returncode, stdout, stderr
|
|
226
|
+
|
|
227
|
+
Raises:
|
|
228
|
+
subprocess.CalledProcessError: If command fails and check=True
|
|
229
|
+
subprocess.TimeoutExpired: If timeout reached
|
|
230
|
+
"""
|
|
231
|
+
binary = self.ensure_installed()
|
|
232
|
+
cmd = [binary] + list(args)
|
|
233
|
+
|
|
234
|
+
return subprocess.run(
|
|
235
|
+
cmd,
|
|
236
|
+
cwd=cwd,
|
|
237
|
+
capture_output=capture_output,
|
|
238
|
+
text=text,
|
|
239
|
+
timeout=timeout,
|
|
240
|
+
**kwargs
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
def prepare(self, source_path: str, plan_out: Optional[str] = None,
|
|
244
|
+
info_out: Optional[str] = None, env: Optional[dict] = None) -> dict:
|
|
245
|
+
"""
|
|
246
|
+
Run 'railpack prepare' to generate build plan.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
source_path: Path to source code directory
|
|
250
|
+
plan_out: Output path for plan JSON (default: source_path/.railpack-plan.json)
|
|
251
|
+
info_out: Output path for build info JSON
|
|
252
|
+
env: Environment variables for Railpack
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
Parsed plan JSON as dict
|
|
256
|
+
|
|
257
|
+
Raises:
|
|
258
|
+
RuntimeError: If Railpack fails
|
|
259
|
+
"""
|
|
260
|
+
import json
|
|
261
|
+
|
|
262
|
+
if plan_out is None:
|
|
263
|
+
plan_out = os.path.join(source_path, ".railpack-plan.json")
|
|
264
|
+
|
|
265
|
+
args = ["prepare", source_path, "--plan-out", plan_out]
|
|
266
|
+
|
|
267
|
+
if info_out:
|
|
268
|
+
args.extend(["--info-out", info_out])
|
|
269
|
+
|
|
270
|
+
# Add env vars
|
|
271
|
+
env_vars = os.environ.copy()
|
|
272
|
+
if env:
|
|
273
|
+
env_vars.update(env)
|
|
274
|
+
|
|
275
|
+
result = self.run(*args, env=env_vars, check=True)
|
|
276
|
+
|
|
277
|
+
# Read and return plan
|
|
278
|
+
with open(plan_out) as f:
|
|
279
|
+
return json.load(f)
|
|
280
|
+
|
|
281
|
+
def get_version(self) -> str:
|
|
282
|
+
"""Get installed Railpack version."""
|
|
283
|
+
if not self.is_installed():
|
|
284
|
+
return "not installed"
|
|
285
|
+
|
|
286
|
+
result = self.run("--version", check=True)
|
|
287
|
+
return result.stdout.strip()
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# Global singleton instance
|
|
291
|
+
_railpack_manager: Optional[RailpackManager] = None
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def get_railpack_manager(version: Optional[str] = None) -> RailpackManager:
|
|
295
|
+
"""
|
|
296
|
+
Get or create global RailpackManager instance.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
version: Specific version to use. If None, uses default.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
RailpackManager singleton
|
|
303
|
+
"""
|
|
304
|
+
global _railpack_manager
|
|
305
|
+
|
|
306
|
+
if _railpack_manager is None:
|
|
307
|
+
_railpack_manager = RailpackManager(version=version)
|
|
308
|
+
elif version is not None and _railpack_manager.version != version:
|
|
309
|
+
# Requested different version, create new instance
|
|
310
|
+
_railpack_manager = RailpackManager(version=version)
|
|
311
|
+
|
|
312
|
+
return _railpack_manager
|
xenfra_sdk/recipes.py
CHANGED
|
@@ -1,26 +1,159 @@
|
|
|
1
|
-
from
|
|
1
|
+
from typing import Dict, Any
|
|
2
|
+
import os
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
def generate_stack(context: Dict[str, Any], is_dockerized: bool = True) -> str:
|
|
5
|
+
"""
|
|
6
|
+
Generates the cloud-init startup script programmatically.
|
|
7
|
+
Effectively replaces the old 'cloud-init.sh.j2' template.
|
|
8
|
+
"""
|
|
9
|
+
domain = context.get("domain")
|
|
10
|
+
email = context.get("email")
|
|
11
|
+
port = context.get("port", 8000)
|
|
12
|
+
install_caddy = context.get("install_caddy", False)
|
|
13
|
+
xenfra_api_url = os.getenv("XENFRA_API_URL", "https://api.xenfra.tech")
|
|
14
|
+
|
|
15
|
+
# Base Script Header
|
|
16
|
+
script = [
|
|
17
|
+
"#!/bin/bash",
|
|
18
|
+
"export DEBIAN_FRONTEND=noninteractive",
|
|
19
|
+
'LOG="/root/setup.log"',
|
|
20
|
+
"touch $LOG",
|
|
21
|
+
"",
|
|
22
|
+
'echo "--------------------------------" >> $LOG',
|
|
23
|
+
'echo "🧘 XENFRA: Context-Aware Boot" >> $LOG',
|
|
24
|
+
'echo "--------------------------------" >> $LOG',
|
|
25
|
+
"",
|
|
26
|
+
"# Create App Directory",
|
|
27
|
+
"mkdir -p /root/app",
|
|
28
|
+
"cd /root/app",
|
|
29
|
+
"",
|
|
30
|
+
"# --- MERCILESS FIX: TERMINATE BACKGROUND PROCESSES ---",
|
|
31
|
+
'echo "⚔️ [0/6] Mercilessly Terminating Background Processes..." >> $LOG',
|
|
32
|
+
"",
|
|
33
|
+
"kill_apt_processes() {",
|
|
34
|
+
' echo "🎯 Killing processes holding apt/dpkg locks..." >> $LOG',
|
|
35
|
+
" fuser -k /var/lib/dpkg/lock >/dev/null 2>&1",
|
|
36
|
+
" fuser -k /var/lib/apt/lists/lock >/dev/null 2>&1",
|
|
37
|
+
" fuser -k /var/lib/dpkg/lock-frontends >/dev/null 2>&1",
|
|
38
|
+
"}",
|
|
39
|
+
"",
|
|
40
|
+
"# Explicitly stop and disable services that cause locks",
|
|
41
|
+
"systemctl stop unattended-upgrades.service || true",
|
|
42
|
+
"systemctl disable unattended-upgrades.service || true",
|
|
43
|
+
"systemctl stop apt-daily.service || true",
|
|
44
|
+
"systemctl disable apt-daily.service || true",
|
|
45
|
+
"systemctl stop apt-daily-upgrade.service || true",
|
|
46
|
+
"systemctl disable apt-daily-upgrade.service || true",
|
|
47
|
+
"",
|
|
48
|
+
"# Forcefully kill any remaining lock holders",
|
|
49
|
+
"kill_apt_processes",
|
|
50
|
+
"",
|
|
51
|
+
"# Force remove locks if they still exist (The Nuclear Option)",
|
|
52
|
+
"rm -f /var/lib/dpkg/lock*",
|
|
53
|
+
"rm -f /var/lib/apt/lists/lock*",
|
|
54
|
+
"rm -f /var/cache/apt/archives/lock",
|
|
55
|
+
"dpkg --configure -a || true",
|
|
56
|
+
"# -----------------------------------------------",
|
|
57
|
+
"",
|
|
58
|
+
"# 1. System Updates",
|
|
59
|
+
'echo "🔄 [1/5] Refreshing Package Lists..." >> $LOG',
|
|
60
|
+
"apt-get update",
|
|
61
|
+
"apt-get install -y python3-pip git curl",
|
|
62
|
+
""
|
|
63
|
+
]
|
|
4
64
|
|
|
65
|
+
# 2. Setup Environment
|
|
66
|
+
if is_dockerized:
|
|
67
|
+
script.extend([
|
|
68
|
+
'echo "🐳 [2/5] Installing Docker..." >> $LOG',
|
|
69
|
+
"apt-get install -y docker.io || (curl -fsSL https://get.docker.com | sh)",
|
|
70
|
+
'echo "🎶 [3/5] Installing Docker Compose..." >> $LOG',
|
|
71
|
+
"apt-get install -y docker-compose-v2",
|
|
72
|
+
"",
|
|
73
|
+
"# Install Railpack (Railway's zero-config builder)",
|
|
74
|
+
'echo "🚂 [3.5/5] Installing Railpack..." >> $LOG',
|
|
75
|
+
"curl -sSL https://railpack.com/install.sh | sh || echo 'Railpack install failed (non-critical)' >> $LOG",
|
|
76
|
+
])
|
|
77
|
+
else:
|
|
78
|
+
script.extend([
|
|
79
|
+
'echo "🐍 [2/5] Setting up host-based Python environment..." >> $LOG',
|
|
80
|
+
"apt-get install -y python3-venv python3-dev build-essential"
|
|
81
|
+
])
|
|
82
|
+
|
|
83
|
+
script.append("")
|
|
5
84
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
85
|
+
# 3. Setup Reverse Proxy
|
|
86
|
+
if is_dockerized or install_caddy:
|
|
87
|
+
script.extend([
|
|
88
|
+
'echo "📦 [3/5] Installing Caddy..." >> $LOG',
|
|
89
|
+
"apt-get install -y debian-keyring debian-archive-keyring apt-transport-https",
|
|
90
|
+
"curl -LsSf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg",
|
|
91
|
+
"curl -LsSf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list",
|
|
92
|
+
"apt-get update",
|
|
93
|
+
"apt-get install -y caddy"
|
|
94
|
+
])
|
|
95
|
+
else:
|
|
96
|
+
script.append('echo "🛡️ [3/5] Skipping Caddy for host deployment (setup manual reverse proxy if needed)." >> $LOG')
|
|
97
|
+
|
|
98
|
+
script.append("")
|
|
9
99
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
100
|
+
# Caddyfile Config (if domain provided)
|
|
101
|
+
if domain:
|
|
102
|
+
script.extend([
|
|
103
|
+
f'echo "🔒 Writing Caddyfile for {domain}..." >> $LOG',
|
|
104
|
+
"cat << EOF > /etc/caddy/Caddyfile",
|
|
105
|
+
f"{domain}:80, {domain}:443 {{",
|
|
106
|
+
f" reverse_proxy localhost:{port}",
|
|
107
|
+
f" tls {email}",
|
|
108
|
+
"}",
|
|
109
|
+
"EOF",
|
|
110
|
+
"",
|
|
111
|
+
'echo "🚀 [5/5] Starting Caddy..." >> $LOG',
|
|
112
|
+
"systemctl restart caddy"
|
|
113
|
+
])
|
|
114
|
+
else:
|
|
115
|
+
script.append('echo "✅ [5/5] Skipping Caddy start (no domain specified)." >> $LOG')
|
|
116
|
+
|
|
117
|
+
script.append("")
|
|
118
|
+
|
|
119
|
+
# Heartbeat Logic
|
|
120
|
+
script.append("# --- ZEN GAP FIX: HEARTBEAT CALLBACK ---")
|
|
121
|
+
script.append('echo "📡 [HEARTBEAT] Sending network_ready signal to Xenfra..." >> $LOG')
|
|
122
|
+
|
|
123
|
+
if xenfra_api_url:
|
|
124
|
+
script.extend([
|
|
125
|
+
'DROPLET_ID=$(curl -sf http://169.254.169.254/metadata/v1/id 2>/dev/null || echo "")',
|
|
126
|
+
'DROPLET_IP=$(curl -sf http://169.254.169.254/metadata/v1/interfaces/public/0/ipv4/address 2>/dev/null || echo "")',
|
|
127
|
+
'if [ -n "$DROPLET_ID" ]; then',
|
|
128
|
+
f' curl -sf -X POST "{xenfra_api_url}/internal/heartbeat" \\',
|
|
129
|
+
' -H "Content-Type: application/json" \\',
|
|
130
|
+
' -d "{\\"droplet_id\\": $DROPLET_ID, \\"status\\": \\"network_ready\\", \\"ip_address\\": \\"$DROPLET_IP\\"}" \\',
|
|
131
|
+
' --connect-timeout 5 --max-time 10 >> $LOG 2>&1 || echo "⚠️ Heartbeat failed (non-critical)" >> $LOG',
|
|
132
|
+
'else',
|
|
133
|
+
' echo "⚠️ [HEARTBEAT] Could not fetch droplet ID from metadata" >> $LOG',
|
|
134
|
+
'fi'
|
|
135
|
+
])
|
|
136
|
+
else:
|
|
137
|
+
script.append('echo "⚠️ [HEARTBEAT] Skipped - no API URL configured" >> $LOG')
|
|
18
138
|
|
|
19
|
-
|
|
139
|
+
# Footer
|
|
140
|
+
script.extend([
|
|
141
|
+
"",
|
|
142
|
+
"# Finish",
|
|
143
|
+
'echo "✅ SETUP SCRIPT COMPLETE" >> $LOG',
|
|
144
|
+
"touch /root/setup_complete",
|
|
145
|
+
"",
|
|
146
|
+
"# Final heartbeat: cloud-init complete"
|
|
147
|
+
])
|
|
20
148
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
149
|
+
if xenfra_api_url:
|
|
150
|
+
script.extend([
|
|
151
|
+
'if [ -n "$DROPLET_ID" ]; then',
|
|
152
|
+
f' curl -sf -X POST "{xenfra_api_url}/internal/heartbeat" \\',
|
|
153
|
+
' -H "Content-Type: application/json" \\',
|
|
154
|
+
' -d "{\\"droplet_id\\": $DROPLET_ID, \\"status\\": \\"cloud_init_complete\\", \\"ip_address\\": \\"$DROPLET_IP\\"}" \\',
|
|
155
|
+
' --connect-timeout 5 --max-time 10 >> $LOG 2>&1 || true',
|
|
156
|
+
'fi'
|
|
157
|
+
])
|
|
25
158
|
|
|
26
|
-
return script
|
|
159
|
+
return "\n".join(script)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import List
|
|
3
|
+
|
|
4
|
+
from ..exceptions import XenfraAPIError, XenfraError
|
|
5
|
+
from ..models import ActivityLog
|
|
6
|
+
from ..utils import safe_get_json_field, safe_json_parse
|
|
7
|
+
from .base import BaseManager
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ActivityManager(BaseManager):
|
|
13
|
+
def list(self) -> List[ActivityLog]:
|
|
14
|
+
"""Retrieves user activity logs."""
|
|
15
|
+
try:
|
|
16
|
+
response = self._client._request("GET", "/users/activity")
|
|
17
|
+
logger.debug(f"ActivityManager.list response: {response.status_code}")
|
|
18
|
+
|
|
19
|
+
data = safe_json_parse(response)
|
|
20
|
+
# Most listing endpoints return a list directly or wrapped in a dict
|
|
21
|
+
if isinstance(data, list):
|
|
22
|
+
logs = data
|
|
23
|
+
else:
|
|
24
|
+
logs = safe_get_json_field(data, "activity", [])
|
|
25
|
+
|
|
26
|
+
if not isinstance(logs, list):
|
|
27
|
+
raise XenfraError(f"Expected list of logs, got {type(logs).__name__}")
|
|
28
|
+
|
|
29
|
+
return [ActivityLog(**log) for log in logs]
|
|
30
|
+
except XenfraAPIError:
|
|
31
|
+
raise
|
|
32
|
+
except Exception as e:
|
|
33
|
+
raise XenfraError(f"Failed to fetch activity logs: {e}")
|
|
34
|
+
|
|
35
|
+
def create(self, action: str, details: str) -> ActivityLog:
|
|
36
|
+
"""Internal helper to log activity via the SDK."""
|
|
37
|
+
try:
|
|
38
|
+
payload = {"action": action, "details": details}
|
|
39
|
+
response = self._client._request("POST", "/users/activity", json=payload)
|
|
40
|
+
data = safe_json_parse(response)
|
|
41
|
+
return ActivityLog(**data)
|
|
42
|
+
except XenfraAPIError:
|
|
43
|
+
raise
|
|
44
|
+
except Exception as e:
|
|
45
|
+
raise XenfraError(f"Failed to log activity: {e}")
|