xenfra 0.1.7__py3-none-any.whl → 0.1.9__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/engine.py CHANGED
@@ -1,264 +1,292 @@
1
- import digitalocean
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
- from fabric import Connection
6
+ import digitalocean
7
+ import fabric
8
+ from dotenv import load_dotenv
9
+ from sqlmodel import Session
7
10
 
8
- # Internal imports
9
- from .utils import get_project_context
10
- from .recipes import generate_stack
11
- from .dockerizer import detect_framework, generate_deployment_assets
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 Core SDK.
17
- This class handles all communication with DigitalOcean.
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
- def __init__(self):
22
- # Load secrets immediately
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
- def get_user_info(self):
32
- """Returns the current user's email and account status."""
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
- account = self.manager.get_account()
35
- return {"email": account.email, "status": account.status, "limit": account.droplet_limit}
40
+ self.manager = digitalocean.Manager(token=self.token)
41
+ self.get_user_info()
36
42
  except Exception as e:
37
- raise ConnectionError(f"Could not connect to DigitalOcean: {e}")
38
-
39
- def _ensure_ssh_key(self):
40
- """
41
- Private method.
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]
67
-
68
- # 3. If no matches, upload the first key found
69
- if not first_local_key_content:
70
- raise FileNotFoundError(f"Could not read any public keys in {ssh_dir}.")
71
-
72
- print(f"✨ No matching SSH key found on DigitalOcean. Uploading new key from {pub_keys[0].name}...")
73
- new_key = digitalocean.SSHKey(token=self.token,
74
- name=f"xenfra-{pub_keys[0].name}-{int(time.time())}",
75
- public_key=first_local_key_content)
76
- new_key.create()
77
- return new_key
43
+ raise ConnectionError(f"Failed to connect to DigitalOcean: {e}")
78
44
 
79
45
  def _get_connection(self, ip_address: str):
80
- """Creates a standardized Fabric connection."""
81
- return Connection(
46
+ """Establishes a Fabric connection to the server."""
47
+ private_key_path = str(Path.home() / ".ssh" / "id_rsa")
48
+ if not Path(private_key_path).exists():
49
+ raise DeploymentError("No private SSH key found at ~/.ssh/id_rsa.", stage="Setup")
50
+
51
+ return fabric.Connection(
82
52
  host=ip_address,
83
53
  user="root",
84
- connect_kwargs={
85
- "timeout": 10
86
- }
54
+ connect_kwargs={"key_filename": [private_key_path]},
87
55
  )
88
56
 
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}...")
137
-
138
- ignored = {'.git', '.venv', 'venv', '__pycache__', '.pytest_cache', '.DS_Store', '.env'}
57
+ def get_user_info(self):
58
+ """Retrieves user account information."""
59
+ return self.manager.get_account()
60
+
61
+ def list_servers(self):
62
+ """Retrieves a list of all Droplets."""
63
+ return self.manager.get_all_droplets()
64
+
65
+ def destroy_server(self, droplet_id: int, db_session: Session = None):
66
+ """Destroys a Droplet by its ID and removes it from the local DB."""
67
+ session = db_session or self.db_session
139
68
 
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
69
+ # Find the project in the local DB
70
+ project_to_delete = session.query(Project).filter(Project.droplet_id == droplet_id).first()
71
+
72
+ # Destroy the droplet on DigitalOcean
73
+ droplet = digitalocean.Droplet(token=self.token, id=droplet_id)
74
+ droplet.destroy()
160
75
 
161
- print(f"[ENGINE] Airlift Complete: {count} files transferred.")
162
- return True
76
+ # If it was in our DB, delete it
77
+ if project_to_delete:
78
+ session.delete(project_to_delete)
79
+ session.commit()
163
80
 
164
- except Exception as e:
165
- print(f"[ENGINE] Upload failed: {e}")
166
- print("[ENGINE] 🔑 Ensure your SSH Agent is running and keys are added.")
167
- return False
81
+ def list_projects_from_db(self, db_session: Session = None):
82
+ """Lists all projects from the local database."""
83
+ session = db_session or self.db_session
84
+ return session.query(Project).all()
168
85
 
169
- def list_servers(self):
170
- """Returns a list of all active servers."""
171
- return self.manager.get_all_droplets()
86
+ def sync_with_provider(self, db_session: Session = None):
87
+ """Reconciles the local database with the live state from DigitalOcean."""
88
+ session = db_session or self.db_session
89
+
90
+ # 1. Get live and local states
91
+ live_droplets = self.manager.get_all_droplets(tag_name="xenfra")
92
+ local_projects = self.list_projects_from_db(session)
93
+
94
+ live_map = {d.id: d for d in live_droplets}
95
+ local_map = {p.droplet_id: p for p in local_projects}
96
+
97
+ # 2. Reconcile
98
+ # Add new servers found on DO to our DB
99
+ for droplet_id, droplet in live_map.items():
100
+ if droplet_id not in local_map:
101
+ new_project = Project(
102
+ droplet_id=droplet.id, name=droplet.name, ip_address=droplet.ip_address,
103
+ status=droplet.status, region=droplet.region['slug'], size=droplet.size_slug
104
+ )
105
+ session.add(new_project)
106
+
107
+ # Remove servers from our DB that no longer exist on DO
108
+ for project_id, project in local_map.items():
109
+ if project_id not in live_map:
110
+ session.delete(project)
111
+
112
+ session.commit()
113
+ return self.list_projects_from_db(session)
172
114
 
173
- def deploy_server(self, name, region="blr1", size="s-1vcpu-1gb", image="ubuntu-24-04-x64", logger=None):
115
+ def stream_logs(self, droplet_id: int, db_session: Session = None):
174
116
  """
