xenfra 0.1.6__py3-none-any.whl → 0.1.8__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/api/auth.py +51 -0
- xenfra/api/billing.py +80 -0
- xenfra/api/connections.py +163 -0
- xenfra/api/main.py +175 -0
- xenfra/api/webhooks.py +146 -0
- xenfra/cli/main.py +211 -0
- xenfra/config.py +24 -0
- xenfra/db/models.py +51 -0
- xenfra/db/session.py +17 -0
- xenfra/dependencies.py +35 -0
- xenfra/dockerizer.py +45 -109
- xenfra/engine.py +228 -229
- xenfra/mcp_client.py +149 -0
- xenfra/models.py +54 -0
- xenfra/recipes.py +20 -118
- xenfra/security.py +58 -0
- xenfra/templates/Dockerfile.j2 +25 -0
- xenfra/templates/cloud-init.sh.j2 +72 -0
- xenfra/templates/docker-compose.yml.j2 +33 -0
- {xenfra-0.1.6.dist-info → xenfra-0.1.8.dist-info}/METADATA +30 -63
- xenfra-0.1.8.dist-info/RECORD +25 -0
- xenfra-0.1.8.dist-info/entry_points.txt +3 -0
- xenfra/cli.py +0 -169
- xenfra-0.1.6.dist-info/RECORD +0 -10
- xenfra-0.1.6.dist-info/entry_points.txt +0 -3
- {xenfra-0.1.6.dist-info → xenfra-0.1.8.dist-info}/WHEEL +0 -0
xenfra/engine.py
CHANGED
|
@@ -1,264 +1,263 @@
|
|
|
1
|
-
|
|
1
|
+
# src/xenfra/engine.py
|
|
2
|
+
|
|
2
3
|
import os
|
|
3
4
|
import time
|
|
4
|
-
from dotenv import load_dotenv
|
|
5
5
|
from pathlib import Path
|
|
6
|
-
|
|
6
|
+
import digitalocean
|
|
7
|
+
import fabric
|
|
8
|
+
from dotenv import load_dotenv
|
|
9
|
+
from sqlmodel import Session
|
|
7
10
|
|
|
8
|
-
#
|
|
9
|
-
from .
|
|
10
|
-
from .
|
|
11
|
-
from .
|
|
11
|
+
# Xenfra modules
|
|
12
|
+
from . import dockerizer
|
|
13
|
+
from . import recipes
|
|
14
|
+
from .db.models import Project
|
|
15
|
+
from .db.session import get_session
|
|
12
16
|
|
|
17
|
+
class DeploymentError(Exception):
|
|
18
|
+
"""Custom exception for deployment failures."""
|
|
19
|
+
def __init__(self, message, stage="Unknown"):
|
|
20
|
+
self.message = message
|
|
21
|
+
self.stage = stage
|
|
22
|
+
super().__init__(f"Deployment failed at stage '{stage}': {message}")
|
|
13
23
|
|
|
14
24
|
class InfraEngine:
|
|
15
25
|
"""
|
|
16
|
-
The
|
|
17
|
-
|
|
18
|
-
It is designed to be used by a CLI or an AI Agent.
|
|
26
|
+
The InfraEngine is the core of Xenfra. It handles all interactions
|
|
27
|
+
with the cloud provider and orchestrates the deployment lifecycle.
|
|
19
28
|
"""
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
29
|
+
def __init__(self, token: str = None, db_session: Session = None):
|
|
30
|
+
"""
|
|
31
|
+
Initializes the engine and validates the API token.
|
|
32
|
+
"""
|
|
23
33
|
load_dotenv()
|
|
24
|
-
self.token = os.getenv("DIGITAL_OCEAN_TOKEN")
|
|
25
|
-
|
|
26
|
-
if not self.token:
|
|
27
|
-
raise ValueError("❌ FATAL: No DIGITAL_OCEAN_TOKEN found in .env or environment.")
|
|
28
|
-
|
|
29
|
-
self.manager = digitalocean.Manager(token=self.token)
|
|
34
|
+
self.token = token or os.getenv("DIGITAL_OCEAN_TOKEN")
|
|
35
|
+
self.db_session = db_session or next(get_session())
|
|
30
36
|
|
|
31
|
-
|
|
32
|
-
|
|
37
|
+
if not self.token:
|
|
38
|
+
raise ValueError("DigitalOcean API token not found. Please set the DIGITAL_OCEAN_TOKEN environment variable.")
|
|
33
39
|
try:
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
self.manager = digitalocean.Manager(token=self.token)
|
|
41
|
+
self.get_user_info()
|
|
36
42
|
except Exception as e:
|
|
37
|
-
raise ConnectionError(f"
|
|
43
|
+
raise ConnectionError(f"Failed to connect to DigitalOcean: {e}")
|
|
38
44
|
|
|
39
|
-
def
|
|
40
|
-
"""
|
|
41
|
-
|
|
42
|
-
1. Dynamically finds local public SSH keys in ~/.ssh/.
|
|
43
|
-
2. Checks if any of them exist on DigitalOcean.
|
|
44
|
-
3. If not, uploads the first one found.
|
|
45
|
-
Returns: The SSHKey object.
|
|
46
|
-
"""
|
|
47
|
-
# 1. Find Local Keys
|
|
48
|
-
ssh_dir = Path.home() / ".ssh"
|
|
49
|
-
pub_keys = list(ssh_dir.glob("*.pub"))
|
|
50
|
-
|
|
51
|
-
if not pub_keys:
|
|
52
|
-
raise FileNotFoundError(f"No public SSH keys found in {ssh_dir}. Run 'ssh-keygen' first.")
|
|
53
|
-
|
|
54
|
-
# 2. Check Remote Keys against Local Keys
|
|
55
|
-
remote_keys = self.manager.get_all_sshkeys()
|
|
56
|
-
remote_key_map = {k.public_key.strip(): k for k in remote_keys}
|
|
57
|
-
|
|
58
|
-
first_local_key_content = None
|
|
59
|
-
for key_path in pub_keys:
|
|
60
|
-
local_pub_key = key_path.read_text().strip()
|
|
61
|
-
if not first_local_key_content:
|
|
62
|
-
first_local_key_content = local_pub_key
|
|
63
|
-
|
|
64
|
-
if local_pub_key in remote_key_map:
|
|
65
|
-
print(f"✨ Found existing SSH key on DigitalOcean: {key_path.name}")
|
|
66
|
-
return remote_key_map[local_pub_key]
|
|
45
|
+
def _get_connection(self, ip_address: str):
|
|
46
|
+
"""Establishes a Fabric connection to the server."""
|
|
47
|
+
return fabric.Connection(host=ip_address, user="root", connect_kwargs={"password": None}) # Assumes SSH key auth
|
|
67
48
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
49
|
+
def get_user_info(self):
|
|
50
|
+
"""Retrieves user account information."""
|
|
51
|
+
return self.manager.get_account()
|
|
71
52
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
public_key=first_local_key_content)
|
|
76
|
-
new_key.create()
|
|
77
|
-
return new_key
|
|
53
|
+
def list_servers(self):
|
|
54
|
+
"""Retrieves a list of all Droplets."""
|
|
55
|
+
return self.manager.get_all_droplets()
|
|
78
56
|
|
|
79
|
-
def
|
|
80
|
-
"""
|
|
81
|
-
|
|
82
|
-
host=ip_address,
|
|
83
|
-
user="root",
|
|
84
|
-
connect_kwargs={
|
|
85
|
-
"timeout": 10
|
|
86
|
-
}
|
|
87
|
-
)
|
|
88
|
-
|
|
89
|
-
def _is_setup_complete(self, ip_address: str) -> bool:
|
|
90
|
-
"""Checks for the setup completion marker file on the remote server."""
|
|
91
|
-
try:
|
|
92
|
-
c = self._get_connection(ip_address)
|
|
93
|
-
if c.run("test -f /root/setup_complete", warn=True, hide=True).ok:
|
|
94
|
-
return True
|
|
95
|
-
except Exception:
|
|
96
|
-
# This can happen if the server is not ready for SSH
|
|
97
|
-
return False
|
|
98
|
-
return False
|
|
99
|
-
|
|
100
|
-
def _auto_heal_server(self, ip_address: str):
|
|
101
|
-
"""
|
|
102
|
-
Diagnoses issues and attempts to fix them remotely.
|
|
103
|
-
This is the 'nuclear option' for a stalled setup.
|
|
104
|
-
"""
|
|
105
|
-
print(f"\n[ENGINE] 🚑 INITIATING AUTO-HEAL PROTOCOL for {ip_address}...")
|
|
106
|
-
c = self._get_connection(ip_address)
|
|
107
|
-
|
|
108
|
-
# A. STOP SERVICES (Prevent Respawn)
|
|
109
|
-
print("[ENGINE] 🛑 Stopping background update services...")
|
|
110
|
-
c.run("systemctl stop unattended-upgrades.service", warn=True, hide=True)
|
|
111
|
-
c.run("systemctl stop apt-daily.service", warn=True, hide=True)
|
|
112
|
-
c.run("systemctl stop apt-daily-upgrade.service", warn=True, hide=True)
|
|
113
|
-
|
|
114
|
-
# B. KILL PROCESSES
|
|
115
|
-
print("[ENGINE] 🔪 Killing stuck processes...")
|
|
116
|
-
c.run("pkill apt", warn=True, hide=True)
|
|
117
|
-
c.run("pkill apt-get", warn=True, hide=True)
|
|
118
|
-
c.run("pkill dpkg", warn=True, hide=True)
|
|
119
|
-
|
|
120
|
-
# C. REMOVE LOCKS
|
|
121
|
-
print("[ENGINE] PWN: Removing lock files...")
|
|
122
|
-
c.run("rm -f /var/lib/dpkg/lock*", warn=True, hide=True)
|
|
123
|
-
c.run("rm -f /var/lib/apt/lists/lock", warn=True, hide=True)
|
|
124
|
-
c.run("rm -f /var/cache/apt/archives/lock", warn=True, hide=True)
|
|
125
|
-
|
|
126
|
-
# D. REPAIR DPKG
|
|
127
|
-
print("[ENGINE] 🔧 Repairing package database...")
|
|
128
|
-
c.run("dpkg --configure -a", warn=True, hide=True)
|
|
129
|
-
|
|
130
|
-
# E. RE-MARK AS COMPLETE (to unblock the health check)
|
|
131
|
-
c.run("touch /root/setup_complete", warn=True, hide=True)
|
|
132
|
-
print("[ENGINE] ✅ Auto-Heal Complete.")
|
|
133
|
-
|
|
134
|
-
def upload_code(self, ip_address: str) -> bool:
|
|
135
|
-
"""Uses Fabric to upload project files to the /root/app directory."""
|
|
136
|
-
print(f"\n[ENGINE] 🚁 INITIATING CODE AIRLIFT to {ip_address}...")
|
|
57
|
+
def destroy_server(self, droplet_id: int, db_session: Session = None):
|
|
58
|
+
"""Destroys a Droplet by its ID and removes it from the local DB."""
|
|
59
|
+
session = db_session or self.db_session
|
|
137
60
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
try:
|
|
141
|
-
c = self._get_connection(ip_address)
|
|
142
|
-
|
|
143
|
-
print("[ENGINE] 📤 Uploading Project Files...")
|
|
144
|
-
count = 0
|
|
145
|
-
for root, dirs, files in os.walk("."):
|
|
146
|
-
dirs[:] = [d for d in dirs if d not in ignored]
|
|
147
|
-
|
|
148
|
-
for file in files:
|
|
149
|
-
if file.endswith(".pyc"): continue
|
|
150
|
-
|
|
151
|
-
local_path = os.path.join(root, file)
|
|
152
|
-
rel_path = os.path.relpath(local_path, ".")
|
|
153
|
-
remote_path = f"/root/app/{rel_path}"
|
|
154
|
-
|
|
155
|
-
remote_dir = os.path.dirname(remote_path)
|
|
156
|
-
c.run(f"mkdir -p {remote_dir}", hide=True)
|
|
157
|
-
|
|
158
|
-
c.put(local_path, remote_path)
|
|
159
|
-
count += 1
|
|
61
|
+
# Find the project in the local DB
|
|
62
|
+
project_to_delete = session.query(Project).filter(Project.droplet_id == droplet_id).first()
|
|
160
63
|
|
|
161
|
-
|
|
162
|
-
|
|
64
|
+
# Destroy the droplet on DigitalOcean
|
|
65
|
+
droplet = digitalocean.Droplet(token=self.token, id=droplet_id)
|
|
66
|
+
droplet.destroy()
|
|
163
67
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
68
|
+
# If it was in our DB, delete it
|
|
69
|
+
if project_to_delete:
|
|
70
|
+
session.delete(project_to_delete)
|
|
71
|
+
session.commit()
|
|
168
72
|
|
|
169
|
-
def
|
|
170
|
-
"""
|
|
171
|
-
|
|
73
|
+
def list_projects_from_db(self, db_session: Session = None):
|
|
74
|
+
"""Lists all projects from the local database."""
|
|
75
|
+
session = db_session or self.db_session
|
|
76
|
+
return session.query(Project).all()
|
|
172
77
|
|
|
173
|
-
def
|
|
78
|
+
def sync_with_provider(self, db_session: Session = None):
|
|
79
|
+
"""Reconciles the local database with the live state from DigitalOcean."""
|
|
80
|
+
session = db_session or self.db_session
|
|
81
|
+
|
|
82
|
+
# 1. Get live and local states
|
|
83
|
+
live_droplets = self.manager.get_all_droplets(tag_name="xenfra")
|
|
84
|
+
local_projects = self.list_projects_from_db(session)
|
|
85
|
+
|
|
86
|
+
live_map = {d.id: d for d in live_droplets}
|
|
87
|
+
local_map = {p.droplet_id: p for p in local_projects}
|
|
88
|
+
|
|
89
|
+
# 2. Reconcile
|
|
90
|
+
# Add new servers found on DO to our DB
|
|
91
|
+
for droplet_id, droplet in live_map.items():
|
|
92
|
+
if droplet_id not in local_map:
|
|
93
|
+
new_project = Project(
|
|
94
|
+
droplet_id=droplet.id, name=droplet.name, ip_address=droplet.ip_address,
|
|
95
|
+
status=droplet.status, region=droplet.region['slug'], size=droplet.size_slug
|
|
96
|
+
)
|
|
97
|
+
session.add(new_project)
|
|
98
|
+
|
|
99
|
+
# Remove servers from our DB that no longer exist on DO
|
|
100
|
+
for project_id, project in local_map.items():
|
|
101
|
+
if project_id not in live_map:
|
|
102
|
+
session.delete(project)
|
|
103
|
+
|
|
104
|
+
session.commit()
|
|
105
|
+
return self.list_projects_from_db(session)
|
|
106
|
+
|
|
107
|
+
def stream_logs(self, droplet_id: int, db_session: Session = None):
|
|
174
108
|
"""
|
|
175
|
-
|
|
176
|
-
This is a stateful, blocking method that orchestrates the entire process.
|
|
109
|
+
Verifies a server exists and streams its logs in real-time.
|
|
177
110
|
"""
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
111
|
+
session = db_session or self.db_session
|
|
112
|
+
|
|
113
|
+
# 1. Find project in local DB
|
|
114
|
+
project = session.query(Project).filter(Project.droplet_id == droplet_id).first()
|
|
115
|
+
if not project:
|
|
116
|
+
raise DeploymentError(f"Project with Droplet ID {droplet_id} not found in local database.", stage="Log Streaming")
|
|
117
|
+
|
|
118
|
+
# 2. Just-in-Time Verification
|
|
119
|
+
try:
|
|
120
|
+
droplet = self.manager.get_droplet(droplet_id)
|
|
121
|
+
except digitalocean.baseapi.DataReadError as e:
|
|
122
|
+
if e.response.status_code == 404:
|
|
123
|
+
# The droplet doesn't exist, so remove it from our DB
|
|
124
|
+
session.delete(project)
|
|
125
|
+
session.commit()
|
|
126
|
+
raise DeploymentError(f"Server '{project.name}' (ID: {droplet_id}) no longer exists on DigitalOcean. It has been removed from your local list.", stage="Log Streaming")
|
|
181
127
|
else:
|
|
182
|
-
|
|
128
|
+
raise e
|
|
129
|
+
|
|
130
|
+
# 3. Stream logs
|
|
131
|
+
ip_address = droplet.ip_address
|
|
132
|
+
with self._get_connection(ip_address) as conn:
|
|
133
|
+
conn.run("cd /root/app && docker-compose logs -f app", pty=True)
|
|
134
|
+
|
|
183
135
|
|
|
184
|
-
log(f"🚀 Starting deployment for '{name}'...")
|
|
185
136
|
|
|
137
|
+
def _ensure_ssh_key(self, logger):
|
|
138
|
+
"""Ensures a local public SSH key is on DigitalOcean."""
|
|
139
|
+
pub_key_path = Path.home() / ".ssh" / "id_rsa.pub"
|
|
140
|
+
if not pub_key_path.exists():
|
|
141
|
+
raise DeploymentError("No SSH key found at ~/.ssh/id_rsa.pub. Please generate one.", stage="Setup")
|
|
142
|
+
|
|
143
|
+
with open(pub_key_path) as f:
|
|
144
|
+
pub_key_content = f.read()
|
|
145
|
+
|
|
146
|
+
existing_keys = self.manager.get_all_sshkeys()
|
|
147
|
+
for key in existing_keys:
|
|
148
|
+
if key.public_key.strip() == pub_key_content.strip():
|
|
149
|
+
logger(" - Found existing SSH key on DigitalOcean.")
|
|
150
|
+
return key
|
|
151
|
+
|
|
152
|
+
logger(" - No matching SSH key found. Creating a new one on DigitalOcean...")
|
|
153
|
+
key = digitalocean.SSHKey(token=self.token, name="xenfra-cli-key", public_key=pub_key_content)
|
|
154
|
+
key.create()
|
|
155
|
+
return key
|
|
156
|
+
|
|
157
|
+
def deploy_server(self, name: str, region: str, size: str, image: str, logger: callable, db_session: Session = None, **kwargs):
|
|
158
|
+
"""A stateful, blocking orchestrator for deploying a new server."""
|
|
159
|
+
droplet = None
|
|
160
|
+
session = db_session or self.db_session
|
|
186
161
|
try:
|
|
187
|
-
# 1.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
image=image,
|
|
210
|
-
size_slug=size,
|
|
211
|
-
ssh_keys=[ssh_key],
|
|
212
|
-
user_data=setup_script)
|
|
162
|
+
# === 1. SETUP STAGE ===
|
|
163
|
+
logger("\n[bold blue]PHASE 1: SETUP[/bold blue]")
|
|
164
|
+
ssh_key = self._ensure_ssh_key(logger)
|
|
165
|
+
|
|
166
|
+
# === 2. ASSET GENERATION STAGE ===
|
|
167
|
+
logger("\n[bold blue]PHASE 2: GENERATING DEPLOYMENT ASSETS[/bold blue]")
|
|
168
|
+
context = {**kwargs} # Pass db config, etc.
|
|
169
|
+
files = dockerizer.generate_templated_assets(context)
|
|
170
|
+
for file in files:
|
|
171
|
+
logger(f" - Generated {file}")
|
|
172
|
+
|
|
173
|
+
# === 3. CLOUD-INIT STAGE ===
|
|
174
|
+
logger("\n[bold blue]PHASE 3: CREATING SERVER SETUP SCRIPT[/bold blue]")
|
|
175
|
+
cloud_init_script = recipes.generate_stack(context)
|
|
176
|
+
logger(" - Generated cloud-init script.")
|
|
177
|
+
|
|
178
|
+
# === 4. DROPLET CREATION STAGE ===
|
|
179
|
+
logger("\n[bold blue]PHASE 4: PROVISIONING SERVER[/bold blue]")
|
|
180
|
+
droplet = digitalocean.Droplet(
|
|
181
|
+
token=self.token, name=name, region=region, image=image, size_slug=size,
|
|
182
|
+
ssh_keys=[ssh_key], userdata=cloud_init_script, tags=["xenfra"]
|
|
183
|
+
)
|
|
213
184
|
droplet.create()
|
|
185
|
+
logger(f" - Droplet '{name}' creation initiated (ID: {droplet.id}). Waiting for it to become active...")
|
|
214
186
|
|
|
215
|
-
# 5.
|
|
216
|
-
|
|
217
|
-
while
|
|
187
|
+
# === 5. POLLING STAGE ===
|
|
188
|
+
logger("\n[bold blue]PHASE 5: WAITING FOR SERVER SETUP[/bold blue]")
|
|
189
|
+
while True:
|
|
218
190
|
droplet.load()
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
# 6. Health Check & Auto-Heal Loop
|
|
223
|
-
log("🩺 Performing health checks on the new server...")
|
|
224
|
-
attempts = 0
|
|
225
|
-
max_retries = 18 # ~90 seconds
|
|
226
|
-
is_healthy = False
|
|
227
|
-
while attempts < max_retries:
|
|
228
|
-
if self._is_setup_complete(droplet.ip_address):
|
|
229
|
-
log("✅ Server setup complete and healthy.")
|
|
230
|
-
is_healthy = True
|
|
191
|
+
if droplet.status == 'active':
|
|
192
|
+
logger(" - Droplet is active. Waiting for cloud-init to complete...")
|
|
231
193
|
break
|
|
232
|
-
|
|
233
|
-
time.sleep(5)
|
|
234
|
-
attempts += 1
|
|
194
|
+
time.sleep(10)
|
|
235
195
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
#
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
196
|
+
ip_address = droplet.ip_address
|
|
197
|
+
with self._get_connection(ip_address) as conn:
|
|
198
|
+
for i in range(30): # 5-minute timeout for cloud-init
|
|
199
|
+
if conn.run("test -f /root/setup_complete", warn=True).ok:
|
|
200
|
+
logger(" - Cloud-init setup complete.")
|
|
201
|
+
break
|
|
202
|
+
time.sleep(10)
|
|
203
|
+
else:
|
|
204
|
+
raise DeploymentError("Server setup script failed to complete in time.", stage="Polling")
|
|
205
|
+
|
|
206
|
+
# === 6. CODE UPLOAD STAGE ===
|
|
207
|
+
logger("\n[bold blue]PHASE 6: UPLOADING APPLICATION CODE[/bold blue]")
|
|
208
|
+
with self._get_connection(ip_address) as conn:
|
|
209
|
+
fabric.transfer.Transfer(conn).upload(".", "/root/app", exclude=[".git", ".venv", "__pycache__"])
|
|
210
|
+
logger(" - Code upload complete.")
|
|
211
|
+
|
|
212
|
+
# === 7. FINAL DEPLOY STAGE ===
|
|
213
|
+
logger("\n[bold blue]PHASE 7: BUILDING AND DEPLOYING CONTAINERS[/bold blue]")
|
|
214
|
+
with self._get_connection(ip_address) as conn:
|
|
215
|
+
result = conn.run("cd /root/app && docker-compose up -d --build", hide=True)
|
|
216
|
+
if result.failed:
|
|
217
|
+
raise DeploymentError(f"docker-compose failed: {result.stderr}", stage="Deploy")
|
|
218
|
+
logger(" - Docker containers are building in the background...")
|
|
219
|
+
|
|
220
|
+
# === 8. VERIFICATION STAGE ===
|
|
221
|
+
logger("\n[bold blue]PHASE 8: VERIFYING DEPLOYMENT[/bold blue]")
|
|
222
|
+
app_port = context.get('port', 8000)
|
|
223
|
+
for i in range(24): # 2-minute timeout for health checks
|
|
224
|
+
logger(f" - Health check attempt {i+1}/24...")
|
|
225
|
+
with self._get_connection(ip_address) as conn:
|
|
226
|
+
# Check if container is running
|
|
227
|
+
ps_result = conn.run("cd /root/app && docker-compose ps", hide=True)
|
|
228
|
+
if "running" not in ps_result.stdout:
|
|
229
|
+
time.sleep(5)
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
# Check if application is responsive
|
|
233
|
+
curl_result = conn.run(f"curl -s --fail http://localhost:{app_port}/", warn=True)
|
|
234
|
+
if curl_result.ok:
|
|
235
|
+
logger("[bold green] - Health check passed! Application is live.[/bold green]")
|
|
236
|
+
|
|
237
|
+
# === 9. PERSISTENCE STAGE ===
|
|
238
|
+
logger("\n[bold blue]PHASE 9: SAVING DEPLOYMENT TO DATABASE[/bold blue]")
|
|
239
|
+
project = Project(
|
|
240
|
+
droplet_id=droplet.id,
|
|
241
|
+
name=droplet.name,
|
|
242
|
+
ip_address=ip_address,
|
|
243
|
+
status=droplet.status,
|
|
244
|
+
region=droplet.region['slug'],
|
|
245
|
+
size=droplet.size_slug,
|
|
246
|
+
)
|
|
247
|
+
session.add(project)
|
|
248
|
+
session.commit()
|
|
249
|
+
logger(" - Deployment saved.")
|
|
250
|
+
|
|
251
|
+
return droplet # Return the full droplet object
|
|
252
|
+
time.sleep(5)
|
|
253
|
+
else:
|
|
254
|
+
# On failure, get logs and destroy droplet
|
|
255
|
+
with self._get_connection(ip_address) as conn:
|
|
256
|
+
logs = conn.run("cd /root/app && docker-compose logs", hide=True).stdout
|
|
257
|
+
raise DeploymentError(f"Application failed to become healthy in time. Logs:\n{logs}", stage="Verification")
|
|
254
258
|
|
|
255
259
|
except Exception as e:
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
def destroy_server(self, droplet_id):
|
|
261
|
-
"""Destroys a server by ID. IRREVERSIBLE."""
|
|
262
|
-
droplet = self.manager.get_droplet(droplet_id)
|
|
263
|
-
droplet.destroy()
|
|
264
|
-
return True
|
|
260
|
+
if droplet:
|
|
261
|
+
logger(f"[bold red]Deployment failed. Cleaning up created server '{droplet.name}'...[/bold red]")
|
|
262
|
+
droplet.destroy()
|
|
263
|
+
raise e
|
xenfra/mcp_client.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# src/xenfra/mcp_client.py
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
import base64
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
class MCPClient:
|
|
11
|
+
"""
|
|
12
|
+
A client for communicating with a local github-mcp-server process.
|
|
13
|
+
|
|
14
|
+
This client starts the MCP server as a subprocess and interacts with it
|
|
15
|
+
over stdin and stdout to download a full repository to a temporary directory.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, mcp_server_path="github-mcp-server"):
|
|
19
|
+
"""
|
|
20
|
+
Initializes the MCPClient.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
mcp_server_path (str): The path to the github-mcp-server executable.
|
|
24
|
+
Assumes it's in the system's PATH by default.
|
|
25
|
+
"""
|
|
26
|
+
self.mcp_server_path = mcp_server_path
|
|
27
|
+
self.process = None
|
|
28
|
+
|
|
29
|
+
def _start_server(self):
|
|
30
|
+
"""Starts the github-mcp-server subprocess."""
|
|
31
|
+
if self.process and self.process.poll() is None:
|
|
32
|
+
return
|
|
33
|
+
try:
|
|
34
|
+
self.process = subprocess.Popen(
|
|
35
|
+
[self.mcp_server_path],
|
|
36
|
+
stdin=subprocess.PIPE,
|
|
37
|
+
stdout=subprocess.PIPE,
|
|
38
|
+
stderr=subprocess.PIPE,
|
|
39
|
+
text=True,
|
|
40
|
+
env=os.environ
|
|
41
|
+
)
|
|
42
|
+
except FileNotFoundError:
|
|
43
|
+
raise RuntimeError(f"'{self.mcp_server_path}' not found. Ensure github-mcp-server is installed and in your PATH.")
|
|
44
|
+
except Exception as e:
|
|
45
|
+
raise RuntimeError(f"Failed to start MCP server: {e}")
|
|
46
|
+
|
|
47
|
+
def _stop_server(self):
|
|
48
|
+
"""Stops the MCP server process."""
|
|
49
|
+
if self.process:
|
|
50
|
+
self.process.terminate()
|
|
51
|
+
self.process.wait()
|
|
52
|
+
self.process = None
|
|
53
|
+
|
|
54
|
+
def _send_request(self, method: str, params: dict) -> dict:
|
|
55
|
+
"""Sends a JSON-RPC request and returns the response."""
|
|
56
|
+
if not self.process or self.process.poll() is not None:
|
|
57
|
+
self._start_server()
|
|
58
|
+
|
|
59
|
+
request = {"jsonrpc": "2.0", "id": os.urandom(4).hex(), "method": method, "params": params}
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
self.process.stdin.write(json.dumps(request) + '\n')
|
|
63
|
+
self.process.stdin.flush()
|
|
64
|
+
response_line = self.process.stdout.readline()
|
|
65
|
+
if not response_line:
|
|
66
|
+
error_output = self.process.stderr.read()
|
|
67
|
+
raise RuntimeError(f"MCP server closed stream unexpectedly. Error: {error_output}")
|
|
68
|
+
|
|
69
|
+
response = json.loads(response_line)
|
|
70
|
+
if "error" in response:
|
|
71
|
+
raise RuntimeError(f"MCP server returned an error: {response['error']}")
|
|
72
|
+
return response.get("result", {})
|
|
73
|
+
except (BrokenPipeError, json.JSONDecodeError) as e:
|
|
74
|
+
raise RuntimeError(f"Failed to communicate with MCP server: {e}")
|
|
75
|
+
|
|
76
|
+
def download_repo_to_tempdir(self, repo_url: str, commit_sha: str = "HEAD") -> str:
|
|
77
|
+
"""
|
|
78
|
+
Downloads an entire repository at a specific commit to a local temporary directory.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
repo_url (str): The full URL of the GitHub repository.
|
|
82
|
+
commit_sha (str): The commit SHA to download. Defaults to "HEAD".
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
The path to the temporary directory containing the downloaded code.
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
parts = repo_url.strip('/').split('/')
|
|
89
|
+
owner = parts[-2]
|
|
90
|
+
repo_name = parts[-1].replace('.git', '')
|
|
91
|
+
except IndexError:
|
|
92
|
+
raise ValueError("Invalid repository URL format. Expected format: https://github.com/owner/repo")
|
|
93
|
+
|
|
94
|
+
print(f" [MCP] Fetching file tree for {owner}/{repo_name} at {commit_sha}...")
|
|
95
|
+
tree_result = self._send_request(
|
|
96
|
+
method="git.get_repository_tree",
|
|
97
|
+
params={"owner": owner, "repo": repo_name, "tree_sha": commit_sha, "recursive": True}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
tree = tree_result.get("tree", [])
|
|
101
|
+
if not tree:
|
|
102
|
+
raise RuntimeError("Could not retrieve repository file tree.")
|
|
103
|
+
|
|
104
|
+
temp_dir = tempfile.mkdtemp(prefix=f"xenfra_{repo_name}_")
|
|
105
|
+
print(f" [MCP] Downloading to temporary directory: {temp_dir}")
|
|
106
|
+
|
|
107
|
+
for item in tree:
|
|
108
|
+
item_path = item.get("path")
|
|
109
|
+
item_type = item.get("type")
|
|
110
|
+
|
|
111
|
+
if not item_path or item_type != "blob": # Only handle files
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
# For downloading content, we can use the commit_sha in the 'ref' parameter
|
|
115
|
+
# to ensure we get the content from the correct version.
|
|
116
|
+
content_result = self._send_request(
|
|
117
|
+
method="repos.get_file_contents",
|
|
118
|
+
params={"owner": owner, "repo": repo_name, "path": item_path, "ref": commit_sha}
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
content_b64 = content_result.get("content")
|
|
122
|
+
if content_b64 is None:
|
|
123
|
+
print(f" [MCP] [Warning] Could not get content for {item_path}")
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
# Content is base64 encoded, with newlines.
|
|
128
|
+
decoded_content = base64.b64decode(content_b64.replace('\n', ''))
|
|
129
|
+
except (base64.binascii.Error, TypeError):
|
|
130
|
+
print(f" [MCP] [Warning] Could not decode content for {item_path}")
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
# Create file and parent directories in the temp location
|
|
134
|
+
local_file_path = Path(temp_dir) / item_path
|
|
135
|
+
local_file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
|
|
137
|
+
# Write the file content
|
|
138
|
+
with open(local_file_path, "wb") as f:
|
|
139
|
+
f.write(decoded_content)
|
|
140
|
+
|
|
141
|
+
print(" [MCP] ✅ Repository download complete.")
|
|
142
|
+
return temp_dir
|
|
143
|
+
|
|
144
|
+
def __enter__(self):
|
|
145
|
+
self._start_server()
|
|
146
|
+
return self
|
|
147
|
+
|
|
148
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
149
|
+
self._stop_server()
|