175
- Provisions a new server, runs setup, and deploys the application.
176
- This is a stateful, blocking method that orchestrates the entire process.
117
+ Verifies a server exists and streams its logs in real-time.
177
118
  """
178
- def log(msg):
179
- if logger:
180
- logger(f"[ENGINE] {msg}")
119
+ session = db_session or self.db_session
120
+
121
+ # 1. Find project in local DB
122
+ project = session.query(Project).filter(Project.droplet_id == droplet_id).first()
123
+ if not project:
124
+ raise DeploymentError(f"Project with Droplet ID {droplet_id} not found in local database.", stage="Log Streaming")
125
+
126
+ # 2. Just-in-Time Verification
127
+ try:
128
+ droplet = self.manager.get_droplet(droplet_id)
129
+ except digitalocean.baseapi.DataReadError as e:
130
+ if e.response.status_code == 404:
131
+ # The droplet doesn't exist, so remove it from our DB
132
+ session.delete(project)
133
+ session.commit()
134
+ 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
135
  else:
182
- print(f"[ENGINE] {msg}")
136
+ raise e
137
+
138
+ # 3. Stream logs
139
+ ip_address = droplet.ip_address
140
+ with self._get_connection(ip_address) as conn:
141
+ conn.run("cd /root/app && docker-compose logs -f app", pty=True)
142
+
183
143
 
184
- log(f"🚀 Starting deployment for '{name}'...")
185
144
 
145
+ def _ensure_ssh_key(self, logger):
146
+ """Ensures a local public SSH key is on DigitalOcean."""
147
+ pub_key_path = Path.home() / ".ssh" / "id_rsa.pub"
148
+ if not pub_key_path.exists():
149
+ raise DeploymentError("No SSH key found at ~/.ssh/id_rsa.pub. Please generate one.", stage="Setup")
150
+
151
+ with open(pub_key_path) as f:
152
+ pub_key_content = f.read()
153
+
154
+ existing_keys = self.manager.get_all_sshkeys()
155
+ for key in existing_keys:
156
+ if key.public_key.strip() == pub_key_content.strip():
157
+ logger(" - Found existing SSH key on DigitalOcean.")
158
+ return key
159
+
160
+ logger(" - No matching SSH key found. Creating a new one on DigitalOcean...")
161
+ key = digitalocean.SSHKey(token=self.token, name="xenfra-cli-key", public_key=pub_key_content)
162
+ key.create()
163
+ return key
164
+
165
+ def deploy_server(self, name: str, region: str, size: str, image: str, logger: callable, db_session: Session = None, **kwargs):
166
+ """A stateful, blocking orchestrator for deploying a new server."""
167
+ droplet = None
168
+ session = db_session or self.db_session
186
169
  try:
187
- # 1. Project Context & Asset Generation
188
- log("🔎 Detecting project context...")
189
- context = get_project_context()
190
- framework, _, _ = detect_framework()
191
- is_dockerized = False
192
- if framework:
193
- log(f"✨ Web framework '{framework}' detected.")
194
- generate_deployment_assets(context)
195
- is_dockerized = True
196
-
197
- # 2. Get SSH Key
198
- ssh_key = self._ensure_ssh_key()
199
-
200
- # 3. Generate Setup Script
201
- log("📦 Generating cloud-init setup script...")
202
- setup_script = generate_stack(context, is_dockerized=is_dockerized)
203
-
204
- # 4. Create Droplet
205
- log(f"☁️ Provisioning droplet '{name}'...")
206
- droplet = digitalocean.Droplet(token=self.token,
207
- name=name,
208
- region=region,
209
- image=image,
210
- size_slug=size,
211
- ssh_keys=[ssh_key],
212
- user_data=setup_script)
170
+ # === 1. SETUP STAGE ===
171
+ logger("\n[bold blue]PHASE 1: SETUP[/bold blue]")
172
+ ssh_key = self._ensure_ssh_key(logger)
173
+
174
+ # === 2. ASSET GENERATION STAGE ===
175
+ logger("\n[bold blue]PHASE 2: GENERATING DEPLOYMENT ASSETS[/bold blue]")
176
+ context = {**kwargs} # Pass db config, etc.
177
+ files = dockerizer.generate_templated_assets(context)
178
+ for file in files:
179
+ logger(f" - Generated {file}")
180
+
181
+ # === 3. CLOUD-INIT STAGE ===
182
+ logger("\n[bold blue]PHASE 3: CREATING SERVER SETUP SCRIPT[/bold blue]")
183
+ cloud_init_script = recipes.generate_stack(context)
184
+ logger(" - Generated cloud-init script.")
185
+
186
+ # === 4. DROPLET CREATION STAGE ===
187
+ logger("\n[bold blue]PHASE 4: PROVISIONING SERVER[/bold blue]")
188
+ droplet = digitalocean.Droplet(
189
+ token=self.token, name=name, region=region, image=image, size_slug=size,
190
+ ssh_keys=[ssh_key], userdata=cloud_init_script, tags=["xenfra"]
191
+ )
213
192
  droplet.create()
193
+ logger(f" - Droplet '{name}' creation initiated (ID: {droplet.id}). Waiting for it to become active...")
214
194
 
215
- # 5. Wait for Droplet to become active and get IP
216
- log(" Waiting for droplet to become active and get IP address...")
217
- while not droplet.ip_address:
195
+ # === 5. POLLING STAGE ===
196
+ logger("\n[bold blue]PHASE 5: WAITING FOR SERVER SETUP[/bold blue]")
197
+ while True:
218
198
  droplet.load()
219
- time.sleep(5)
220
- log(f"🌍 Droplet is active at {droplet.ip_address}")
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
199
+ if droplet.status == 'active':
200
+ logger(" - Droplet is active. Waiting for SSH to be available...")
201
+ break
202
+ time.sleep(10)
203
+
204
+ ip_address = droplet.ip_address
205
+
206
+ # Retry SSH connection
207
+ conn = None
208
+ max_retries = 12 # 2-minute timeout for SSH
209
+ for i in range(max_retries):
210
+ try:
211
+ logger(f" - Attempting SSH connection ({i+1}/{max_retries})...")
212
+ conn = self._get_connection(ip_address)
213
+ conn.open() # Explicitly open the connection
214
+ logger(" - SSH connection established.")
231
215
  break
232
- log(f" - Still waiting for setup to complete... (attempt {attempts+1}/{max_retries})")
216
+ except Exception as e:
217
+ if i < max_retries - 1:
218
+ logger(f" - SSH connection failed. Retrying in 10s...")
219
+ time.sleep(10)
220
+ else:
221
+ raise DeploymentError(f"Failed to establish SSH connection: {e}", stage="Polling")
222
+
223
+ if not conn or not conn.is_connected:
224
+ raise DeploymentError("Could not establish SSH connection.", stage="Polling")
225
+
226
+ with conn:
227
+ for i in range(30): # 5-minute timeout for cloud-init
228
+ if conn.run("test -f /root/setup_complete", warn=True).ok:
229
+ logger(" - Cloud-init setup complete.")
230
+ break
231
+ time.sleep(10)
232
+ else:
233
+ raise DeploymentError("Server setup script failed to complete in time.", stage="Polling")
234
+
235
+ # === 6. CODE UPLOAD STAGE ===
236
+ logger("\n[bold blue]PHASE 6: UPLOADING APPLICATION CODE[/bold blue]")
237
+ with self._get_connection(ip_address) as conn:
238
+ fabric.transfer.Transfer(conn).upload(".", "/root/app", exclude=[".git", ".venv", "__pycache__"])
239
+ logger(" - Code upload complete.")
240
+
241
+ # === 7. FINAL DEPLOY STAGE ===
242
+ logger("\n[bold blue]PHASE 7: BUILDING AND DEPLOYING CONTAINERS[/bold blue]")
243
+ with self._get_connection(ip_address) as conn:
244
+ result = conn.run("cd /root/app && docker-compose up -d --build", hide=True)
245
+ if result.failed:
246
+ raise DeploymentError(f"docker-compose failed: {result.stderr}", stage="Deploy")
247
+ logger(" - Docker containers are building in the background...")
248
+
249
+ # === 8. VERIFICATION STAGE ===
250
+ logger("\n[bold blue]PHASE 8: VERIFYING DEPLOYMENT[/bold blue]")
251
+ app_port = context.get('port', 8000)
252
+ for i in range(24): # 2-minute timeout for health checks
253
+ logger(f" - Health check attempt {i+1}/24...")
254
+ with self._get_connection(ip_address) as conn:
255
+ # Check if container is running
256
+ ps_result = conn.run("cd /root/app && docker-compose ps", hide=True)
257
+ if "running" not in ps_result.stdout:
258
+ time.sleep(5)
259
+ continue
260
+
261
+ # Check if application is responsive
262
+ curl_result = conn.run(f"curl -s --fail http://localhost:{app_port}/", warn=True)
263
+ if curl_result.ok:
264
+ logger("[bold green] - Health check passed! Application is live.[/bold green]")
265
+
266
+ # === 9. PERSISTENCE STAGE ===
267
+ logger("\n[bold blue]PHASE 9: SAVING DEPLOYMENT TO DATABASE[/bold blue]")
268
+ project = Project(
269
+ droplet_id=droplet.id,
270
+ name=droplet.name,
271
+ ip_address=ip_address,
272
+ status=droplet.status,
273
+ region=droplet.region['slug'],
274
+ size=droplet.size_slug,
275
+ )
276
+ session.add(project)
277
+ session.commit()
278
+ logger(" - Deployment saved.")
279
+
280
+ return droplet # Return the full droplet object
233
281
  time.sleep(5)
234
- attempts += 1
235
-
236
- if not is_healthy:
237
- log("⚠️ Server setup stalled. Initiating auto-heal protocol...")
238
- self._auto_heal_server(droplet.ip_address)
239
- if not self._is_setup_complete(droplet.ip_address):
240
- raise Exception("Deployment failed: Auto-heal could not recover the server.")
241
- log("✅ Server recovered successfully after auto-heal.")
242
-
243
- # 7. Upload Code
244
- self.upload_code(droplet.ip_address)
245
-
246
- # 8. Run Docker Compose if applicable
247
- if is_dockerized:
248
- log("🚀 Starting application with Docker Compose...")
249
- c = self._get_connection(droplet.ip_address)
250
- c.run("cd /root/app && docker compose up -d", hide=True)
251
-
252
- log("🎉 Deployment successful!")
253
- return {"status": "success", "ip": droplet.ip_address, "name": name}
282
+ else:
283
+ # On failure, get logs and destroy droplet
284
+ with self._get_connection(ip_address) as conn:
285
+ logs = conn.run("cd /root/app && docker-compose logs", hide=True).stdout
286
+ raise DeploymentError(f"Application failed to become healthy in time. Logs:\n{logs}", stage="Verification")
254
287
 
255
288
  except Exception as e:
256
- log(f"❌ CRITICAL DEPLOYMENT FAILURE: {e}")
257
- # Optionally, trigger a cleanup action here
258
- raise e
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
289
+ if droplet:
290
+ logger(f"[bold red]Deployment failed. Cleaning up created server '{droplet.name}'...[/bold red]")
291
+ droplet.destroy()
292
+ raise e