xenfra 0.4.3__py3-none-any.whl → 0.4.4__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.
@@ -1,973 +1,1133 @@
1
- """
2
- Deployment commands for Xenfra CLI.
3
- """
4
-
5
- import os
6
-
7
- import click
8
- from rich.console import Console
9
- from rich.panel import Panel
10
- from rich.table import Table
11
- from xenfra_sdk import XenfraClient
12
- from xenfra_sdk.exceptions import XenfraAPIError, XenfraError
13
- from xenfra_sdk.privacy import scrub_logs
14
-
15
- from ..utils.auth import API_BASE_URL, get_auth_token
16
- from ..utils.codebase import has_xenfra_config
17
- from ..utils.config import apply_patch, read_xenfra_yaml
18
- from ..utils.validation import (
19
- validate_branch_name,
20
- validate_deployment_id,
21
- validate_framework,
22
- validate_git_repo_url,
23
- validate_project_name,
24
- )
25
-
26
- import time
27
- from datetime import datetime
28
-
29
- from rich.live import Live
30
- from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
31
-
32
- console = Console()
33
-
34
- # Maximum number of retry attempts for auto-healing
35
- MAX_RETRY_ATTEMPTS = 3
36
-
37
-
38
- def get_client() -> XenfraClient:
39
- """Get authenticated SDK client."""
40
- token = get_auth_token()
41
- if not token:
42
- console.print("[bold red]Not logged in. Run 'xenfra auth login' first.[/bold red]")
43
- raise click.Abort()
44
-
45
- return XenfraClient(token=token, api_url=API_BASE_URL)
46
-
47
-
48
- def show_diagnosis_panel(diagnosis: str, suggestion: str):
49
- """Display diagnosis and suggestion in formatted panels."""
50
- console.print()
51
- console.print(Panel(diagnosis, title="[bold red]🔍 Diagnosis[/bold red]", border_style="red"))
52
- console.print()
53
- console.print(
54
- Panel(suggestion, title="[bold yellow]💡 Suggestion[/bold yellow]", border_style="yellow")
55
- )
56
-
57
-
58
- def show_patch_preview(patch_data: dict):
59
- """Show a preview of the patch that will be applied."""
60
- console.print()
61
- console.print("[bold green]🔧 Automatic Fix Available[/bold green]")
62
- console.print(f" [cyan]File:[/cyan] {patch_data.get('file')}")
63
- console.print(f" [cyan]Operation:[/cyan] {patch_data.get('operation')}")
64
- console.print(f" [cyan]Value:[/cyan] {patch_data.get('value')}")
65
- console.print()
66
-
67
-
68
- def _stream_deployment(client: XenfraClient, project_name: str, git_repo: str, branch: str, framework: str, region: str, size_slug: str, is_dockerized: bool = True, port: int = None, command: str = None, entrypoint: str = None, database: str = None, package_manager: str = None, dependency_file: str = None, file_manifest: list = None):
69
- """
70
- Creates deployment with real-time SSE streaming (no polling needed).
71
-
72
- Returns tuple of (status, deployment_id, logs_buffer)
73
- """
74
- console.print(Panel(
75
- f"[bold cyan]Project:[/bold cyan] {project_name}\n"
76
- f"[bold cyan]Mode:[/bold cyan] Real-time Streaming Deployment",
77
- title="[bold green]🚀 Deployment Starting[/bold green]",
78
- border_style="green"
79
- ))
80
-
81
- deployment_id = None
82
- logs_buffer = []
83
- status_val = "PENDING"
84
-
85
- try:
86
- for event in client.deployments.create_stream(
87
- project_name=project_name,
88
- git_repo=git_repo,
89
- branch=branch,
90
- framework=framework,
91
- region=region,
92
- size_slug=size_slug,
93
- is_dockerized=is_dockerized,
94
- port=port,
95
- command=command,
96
- entrypoint=entrypoint, # Pass entrypoint to deployment API
97
- database=database,
98
- package_manager=package_manager,
99
- dependency_file=dependency_file,
100
- file_manifest=file_manifest,
101
- ):
102
-
103
- event_type = event.get("event", "message")
104
- data = event.get("data", "")
105
-
106
- if event_type == "deployment_created":
107
- # Extract deployment ID
108
- if isinstance(data, dict):
109
- deployment_id = data.get("deployment_id")
110
- console.print(f"[bold green]✓[/bold green] Deployment created: [cyan]{deployment_id}[/cyan]\n")
111
-
112
- elif event_type == "log":
113
- # Real-time log output
114
- log_line = str(data)
115
- logs_buffer.append(log_line)
116
-
117
- # Colorize output
118
- if any(x in log_line for x in ["ERROR", "FAILED", "✗"]):
119
- console.print(f"[bold red]{log_line}[/bold red]")
120
- elif any(x in log_line for x in ["WARN", "WARNING", "⚠"]):
121
- console.print(f"[yellow]{log_line}[/yellow]")
122
- elif any(x in log_line for x in ["SUCCESS", "COMPLETED", "", "passed!"]):
123
- console.print(f"[bold green]{log_line}[/bold green]")
124
- elif "PHASE" in log_line:
125
- console.print(f"\n[bold blue]{log_line}[/bold blue]")
126
- elif "[InfraEngine]" in log_line or "[INFO]" in log_line:
127
- console.print(f"[cyan][/cyan] {log_line}")
128
- else:
129
- console.print(f"[dim]{log_line}[/dim]")
130
-
131
- elif event_type == "error":
132
- error_msg = str(data)
133
- logs_buffer.append(f"ERROR: {error_msg}")
134
- console.print(f"\n[bold red]❌ Error: {error_msg}[/bold red]")
135
- status_val = "FAILED"
136
-
137
- elif event_type == "deployment_complete":
138
- # Final status
139
- if isinstance(data, dict):
140
- status_val = data.get("status", "UNKNOWN")
141
- ip_address = data.get("ip_address")
142
-
143
- console.print()
144
- if status_val == "SUCCESS":
145
- console.print("[bold green]✨ SUCCESS: Your application is live![/bold green]")
146
- if ip_address and ip_address != "unknown":
147
- console.print(f"[bold]Accessible at:[/bold] [link=http://{ip_address}]http://{ip_address}[/link]")
148
- elif status_val == "FAILED":
149
- console.print("[bold red] DEPLOYMENT FAILED[/bold red]")
150
- error = data.get("error")
151
- if error:
152
- console.print(f"[red]Error: {error}[/red]")
153
- break
154
-
155
- except Exception as e:
156
- console.print(f"\n[bold red]❌ Streaming error: {e}[/bold red]")
157
- status_val = "FAILED"
158
- logs_buffer.append(f"Streaming error: {e}")
159
-
160
- return (status_val, deployment_id, "\n".join(logs_buffer))
161
-
162
-
163
- def _follow_deployment(client: XenfraClient, deployment_id: str):
164
- """
165
- Polls logs and status in real-time until completion with CI/CD style output.
166
- (LEGACY - Used for backward compatibility)
167
- """
168
- console.print(Panel(
169
- f"[bold cyan]Deployment ID:[/bold cyan] {deployment_id}\n"
170
- f"[bold cyan]Mode:[/bold cyan] Streaming Real-time Infrastructure Logs",
171
- title="[bold green]🚀 Deployment Monitor[/bold green]",
172
- border_style="green"
173
- ))
174
-
175
- last_log_len = 0
176
- status_val = "PENDING"
177
-
178
- # Use a live display for the progress bar at the bottom
179
- with Progress(
180
- SpinnerColumn(),
181
- TextColumn("[bold blue]{task.description}"),
182
- BarColumn(bar_width=40),
183
- TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
184
- console=console,
185
- transient=False,
186
- ) as progress:
187
- task = progress.add_task("Waiting for server response...", total=100)
188
-
189
- while status_val not in ["SUCCESS", "FAILED", "CANCELLED"]:
190
- try:
191
- # 1. Update Status
192
- dep_status = client.deployments.get_status(deployment_id)
193
- status_val = dep_status.get("status", "PENDING")
194
- progress_val = dep_status.get("progress", 0)
195
- state = dep_status.get("state", "preparing")
196
-
197
- # Use a more descriptive description for the progress task
198
- desc = f"Phase: {state}"
199
- if status_val == "FAILED":
200
- desc = "[bold red]FAILED[/bold red]"
201
- elif status_val == "SUCCESS":
202
- desc = "[bold green]SUCCESS[/bold green]"
203
-
204
- progress.update(task, completed=progress_val, description=desc)
205
-
206
- # 2. Update Logs
207
- log_content = client.deployments.get_logs(deployment_id)
208
- if log_content and len(log_content) > last_log_len:
209
- new_logs = log_content[last_log_len:].strip()
210
- for line in new_logs.split("\n"):
211
- # Process and colorize lines
212
- clean_line = line.strip()
213
- if not clean_line:
214
- continue
215
-
216
- if any(x in clean_line for x in ["ERROR", "FAILED", "✗"]):
217
- progress.console.print(f"[bold red]{clean_line}[/bold red]")
218
- elif any(x in clean_line for x in ["WARN", "WARNING", "⚠"]):
219
- progress.console.print(f"[yellow]{clean_line}[/yellow]")
220
- elif any(x in clean_line for x in ["SUCCESS", "COMPLETED", "", "passed!"]):
221
- progress.console.print(f"[bold green]{clean_line}[/bold green]")
222
- elif "PHASE" in clean_line:
223
- progress.console.print(f"\n[bold blue]{clean_line}[/bold blue]")
224
- elif "[InfraEngine]" in clean_line:
225
- progress.console.print(f"[dim]{clean_line}[/dim]")
226
- else:
227
- progress.console.print(f"[cyan][/cyan] {clean_line}")
228
-
229
- last_log_len = len(log_content)
230
-
231
- if status_val in ["SUCCESS", "FAILED", "CANCELLED"]:
232
- break
233
-
234
- time.sleep(1.5) # Slightly faster polling for better feel
235
- except Exception as e:
236
- # progress.console.print(f"[dim]Transient connection issue: {e}[/dim]")
237
- time.sleep(3)
238
- continue
239
-
240
- console.print()
241
- if status_val == "SUCCESS":
242
- console.print("[bold green]✨ SUCCESS: Your application is live![/bold green]")
243
- # Try to get the IP address
244
- try:
245
- final_status = client.deployments.get_status(deployment_id)
246
- ip = final_status.get("ip_address")
247
- if ip:
248
- console.print(f"[bold]Accessible at:[/bold] [link=http://{ip}]http://{ip}[/link]")
249
- except:
250
- pass
251
- elif status_val == "FAILED":
252
- console.print("\n[bold red] FAILURE DETECTED: Entering AI Diagnosis Mode...[/bold red]")
253
-
254
- return status_val
255
-
256
-
257
- def zen_nod_workflow(logs: str, client: XenfraClient, attempt: int) -> bool:
258
- """
259
- Execute the Zen Nod auto-healing workflow.
260
-
261
- Args:
262
- logs: Deployment error logs
263
- client: Authenticated SDK client
264
- attempt: Current attempt number
265
-
266
- Returns:
267
- True if patch was applied and user wants to retry, False otherwise
268
- """
269
- console.print()
270
- console.print(f"[cyan]🤖 Analyzing failure (attempt {attempt}/{MAX_RETRY_ATTEMPTS})...[/cyan]")
271
-
272
- # Slice logs to last 300 lines for focused diagnosis (Fix #26)
273
- log_lines = logs.split("\n")
274
- if len(log_lines) > 300:
275
- logs = "\n".join(log_lines[-300:])
276
- console.print("[dim]Note: Analyzing only the last 300 lines of logs for efficiency.[/dim]")
277
-
278
- # Scrub sensitive data from logs
279
- scrubbed_logs = scrub_logs(logs)
280
-
281
- # Diagnose with AI
282
- try:
283
- diagnosis_result = client.intelligence.diagnose(scrubbed_logs)
284
- except Exception as e:
285
- console.print(f"[yellow]Could not diagnose failure: {e}[/yellow]")
286
- return False
287
-
288
- # Show diagnosis
289
- show_diagnosis_panel(diagnosis_result.diagnosis, diagnosis_result.suggestion)
290
-
291
- # Check if there's an automatic patch
292
- if diagnosis_result.patch and diagnosis_result.patch.file:
293
- show_patch_preview(diagnosis_result.patch.model_dump())
294
-
295
- # Zen Nod confirmation
296
- if click.confirm("Apply this fix and retry deployment?", default=True):
297
- try:
298
- # Apply patch (with automatic backup)
299
- backup_path = apply_patch(diagnosis_result.patch.model_dump())
300
- console.print("[bold green]✓ Patch applied[/bold green]")
301
- if backup_path:
302
- console.print(f"[dim]Backup saved: {backup_path}[/dim]")
303
- return True # Signal to retry
304
- except Exception as e:
305
- console.print(f"[bold red]Failed to apply patch: {e}[/bold red]")
306
- return False
307
- else:
308
- console.print()
309
- console.print("[yellow]❌ Patch declined. Follow the manual steps above.[/yellow]")
310
- return False
311
- else:
312
- console.print()
313
- console.print(
314
- "[yellow]No automatic fix available. Please follow the manual steps above.[/yellow]"
315
- )
316
- return False
317
-
318
-
319
- @click.command()
320
- @click.option("--project-name", help="Project name (defaults to current directory name)")
321
- @click.option("--git-repo", help="Git repository URL (if deploying from git)")
322
- @click.option("--branch", default="main", help="Git branch (default: main)")
323
- @click.option("--framework", help="Framework override (fastapi, flask, django)")
324
- @click.option("--region", help="DigitalOcean region override")
325
- @click.option("--size", help="DigitalOcean size slug override")
326
- @click.option("--no-heal", is_flag=True, help="Disable auto-healing on failure")
327
- def deploy(project_name, git_repo, branch, framework, region, size, no_heal):
328
- """
329
- Deploy current project to DigitalOcean with auto-healing.
330
-
331
- Deploys your application with zero configuration. The CLI will:
332
- 1. Check for xenfra.yaml (or run init if missing)
333
- 2. Create a deployment
334
- 3. Auto-diagnose and fix failures (unless --no-heal is set or XENFRA_NO_AI=1)
335
-
336
- Set XENFRA_NO_AI=1 environment variable to disable all AI features.
337
- """
338
- # Check XENFRA_NO_AI environment variable
339
- no_ai = os.environ.get("XENFRA_NO_AI", "0") == "1"
340
- if no_ai:
341
- console.print("[yellow]XENFRA_NO_AI is set. Auto-healing disabled.[/yellow]")
342
- no_heal = True
343
-
344
- # Check for xenfra.yaml
345
- if not has_xenfra_config():
346
- console.print("[yellow]No xenfra.yaml found.[/yellow]")
347
- if click.confirm("Run 'xenfra init' to create configuration?", default=True):
348
- from .intelligence import init
349
-
350
- ctx = click.get_current_context()
351
- ctx.invoke(init, manual=no_ai, accept_all=False)
352
- else:
353
- console.print("[dim]Deployment cancelled.[/dim]")
354
- return
355
-
356
- # Load configuration from xenfra.yaml if it exists
357
- config = {}
358
- if has_xenfra_config():
359
- try:
360
- config = read_xenfra_yaml()
361
- except Exception as e:
362
- console.print(f"[yellow]Warning: Could not read xenfra.yaml: {e}[/dim]")
363
-
364
- # Resolve values with precedence: 1. CLI Flag, 2. xenfra.yaml, 3. Default
365
- project_name = project_name or config.get("name") or os.path.basename(os.getcwd())
366
- framework = framework or config.get("framework")
367
- # Track if is_dockerized was explicitly set in config (to avoid AI override)
368
- is_dockerized_from_config = "is_dockerized" in config
369
- is_dockerized = config.get("is_dockerized", True)
370
- region = region or config.get("region") or "nyc3"
371
-
372
- # Resolve size slug (complex mapping)
373
- if not size:
374
- if config.get("size"):
375
- size = config.get("size")
376
- else:
377
- instance_size = config.get("instance_size", "basic")
378
- resources = config.get("resources", {})
379
- cpu = resources.get("cpu", 1)
380
-
381
- if instance_size == "standard" or cpu >= 2:
382
- size = "s-2vcpu-4gb"
383
- elif instance_size == "premium" or cpu >= 4:
384
- size = "s-4vcpu-8gb"
385
- else:
386
- size = "s-1vcpu-1gb"
387
-
388
- # Extract port, command, database from config
389
- # Track if port was explicitly set to avoid AI override
390
- port_from_config = config.get("port")
391
- port = port_from_config or 8000
392
- command = config.get("command") # Auto-detected if not provided
393
- entrypoint = config.get("entrypoint") # e.g., "todo.main:app"
394
- database_config = config.get("database", {})
395
- database = database_config.get("type") if isinstance(database_config, dict) else None
396
- package_manager = config.get("package_manager", "pip")
397
- dependency_file = config.get("dependency_file", "requirements.txt")
398
-
399
- # Default project name to current directory
400
- if not project_name:
401
- project_name = os.path.basename(os.getcwd())
402
-
403
- # Validate project name
404
- is_valid, error_msg = validate_project_name(project_name)
405
- if not is_valid:
406
- console.print(f"[bold red]Invalid project name: {error_msg}[/bold red]")
407
- raise click.Abort()
408
-
409
- # Validate git repo if provided
410
- if git_repo:
411
- is_valid, error_msg = validate_git_repo_url(git_repo)
412
- if not is_valid:
413
- console.print(f"[bold red]Invalid git repository URL: {error_msg}[/bold red]")
414
- raise click.Abort()
415
- console.print(f"[cyan]Deploying {project_name} from git repository...[/cyan]")
416
- console.print(f"[dim]Repository: {git_repo} (branch: {branch})[/dim]")
417
- else:
418
- # Note: Local folder deployment only works when engine runs locally
419
- # In cloud API mode, this will fail with a clear error from the server
420
- console.print(f"[cyan]Deploying {project_name}...[/cyan]")
421
- console.print("[dim]Note: Git repository recommended for cloud deployments[/dim]")
422
-
423
- # Validate branch name
424
- is_valid, error_msg = validate_branch_name(branch)
425
- if not is_valid:
426
- console.print(f"[bold red]Invalid branch name: {error_msg}[/bold red]")
427
- raise click.Abort()
428
-
429
- # Validate framework if provided
430
- if framework:
431
- is_valid, error_msg = validate_framework(framework)
432
- if not is_valid:
433
- console.print(f"[bold red]Invalid framework: {error_msg}[/bold red]")
434
- raise click.Abort()
435
-
436
- # Retry loop for auto-healing
437
- attempt = 0
438
- deployment_id = None
439
-
440
- try:
441
- with get_client() as client:
442
- while attempt < MAX_RETRY_ATTEMPTS:
443
- attempt += 1
444
-
445
- if attempt > 1:
446
- console.print(
447
- f"\n[cyan]🔄 Retrying deployment (attempt {attempt}/{MAX_RETRY_ATTEMPTS})...[/cyan]"
448
- )
449
- else:
450
- console.print("[cyan]Creating deployment...[/cyan]")
451
-
452
- # Detect framework if not provided (AI-powered Zen Mode)
453
- if not framework:
454
- console.print("[cyan]🔍 AI Auto-detecting project type...[/cyan]")
455
- try:
456
- from ..utils.codebase import scan_codebase
457
- code_snippets = scan_codebase()
458
- if code_snippets:
459
- analysis = client.intelligence.analyze_codebase(code_snippets)
460
- framework = analysis.framework
461
- # Only use AI's is_dockerized if config didn't explicitly set it
462
- if not is_dockerized_from_config:
463
- is_dockerized = analysis.is_dockerized
464
- # Override port if AI detected it and config didn't set one
465
- if not port_from_config and analysis.port:
466
- port = analysis.port
467
- # Override port and size if AI has strong recommendations
468
- if not size and analysis.instance_size:
469
- size = "s-1vcpu-1gb" if analysis.instance_size == "basic" else "s-2vcpu-4gb"
470
-
471
- mode_str = "Docker" if is_dockerized else "Bare Metal"
472
- console.print(f"[green]✓ Detected {framework.upper()} project ({mode_str} Mode)[/green] (Port: {port})")
473
- else:
474
- console.print("[yellow]⚠ No code files found for AI analysis. Defaulting to 'fastapi'[/yellow]")
475
- framework = "fastapi"
476
- is_dockerized = True
477
- except Exception as e:
478
- console.print(f"[yellow]⚠ AI detection failed: {e}. Defaulting to 'fastapi'[/yellow]")
479
- framework = "fastapi"
480
- is_dockerized = True
481
-
482
- # Delta upload: if no git_repo, scan and upload local files
483
- file_manifest = None
484
- if not git_repo:
485
- from ..utils.file_sync import scan_project_files_cached, ensure_gitignore_ignored
486
-
487
- # Protect privacy: ensure .xenfra is in .gitignore
488
- if ensure_gitignore_ignored():
489
- console.print("[dim] - Added .xenfra to .gitignore for privacy[/dim]")
490
-
491
- console.print("[cyan]📁 Scanning project files...[/cyan]")
492
-
493
- file_manifest = scan_project_files_cached()
494
- console.print(f"[dim]Found {len(file_manifest)} files[/dim]")
495
-
496
- if not file_manifest:
497
- console.print("[bold red]Error: No files found to deploy.[/bold red]")
498
- raise click.Abort()
499
-
500
- # Check which files need uploading
501
- console.print("[cyan]🔍 Checking file cache...[/cyan]")
502
- check_result = client.files.check(file_manifest)
503
- missing = check_result.get('missing', [])
504
- cached = check_result.get('cached', 0)
505
-
506
- if cached > 0:
507
- console.print(f"[green]✓ {cached} files already cached[/green]")
508
-
509
- # Upload missing files
510
- if missing:
511
- console.print(f"[cyan]☁️ Uploading {len(missing)} files...[/cyan]")
512
- uploaded = client.files.upload_files(
513
- file_manifest,
514
- missing,
515
- progress_callback=lambda done, total: console.print(f"[dim] Progress: {done}/{total}[/dim]") if done % 10 == 0 or done == total else None
516
- )
517
- console.print(f"[green]✓ Uploaded {uploaded} files[/green]")
518
- else:
519
- console.print("[green]✓ All files already cached[/green]")
520
-
521
- # Remove abs_path from manifest before sending to API
522
- file_manifest = [{"path": f["path"], "sha": f["sha"], "size": f["size"]} for f in file_manifest]
523
-
524
- # Create deployment with real-time streaming
525
- try:
526
- status_result, deployment_id, logs_data = _stream_deployment(
527
- client=client,
528
- project_name=project_name,
529
- git_repo=git_repo,
530
- branch=branch,
531
- framework=framework,
532
- region=region,
533
- size_slug=size,
534
- is_dockerized=is_dockerized,
535
- port=port,
536
- command=command,
537
- entrypoint=entrypoint, # Pass entrypoint to deployment API
538
- database=database,
539
- package_manager=package_manager,
540
- dependency_file=dependency_file,
541
- file_manifest=file_manifest,
542
- )
543
-
544
-
545
- if status_result == "FAILED" and not no_heal:
546
- # Hand off to the Zen Nod AI Agent
547
- should_retry = zen_nod_workflow(logs_data, client, attempt)
548
-
549
- if should_retry:
550
- # The agent applied a fix, loop back for attempt + 1
551
- continue
552
- else:
553
- # Agent couldn't fix it or user declined
554
- raise click.Abort()
555
-
556
- # If we got here with success, break the retry loop
557
- if status_result == "SUCCESS":
558
- break
559
- else:
560
- raise click.Abort()
561
-
562
- except XenfraAPIError as e:
563
- # Deployment failed - try to provide helpful error
564
- from ..utils.errors import detect_error_type, show_error_with_solution
565
-
566
- console.print(f"[bold red]✗ Deployment failed[/bold red]")
567
-
568
- # Try to detect error type and show solution
569
- error_type, error_kwargs = detect_error_type(str(e.detail))
570
- if error_type:
571
- show_error_with_solution(error_type, **error_kwargs)
572
- else:
573
- console.print(f"[red]{e.detail}[/red]")
574
-
575
- # Check if we should auto-heal
576
- if no_heal or attempt >= MAX_RETRY_ATTEMPTS:
577
- # No auto-healing or max retries reached
578
- if attempt >= MAX_RETRY_ATTEMPTS:
579
- console.print(
580
- f"\n[bold red]❌ Maximum retry attempts ({MAX_RETRY_ATTEMPTS}) reached.[/bold red]"
581
- )
582
- console.print(
583
- "[yellow]Unable to auto-fix the issue. Please review the errors above.[/yellow]"
584
- )
585
- raise
586
- else:
587
- # Try to get logs for diagnosis
588
- error_logs = str(e.detail)
589
- try:
590
- if deployment_id:
591
- # This should be a method in the SDK that returns a string
592
- logs_response = client.deployments.get_logs(deployment_id)
593
- if isinstance(logs_response, dict):
594
- error_logs = logs_response.get("logs", str(e.detail))
595
- else:
596
- error_logs = str(logs_response) # Assuming it can be a string
597
- except Exception as log_err:
598
- console.print(
599
- f"[yellow]Warning: Could not fetch detailed logs for diagnosis: {log_err}[/yellow]"
600
- )
601
- # Fallback to the initial error detail
602
- pass
603
-
604
- # Run Zen Nod workflow
605
- should_retry = zen_nod_workflow(error_logs, client, attempt)
606
-
607
- if not should_retry:
608
- # User declined patch or no patch available
609
- console.print("\n[dim]Deployment cancelled.[/dim]")
610
- raise click.Abort()
611
-
612
- # Continue to next iteration (retry)
613
- continue
614
-
615
- except XenfraAPIError as e:
616
- console.print(f"[bold red]API Error: {e.detail}[/bold red]")
617
- except XenfraError as e:
618
- console.print(f"[bold red]Error: {e}[/bold red]")
619
- except click.Abort:
620
- pass
621
- except Exception as e:
622
- console.print(f"[bold red]Unexpected error: {e}[/bold red]")
623
-
624
-
625
- @click.command()
626
- @click.argument("deployment-id")
627
- @click.option("--follow", "-f", is_flag=True, help="Follow log output (stream)")
628
- @click.option("--tail", type=int, help="Show last N lines")
629
- def logs(deployment_id, follow, tail):
630
- # Validate deployment ID
631
- is_valid, error_msg = validate_deployment_id(deployment_id)
632
- if not is_valid:
633
- console.print(f"[bold red]Invalid deployment ID: {error_msg}[/bold red]")
634
- raise click.Abort()
635
- """
636
- Stream deployment logs.
637
-
638
- Shows logs for a specific deployment. Use --follow to stream logs in real-time.
639
- """
640
- try:
641
- with get_client() as client:
642
- console.print(f"[cyan]Fetching logs for deployment {deployment_id}...[/cyan]")
643
-
644
- log_content = client.deployments.get_logs(deployment_id)
645
-
646
- if not log_content:
647
- console.print("[yellow]No logs available yet.[/yellow]")
648
- console.print("[dim]The deployment may still be starting up.[/dim]")
649
- return
650
-
651
- # Process logs
652
- log_lines = log_content.strip().split("\n")
653
-
654
- # Apply tail if specified
655
- if tail:
656
- log_lines = log_lines[-tail:]
657
-
658
- # Display logs with syntax highlighting
659
- console.print(f"\n[bold]Logs for deployment {deployment_id}:[/bold]\n")
660
-
661
- if follow:
662
- _follow_deployment(client, deployment_id)
663
- return
664
-
665
- # Display logs
666
- for line in log_lines:
667
- # Color-code based on log level
668
- if "ERROR" in line or "FAILED" in line:
669
- console.print(f"[red]{line}[/red]")
670
- elif "WARN" in line or "WARNING" in line:
671
- console.print(f"[yellow]{line}[/yellow]")
672
- elif "SUCCESS" in line or "COMPLETED" in line:
673
- console.print(f"[green]{line}[/green]")
674
- elif "INFO" in line:
675
- console.print(f"[cyan]{line}[/cyan]")
676
- else:
677
- console.print(line)
678
-
679
- except XenfraAPIError as e:
680
- console.print(f"[bold red]API Error: {e.detail}[/bold red]")
681
- except XenfraError as e:
682
- console.print(f"[bold red]Error: {e}[/bold red]")
683
- except click.Abort:
684
- pass
685
-
686
-
687
- @click.command()
688
- @click.argument("deployment-id", required=False)
689
- @click.option("--watch", "-w", is_flag=True, help="Watch status updates")
690
- def status(deployment_id, watch):
691
- """
692
- Show deployment status.
693
-
694
- Displays current status, progress, and details for a deployment.
695
- Use --watch to monitor status in real-time.
696
- """
697
- try:
698
- if not deployment_id:
699
- console.print("[yellow]No deployment ID provided.[/yellow]")
700
- console.print("[dim]Usage: xenfra status <deployment-id>[/dim]")
701
- return
702
-
703
- # Validate deployment ID
704
- is_valid, error_msg = validate_deployment_id(deployment_id)
705
- if not is_valid:
706
- console.print(f"[bold red]Invalid deployment ID: {error_msg}[/bold red]")
707
- raise click.Abort()
708
-
709
- with get_client() as client:
710
- console.print(f"[cyan]Fetching status for deployment {deployment_id}...[/cyan]")
711
-
712
- deployment_status = client.deployments.get_status(deployment_id)
713
-
714
- if watch:
715
- _follow_deployment(client, deployment_id)
716
- return
717
-
718
- # Display status
719
- status_value = deployment_status.get("status", "UNKNOWN")
720
- state = deployment_status.get("state", "unknown")
721
- progress = deployment_status.get("progress", 0)
722
-
723
- # Status panel
724
- status_color = {
725
- "PENDING": "yellow",
726
- "IN_PROGRESS": "cyan",
727
- "SUCCESS": "green",
728
- "FAILED": "red",
729
- "CANCELLED": "dim",
730
- }.get(status_value, "white")
731
-
732
- # Create status table
733
- table = Table(show_header=False, box=None)
734
- table.add_column("Property", style="cyan")
735
- table.add_column("Value")
736
-
737
- table.add_row("Deployment ID", str(deployment_id))
738
- table.add_row("Status", f"[{status_color}]{status_value}[/{status_color}]")
739
- table.add_row("State", state)
740
-
741
- if progress > 0:
742
- table.add_row("Progress", f"{progress}%")
743
-
744
- if "project_name" in deployment_status:
745
- table.add_row("Project", deployment_status["project_name"])
746
-
747
- if "created_at" in deployment_status:
748
- table.add_row("Created", deployment_status["created_at"])
749
-
750
- if "finished_at" in deployment_status:
751
- table.add_row("Finished", deployment_status["finished_at"])
752
-
753
- if "url" in deployment_status:
754
- table.add_row("URL", f"[link]{deployment_status['url']}[/link]")
755
-
756
- if "ip_address" in deployment_status:
757
- table.add_row("IP Address", deployment_status["ip_address"])
758
-
759
- panel = Panel(table, title="[bold]Deployment Status[/bold]", border_style=status_color)
760
- console.print(panel)
761
-
762
- # Show error if failed
763
- if status_value == "FAILED" and "error" in deployment_status:
764
- error_panel = Panel(
765
- deployment_status["error"],
766
- title="[bold red]Error[/bold red]",
767
- border_style="red",
768
- )
769
- console.print("\n", error_panel)
770
-
771
- console.print("\n[bold]Troubleshooting:[/bold]")
772
- console.print(f" • View logs: [cyan]xenfra logs {deployment_id}[/cyan]")
773
- console.print(f" • Diagnose: [cyan]xenfra diagnose {deployment_id}[/cyan]")
774
-
775
- # Show next steps based on status
776
- elif status_value == "SUCCESS":
777
- console.print("\n[bold green]Deployment successful! 🎉[/bold green]")
778
- if "url" in deployment_status:
779
- console.print(f" • Visit: [link]{deployment_status['url']}[/link]")
780
-
781
- elif status_value in ["PENDING", "IN_PROGRESS"]:
782
- console.print("\n[bold]Deployment in progress...[/bold]")
783
- console.print(f" • View logs: [cyan]xenfra logs {deployment_id}[/cyan]")
784
- console.print(f" • Check again: [cyan]xenfra status {deployment_id}[/cyan]")
785
-
786
- except XenfraAPIError as e:
787
- console.print(f"[bold red]API Error: {e.detail}[/bold red]")
788
- except XenfraError as e:
789
- console.print(f"[bold red]Error: {e}[/bold red]")
790
- except click.Abort:
791
- pass
792
-
793
-
794
- @click.command()
795
- @click.argument("deployment-id")
796
- @click.option("--format", "output_format", type=click.Choice(["detailed", "summary"], case_sensitive=False), default="detailed", help="Report format (detailed or summary)")
797
- def report(deployment_id, output_format):
798
- """
799
- Generate deployment report with self-healing events.
800
-
801
- Shows comprehensive deployment information including:
802
- - Deployment status and timeline
803
- - Self-healing attempts and outcomes
804
- - Patches applied during healing
805
- - Statistics and metrics
806
- """
807
- try:
808
- # Validate deployment ID
809
- is_valid, error_msg = validate_deployment_id(deployment_id)
810
- if not is_valid:
811
- console.print(f"[bold red]Invalid deployment ID: {error_msg}[/bold red]")
812
- raise click.Abort()
813
-
814
- with get_client() as client:
815
- console.print(f"[cyan]Generating report for deployment {deployment_id}...[/cyan]\n")
816
-
817
- # Get deployment status
818
- try:
819
- deployment_status = client.deployments.get_status(deployment_id)
820
- except XenfraAPIError as e:
821
- console.print(f"[bold red]Error fetching deployment status: {e.detail}[/bold red]")
822
- raise click.Abort()
823
-
824
- # Get deployment logs
825
- try:
826
- logs = client.deployments.get_logs(deployment_id)
827
- except XenfraAPIError:
828
- logs = None
829
-
830
- # Parse status
831
- status_value = deployment_status.get("status", "UNKNOWN")
832
- state = deployment_status.get("state", "unknown")
833
- progress = deployment_status.get("progress", 0)
834
-
835
- # Status color mapping
836
- status_color = {
837
- "PENDING": "yellow",
838
- "IN_PROGRESS": "cyan",
839
- "SUCCESS": "green",
840
- "FAILED": "red",
841
- "CANCELLED": "dim",
842
- }.get(status_value, "white")
843
-
844
- # Calculate statistics from logs
845
- heal_attempts = logs.count("🤖 Analyzing failure") if logs else 0
846
- patches_applied = logs.count("✓ Patch applied") if logs else 0
847
- diagnoses = logs.count("🔍 Diagnosis") if logs else 0
848
-
849
- # Create main report table
850
- report_table = Table(show_header=True, box=None)
851
- report_table.add_column("Property", style="cyan", width=25)
852
- report_table.add_column("Value", style="white")
853
-
854
- report_table.add_row("Deployment ID", str(deployment_id))
855
- report_table.add_row("Status", f"[{status_color}]{status_value}[/{status_color}]")
856
- report_table.add_row("State", state)
857
-
858
- if progress > 0:
859
- report_table.add_row("Progress", f"{progress}%")
860
-
861
- if "project_name" in deployment_status:
862
- report_table.add_row("Project", deployment_status["project_name"])
863
-
864
- if "created_at" in deployment_status:
865
- report_table.add_row("Created", deployment_status["created_at"])
866
-
867
- if "finished_at" in deployment_status:
868
- report_table.add_row("Finished", deployment_status["finished_at"])
869
-
870
- if "url" in deployment_status:
871
- report_table.add_row("URL", f"[link]{deployment_status['url']}[/link]")
872
-
873
- if "ip_address" in deployment_status:
874
- report_table.add_row("IP Address", deployment_status["ip_address"])
875
-
876
- # Self-healing statistics
877
- report_table.add_row("", "") # Separator
878
- report_table.add_row("[bold]Self-Healing Stats[/bold]", "")
879
- report_table.add_row("Healing Attempts", str(heal_attempts))
880
- report_table.add_row("Patches Applied", str(patches_applied))
881
- report_table.add_row("Diagnoses Performed", str(diagnoses))
882
-
883
- if heal_attempts > 0:
884
- success_rate = (patches_applied / heal_attempts * 100) if heal_attempts > 0 else 0
885
- report_table.add_row("Healing Success Rate", f"{success_rate:.1f}%")
886
-
887
- # Display main report
888
- console.print(Panel(report_table, title="[bold]Deployment Report[/bold]", border_style=status_color))
889
-
890
- # Detailed format includes timeline and healing events
891
- if output_format == "detailed" and logs:
892
- console.print("\n[bold]Self-Healing Timeline[/bold]\n")
893
-
894
- # Extract healing events from logs
895
- log_lines = logs.split("\n")
896
- timeline_entries = []
897
-
898
- for i, line in enumerate(log_lines):
899
- if "🤖 Analyzing failure" in line:
900
- attempt_match = None
901
- # Try to find attempt number in surrounding lines
902
- for j in range(max(0, i-5), min(len(log_lines), i+10)):
903
- if "attempt" in log_lines[j].lower():
904
- timeline_entries.append(("Healing Attempt", log_lines[j].strip()))
905
- break
906
- elif "🔍 Diagnosis" in line or "Diagnosis" in line:
907
- # Extract diagnosis text from next few lines
908
- diagnosis_text = line.strip()
909
- if i+1 < len(log_lines) and log_lines[i+1].strip():
910
- diagnosis_text += "\n " + log_lines[i+1].strip()[:100]
911
- timeline_entries.append(("Diagnosis", diagnosis_text))
912
- elif "✓ Patch applied" in line or "Patch applied" in line:
913
- timeline_entries.append(("Patch Applied", line.strip()))
914
- elif "🔄 Retrying deployment" in line:
915
- timeline_entries.append(("Retry", line.strip()))
916
-
917
- if timeline_entries:
918
- timeline_table = Table(show_header=True, box=None)
919
- timeline_table.add_column("Event", style="cyan", width=20)
920
- timeline_table.add_column("Details", style="white")
921
-
922
- for event_type, details in timeline_entries[:20]: # Limit to 20 entries
923
- timeline_table.add_row(event_type, details)
924
-
925
- console.print(timeline_table)
926
- else:
927
- console.print("[dim]No self-healing events detected in logs.[/dim]")
928
-
929
- # Show error if failed
930
- if status_value == "FAILED":
931
- console.print("\n[bold red]⚠ Deployment Failed[/bold red]")
932
- if "error" in deployment_status:
933
- error_panel = Panel(
934
- deployment_status["error"],
935
- title="[bold red]Error Details[/bold red]",
936
- border_style="red",
937
- )
938
- console.print("\n", error_panel)
939
-
940
- console.print("\n[bold]Troubleshooting:[/bold]")
941
- console.print(f" • View logs: [cyan]xenfra logs {deployment_id}[/cyan]")
942
- console.print(f" • Diagnose: [cyan]xenfra diagnose {deployment_id}[/cyan]")
943
- console.print(f" • View status: [cyan]xenfra status {deployment_id}[/cyan]")
944
-
945
- # Success summary
946
- elif status_value == "SUCCESS":
947
- console.print("\n[bold green] Deployment Successful[/bold green]")
948
- if heal_attempts > 0:
949
- console.print(f"[dim]Deployment succeeded after {heal_attempts} self-healing attempt(s).[/dim]")
950
- if "url" in deployment_status:
951
- console.print(f" • Visit: [link]{deployment_status['url']}[/link]")
952
-
953
- # Export summary format (JSON-like structure for programmatic use)
954
- if output_format == "summary":
955
- console.print("\n[bold]Summary Format:[/bold]")
956
- import json
957
- summary = {
958
- "deployment_id": deployment_id,
959
- "status": status_value,
960
- "healing_attempts": heal_attempts,
961
- "patches_applied": patches_applied,
962
- "success": status_value == "SUCCESS",
963
- }
964
- console.print(f"[dim]{json.dumps(summary, indent=2)}[/dim]")
965
-
966
- except XenfraAPIError as e:
967
- console.print(f"[bold red]API Error: {e.detail}[/bold red]")
968
- except XenfraError as e:
969
- console.print(f"[bold red]Error: {e}[/bold red]")
970
- except click.Abort:
971
- pass
972
- except Exception as e:
973
- console.print(f"[bold red]Unexpected error: {e}[/bold red]")
1
+ """
2
+ Deployment commands for Xenfra CLI.
3
+ """
4
+
5
+ import os
6
+
7
+ import click
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+ from rich.table import Table
11
+ from xenfra_sdk import XenfraClient
12
+ from xenfra_sdk.exceptions import XenfraAPIError, XenfraError
13
+ from xenfra_sdk.privacy import scrub_logs
14
+
15
+ from ..utils.auth import API_BASE_URL, get_auth_token
16
+ from ..utils.codebase import has_xenfra_config
17
+ from ..utils.config import apply_patch, read_xenfra_yaml
18
+ from ..utils.validation import (
19
+ validate_branch_name,
20
+ validate_deployment_id,
21
+ validate_framework,
22
+ validate_git_repo_url,
23
+ validate_project_id,
24
+ validate_project_name,
25
+ )
26
+
27
+ import time
28
+ from datetime import datetime
29
+
30
+ from rich.live import Live
31
+ from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
32
+
33
+ console = Console()
34
+
35
+ # Maximum number of retry attempts for auto-healing
36
+ MAX_RETRY_ATTEMPTS = 3
37
+
38
+
39
+ def get_client() -> XenfraClient:
40
+ """Get authenticated SDK client."""
41
+ token = get_auth_token()
42
+ if not token:
43
+ console.print("[bold red]Not logged in. Run 'xenfra auth login' first.[/bold red]")
44
+ raise click.Abort()
45
+
46
+ return XenfraClient(token=token, api_url=API_BASE_URL)
47
+
48
+
49
+ def show_diagnosis_panel(diagnosis: str, suggestion: str):
50
+ """Display diagnosis and suggestion in formatted panels."""
51
+ console.print()
52
+ console.print(Panel(diagnosis, title="[bold red]🔍 Diagnosis[/bold red]", border_style="red"))
53
+ console.print()
54
+ console.print(
55
+ Panel(suggestion, title="[bold yellow]💡 Suggestion[/bold yellow]", border_style="yellow")
56
+ )
57
+
58
+
59
+ def show_patch_preview(patch_data: dict):
60
+ """Show a preview of the patch that will be applied."""
61
+ console.print()
62
+ console.print("[bold green]🔧 Automatic Fix Available[/bold green]")
63
+ console.print(f" [cyan]File:[/cyan] {patch_data.get('file')}")
64
+ console.print(f" [cyan]Operation:[/cyan] {patch_data.get('operation')}")
65
+ console.print(f" [cyan]Value:[/cyan] {patch_data.get('value')}")
66
+ console.print()
67
+
68
+
69
+ def _stream_deployment(client: XenfraClient, project_name: str, git_repo: str, branch: str, framework: str, region: str, size_slug: str, is_dockerized: bool = True, port: int = None, command: str = None, entrypoint: str = None, database: str = None, package_manager: str = None, dependency_file: str = None, file_manifest: list = None, cleanup_on_failure: bool = False, services: list = None, mode: str = None):
70
+ """
71
+ Creates deployment with real-time SSE streaming (no polling needed).
72
+
73
+ Returns tuple of (status, deployment_id, logs_buffer)
74
+ """
75
+ console.print(Panel(
76
+ f"[bold cyan]Project:[/bold cyan] {project_name}\n"
77
+ f"[bold cyan]Mode:[/bold cyan] Real-time Streaming Deployment",
78
+ title="[bold green]🚀 Deployment Starting[/bold green]",
79
+ border_style="green"
80
+ ))
81
+
82
+ deployment_id = None
83
+ logs_buffer = []
84
+ status_val = "PENDING"
85
+
86
+ try:
87
+ for event in client.deployments.create_stream(
88
+ project_name=project_name,
89
+ git_repo=git_repo,
90
+ branch=branch,
91
+ framework=framework,
92
+ region=region,
93
+ size_slug=size_slug,
94
+ is_dockerized=is_dockerized,
95
+ port=port,
96
+ command=command,
97
+ entrypoint=entrypoint, # Pass entrypoint to deployment API
98
+ database=database,
99
+ package_manager=package_manager,
100
+ dependency_file=dependency_file,
101
+ file_manifest=file_manifest,
102
+ cleanup_on_failure=cleanup_on_failure,
103
+ services=services, # Microservices support
104
+ mode=mode, # Deployment mode
105
+ ):
106
+
107
+ event_type = event.get("event", "message")
108
+ data = event.get("data", "")
109
+
110
+ if event_type == "deployment_created":
111
+ # Extract deployment ID
112
+ if isinstance(data, dict):
113
+ deployment_id = data.get("deployment_id")
114
+ console.print(f"[bold green]✓[/bold green] Deployment created: [cyan]{deployment_id}[/cyan]\n")
115
+
116
+ elif event_type == "log":
117
+ # Real-time log output
118
+ log_line = str(data)
119
+ logs_buffer.append(log_line)
120
+
121
+ # Colorize output
122
+ if any(x in log_line for x in ["ERROR", "FAILED", ""]):
123
+ console.print(f"[bold red]{log_line}[/bold red]")
124
+ elif any(x in log_line for x in ["WARN", "WARNING", "⚠"]):
125
+ console.print(f"[yellow]{log_line}[/yellow]")
126
+ elif any(x in log_line for x in ["SUCCESS", "COMPLETED", "✓", "passed!"]):
127
+ console.print(f"[bold green]{log_line}[/bold green]")
128
+ elif "PHASE" in log_line:
129
+ console.print(f"\n[bold blue]{log_line}[/bold blue]")
130
+ elif "[InfraEngine]" in log_line or "[INFO]" in log_line:
131
+ console.print(f"[cyan]›[/cyan] {log_line}")
132
+ else:
133
+ console.print(f"[dim]{log_line}[/dim]")
134
+
135
+ elif event_type == "error":
136
+ error_msg = str(data)
137
+ logs_buffer.append(f"ERROR: {error_msg}")
138
+ console.print(f"\n[bold red]❌ Error: {error_msg}[/bold red]")
139
+ status_val = "FAILED"
140
+
141
+ elif event_type == "deployment_complete":
142
+ # Final status
143
+ if isinstance(data, dict):
144
+ status_val = data.get("status", "UNKNOWN")
145
+ ip_address = data.get("ip_address")
146
+
147
+ console.print()
148
+ if status_val == "SUCCESS":
149
+ console.print("[bold green] SUCCESS: Your application is live![/bold green]")
150
+ if ip_address and ip_address != "unknown":
151
+ console.print(f"[bold]Accessible at:[/bold] [link=http://{ip_address}]http://{ip_address}[/link]")
152
+ elif status_val == "FAILED":
153
+ console.print("[bold red]❌ DEPLOYMENT FAILED[/bold red]")
154
+ error = data.get("error")
155
+ if error:
156
+ console.print(f"[red]Error: {error}[/red]")
157
+ break
158
+
159
+ except Exception as e:
160
+ console.print(f"\n[bold red]❌ Streaming error: {e}[/bold red]")
161
+ status_val = "FAILED"
162
+ logs_buffer.append(f"Streaming error: {e}")
163
+
164
+ return (status_val, deployment_id, "\n".join(logs_buffer))
165
+
166
+
167
+ def _follow_deployment(client: XenfraClient, deployment_id: str):
168
+ """
169
+ Polls logs and status in real-time until completion with CI/CD style output.
170
+ (LEGACY - Used for backward compatibility)
171
+ """
172
+ console.print(Panel(
173
+ f"[bold cyan]Deployment ID:[/bold cyan] {deployment_id}\n"
174
+ f"[bold cyan]Mode:[/bold cyan] Streaming Real-time Infrastructure Logs",
175
+ title="[bold green]🚀 Deployment Monitor[/bold green]",
176
+ border_style="green"
177
+ ))
178
+
179
+ last_log_len = 0
180
+ status_val = "PENDING"
181
+
182
+ # Use a live display for the progress bar at the bottom
183
+ with Progress(
184
+ SpinnerColumn(),
185
+ TextColumn("[bold blue]{task.description}"),
186
+ BarColumn(bar_width=40),
187
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
188
+ console=console,
189
+ transient=False,
190
+ ) as progress:
191
+ task = progress.add_task("Waiting for server response...", total=100)
192
+
193
+ while status_val not in ["SUCCESS", "FAILED", "CANCELLED"]:
194
+ try:
195
+ # 1. Update Status
196
+ dep_status = client.deployments.get_status(deployment_id)
197
+ status_val = dep_status.get("status", "PENDING")
198
+ progress_val = dep_status.get("progress", 0)
199
+ state = dep_status.get("state", "preparing")
200
+
201
+ # Use a more descriptive description for the progress task
202
+ desc = f"Phase: {state}"
203
+ if status_val == "FAILED":
204
+ desc = "[bold red]FAILED[/bold red]"
205
+ elif status_val == "SUCCESS":
206
+ desc = "[bold green]SUCCESS[/bold green]"
207
+
208
+ progress.update(task, completed=progress_val, description=desc)
209
+
210
+ # 2. Update Logs
211
+ log_content = client.deployments.get_logs(deployment_id)
212
+ if log_content and len(log_content) > last_log_len:
213
+ new_logs = log_content[last_log_len:].strip()
214
+ for line in new_logs.split("\n"):
215
+ # Process and colorize lines
216
+ clean_line = line.strip()
217
+ if not clean_line:
218
+ continue
219
+
220
+ if any(x in clean_line for x in ["ERROR", "FAILED", ""]):
221
+ progress.console.print(f"[bold red]{clean_line}[/bold red]")
222
+ elif any(x in clean_line for x in ["WARN", "WARNING", "⚠"]):
223
+ progress.console.print(f"[yellow]{clean_line}[/yellow]")
224
+ elif any(x in clean_line for x in ["SUCCESS", "COMPLETED", "✓", "passed!"]):
225
+ progress.console.print(f"[bold green]{clean_line}[/bold green]")
226
+ elif "PHASE" in clean_line:
227
+ progress.console.print(f"\n[bold blue]{clean_line}[/bold blue]")
228
+ elif "[InfraEngine]" in clean_line:
229
+ progress.console.print(f"[dim]{clean_line}[/dim]")
230
+ else:
231
+ progress.console.print(f"[cyan]›[/cyan] {clean_line}")
232
+
233
+ last_log_len = len(log_content)
234
+
235
+ if status_val in ["SUCCESS", "FAILED", "CANCELLED"]:
236
+ break
237
+
238
+ time.sleep(1.5) # Slightly faster polling for better feel
239
+ except Exception as e:
240
+ # progress.console.print(f"[dim]Transient connection issue: {e}[/dim]")
241
+ time.sleep(3)
242
+ continue
243
+
244
+ console.print()
245
+ if status_val == "SUCCESS":
246
+ console.print("[bold green]✨ SUCCESS: Your application is live![/bold green]")
247
+ # Try to get the IP address
248
+ try:
249
+ final_status = client.deployments.get_status(deployment_id)
250
+ ip = final_status.get("ip_address")
251
+ if ip:
252
+ console.print(f"[bold]Accessible at:[/bold] [link=http://{ip}]http://{ip}[/link]")
253
+ except:
254
+ pass
255
+ elif status_val == "FAILED":
256
+ console.print("\n[bold red]❌ FAILURE DETECTED: Entering AI Diagnosis Mode...[/bold red]")
257
+
258
+ return status_val
259
+
260
+
261
+ def zen_nod_workflow(
262
+ logs: str,
263
+ client: XenfraClient,
264
+ attempt: int,
265
+ package_manager: str = None,
266
+ dependency_file: str = None,
267
+ services: list = None
268
+ ) -> bool:
269
+ """
270
+ Execute the Zen Nod auto-healing workflow.
271
+
272
+ Args:
273
+ logs: Deployment error logs
274
+ client: Authenticated SDK client
275
+ attempt: Current attempt number
276
+ package_manager: Project package manager
277
+ dependency_file: Project dependency file
278
+ services: List of services in the project (for multi-service context)
279
+
280
+ Returns:
281
+ True if patch was applied and user wants to retry, False otherwise
282
+ """
283
+ console.print()
284
+ console.print(f"[cyan]🤖 Analyzing failure (attempt {attempt}/{MAX_RETRY_ATTEMPTS})...[/cyan]")
285
+
286
+ # Slice logs to last 300 lines for focused diagnosis (Fix #26)
287
+ log_lines = logs.split("\n")
288
+ if len(log_lines) > 300:
289
+ logs = "\n".join(log_lines[-300:])
290
+ console.print("[dim]Note: Analyzing only the last 300 lines of logs for efficiency.[/dim]")
291
+
292
+ # Scrub sensitive data from logs
293
+ scrubbed_logs = scrub_logs(logs)
294
+
295
+ # Diagnose with AI
296
+ try:
297
+ diagnosis_result = client.intelligence.diagnose(
298
+ logs=scrubbed_logs,
299
+ package_manager=package_manager,
300
+ dependency_file=dependency_file,
301
+ services=services
302
+ )
303
+ except Exception as e:
304
+ console.print(f"[yellow]Could not diagnose failure: {e}[/yellow]")
305
+ return False
306
+
307
+ # Show diagnosis
308
+ show_diagnosis_panel(diagnosis_result.diagnosis, diagnosis_result.suggestion)
309
+
310
+ # Check if there's an automatic patch
311
+ if diagnosis_result.patch and diagnosis_result.patch.file:
312
+ show_patch_preview(diagnosis_result.patch.model_dump())
313
+
314
+ # Zen Nod confirmation
315
+ if click.confirm("Apply this fix and retry deployment?", default=True):
316
+ try:
317
+ # Apply patch (with automatic backup)
318
+ backup_path = apply_patch(diagnosis_result.patch.model_dump())
319
+ console.print("[bold green]✓ Patch applied[/bold green]")
320
+ if backup_path:
321
+ console.print(f"[dim]Backup saved: {backup_path}[/dim]")
322
+ return True # Signal to retry
323
+ except Exception as e:
324
+ console.print(f"[bold red]Failed to apply patch: {e}[/bold red]")
325
+ return False
326
+ else:
327
+ console.print()
328
+ console.print("[yellow]❌ Patch declined. Follow the manual steps above.[/yellow]")
329
+ return False
330
+ else:
331
+ console.print()
332
+ console.print(
333
+ "[yellow]No automatic fix available. Please follow the manual steps above.[/yellow]"
334
+ )
335
+ return False
336
+
337
+
338
+ @click.command()
339
+ @click.argument("project_id", type=int)
340
+ @click.option("--show-details", is_flag=True, help="Show project details before confirmation")
341
+ @click.confirmation_option(prompt="Are you sure you want to delete this project and its infrastructure?")
342
+ def delete(project_id, show_details):
343
+ """
344
+ Delete a project and its infrastructure.
345
+
346
+ This command will:
347
+ - Destroy the DigitalOcean droplet
348
+ - Remove database records
349
+
350
+ WARNING: This action cannot be undone.
351
+ """
352
+ # Validate
353
+ is_valid, error_msg = validate_project_id(project_id)
354
+ if not is_valid:
355
+ console.print(f"[bold red]Invalid project ID: {error_msg}[/bold red]")
356
+ raise click.Abort()
357
+
358
+ try:
359
+ with get_client() as client:
360
+ # Optional: Show details
361
+ if show_details:
362
+ try:
363
+ project = client.projects.show(project_id)
364
+
365
+ # Display panel with project info
366
+ details_table = Table(show_header=False, box=None)
367
+ details_table.add_column("Property", style="cyan")
368
+ details_table.add_column("Value")
369
+
370
+ details_table.add_row("Project ID", str(project_id))
371
+ if hasattr(project, 'name'):
372
+ details_table.add_row("Name", project.name)
373
+ if hasattr(project, 'droplet_id'):
374
+ details_table.add_row("Droplet ID", str(project.droplet_id))
375
+ if hasattr(project, 'ip_address'):
376
+ details_table.add_row("IP Address", project.ip_address)
377
+ if hasattr(project, 'created_at'):
378
+ details_table.add_row("Created", str(project.created_at))
379
+
380
+ panel = Panel(details_table, title="[bold]Project Details[/bold]", border_style="yellow")
381
+ console.print(panel)
382
+ console.print()
383
+ except XenfraAPIError as e:
384
+ if e.status_code == 404:
385
+ console.print(f"[yellow]Note: Project {project_id} not found in records.[/yellow]")
386
+ else:
387
+ console.print(f"[yellow]Warning: Could not fetch project details: {e.detail}[/yellow]")
388
+ console.print()
389
+
390
+ # Delete
391
+ console.print(f"[cyan]Deleting project {project_id}...[/cyan]")
392
+ client.projects.delete(str(project_id))
393
+ console.print(f"[bold green]✓ Project {project_id} deleted successfully.[/bold green]")
394
+ console.print("[dim]The droplet has been destroyed and all records removed.[/dim]")
395
+
396
+ except XenfraAPIError as e:
397
+ if e.status_code == 404:
398
+ console.print(f"[yellow]Project {project_id} not found. It may have already been deleted.[/yellow]")
399
+ else:
400
+ console.print(f"[bold red]API Error: {e.detail}[/bold red]")
401
+ raise click.Abort()
402
+ except XenfraError as e:
403
+ console.print(f"[bold red]Error: {e}[/bold red]")
404
+ raise click.Abort()
405
+ except click.Abort:
406
+ console.print("[dim]Deletion cancelled.[/dim]")
407
+ raise
408
+ except Exception as e:
409
+ console.print(f"[bold red]Unexpected error: {e}[/bold red]")
410
+ raise click.Abort()
411
+
412
+
413
+ @click.command()
414
+ @click.option("--project-name", help="Project name (defaults to current directory name)")
415
+ @click.option("--git-repo", help="Git repository URL (if deploying from git)")
416
+ @click.option("--branch", default="main", help="Git branch (default: main)")
417
+ @click.option("--framework", help="Framework override (fastapi, flask, django)")
418
+ @click.option("--region", help="DigitalOcean region override")
419
+ @click.option("--size", help="DigitalOcean size slug override")
420
+ @click.option("--no-heal", is_flag=True, help="Disable auto-healing on failure")
421
+ @click.option("--cleanup-on-failure", is_flag=True, help="Automatically cleanup resources if deployment fails")
422
+ def deploy(project_name, git_repo, branch, framework, region, size, no_heal, cleanup_on_failure):
423
+ """
424
+ Deploy current project to DigitalOcean with auto-healing.
425
+
426
+ Deploys your application with zero configuration. The CLI will:
427
+ 1. Check for xenfra.yaml (or run init if missing)
428
+ 2. Create a deployment
429
+ 3. Auto-diagnose and fix failures (unless --no-heal is set or XENFRA_NO_AI=1)
430
+
431
+ Set XENFRA_NO_AI=1 environment variable to disable all AI features.
432
+ """
433
+ # Check XENFRA_NO_AI environment variable
434
+ no_ai = os.environ.get("XENFRA_NO_AI", "0") == "1"
435
+ if no_ai:
436
+ console.print("[yellow]XENFRA_NO_AI is set. Auto-healing disabled.[/yellow]")
437
+ no_heal = True
438
+
439
+ # Check for xenfra.yaml
440
+ if not has_xenfra_config():
441
+ console.print("[yellow]No xenfra.yaml found.[/yellow]")
442
+ if click.confirm("Run 'xenfra init' to create configuration?", default=True):
443
+ from .intelligence import init
444
+
445
+ ctx = click.get_current_context()
446
+ ctx.invoke(init, manual=no_ai, accept_all=False)
447
+ else:
448
+ console.print("[dim]Deployment cancelled.[/dim]")
449
+ return
450
+
451
+ # Load configuration from xenfra.yaml if it exists
452
+ config = {}
453
+ if has_xenfra_config():
454
+ try:
455
+ config = read_xenfra_yaml()
456
+ except Exception as e:
457
+ console.print(f"[yellow]Warning: Could not read xenfra.yaml: {e}[/dim]")
458
+
459
+ # Resolve values with precedence: 1. CLI Flag, 2. xenfra.yaml, 3. Default
460
+ project_name = project_name or config.get("name") or os.path.basename(os.getcwd())
461
+ framework = framework or config.get("framework")
462
+ # Track if is_dockerized was explicitly set in config (to avoid AI override)
463
+ is_dockerized_from_config = "is_dockerized" in config
464
+ is_dockerized = config.get("is_dockerized", True)
465
+ region = region or config.get("region") or "nyc3"
466
+
467
+ # Resolve size slug (complex mapping)
468
+ if not size:
469
+ if config.get("size"):
470
+ size = config.get("size")
471
+ else:
472
+ instance_size = config.get("instance_size", "basic")
473
+ resources = config.get("resources", {})
474
+ cpu = resources.get("cpu", 1)
475
+
476
+ if instance_size == "standard" or cpu >= 2:
477
+ size = "s-2vcpu-4gb"
478
+ elif instance_size == "premium" or cpu >= 4:
479
+ size = "s-4vcpu-8gb"
480
+ else:
481
+ size = "s-1vcpu-1gb"
482
+
483
+ # Extract port, command, database from config
484
+ # Track if port was explicitly set to avoid AI override
485
+ port_from_config = config.get("port")
486
+ port = port_from_config or 8000
487
+ command = config.get("command") # Auto-detected if not provided
488
+ entrypoint = config.get("entrypoint") # e.g., "todo.main:app"
489
+ database_config = config.get("database", {})
490
+ database = database_config.get("type") if isinstance(database_config, dict) else None
491
+ package_manager = config.get("package_manager", "pip")
492
+ dependency_file = config.get("dependency_file", "requirements.txt")
493
+
494
+ # Microservices support: extract services and mode from xenfra.yaml
495
+ services = config.get("services") # List of service definitions
496
+ mode = config.get("mode", "monolithic") # monolithic, single-droplet, multi-droplet
497
+
498
+ # If services are defined and > 1, this is a microservices deployment
499
+ if services and len(services) > 1:
500
+ console.print(f"\n[bold cyan]🔍 Detected microservices project ({len(services)} services)[/bold cyan]")
501
+
502
+ # Display services table
503
+ from rich.table import Table
504
+ svc_table = Table(show_header=True, header_style="bold cyan", box=None)
505
+ svc_table.add_column("Service", style="white")
506
+ svc_table.add_column("Port", style="green")
507
+ svc_table.add_column("Framework", style="yellow")
508
+
509
+ for svc in services:
510
+ svc_table.add_row(
511
+ svc.get("name", "?"),
512
+ str(svc.get("port", "?")),
513
+ svc.get("framework", "?")
514
+ )
515
+
516
+ console.print(svc_table)
517
+ console.print()
518
+
519
+ # If mode not explicitly set in config, ask user
520
+ if mode == "monolithic" or mode not in ["single-droplet", "multi-droplet"]:
521
+ console.print("[bold]Choose deployment mode:[/bold]")
522
+ console.print(" [cyan]1.[/cyan] Single Droplet - All services on one machine [dim](cost-effective)[/dim]")
523
+ console.print(" [cyan]2.[/cyan] Multi Droplet - Each service on its own machine [dim](scalable)[/dim]")
524
+ console.print()
525
+
526
+ mode_choice = Prompt.ask(
527
+ "Deployment mode",
528
+ choices=["1", "2"],
529
+ default="1"
530
+ )
531
+
532
+ if mode_choice == "1":
533
+ mode = "single-droplet"
534
+ console.print("[green]✓ Using single-droplet mode[/green]\n")
535
+ else:
536
+ mode = "multi-droplet"
537
+ console.print("[green]✓ Using multi-droplet mode[/green]")
538
+ console.print(f"[dim]This will create {len(services)} separate droplets[/dim]\n")
539
+ else:
540
+ console.print(f"[dim]Using configured mode: {mode}[/dim]\n")
541
+
542
+ # Default project name to current directory
543
+ if not project_name:
544
+ project_name = os.path.basename(os.getcwd())
545
+
546
+ # Validate project name
547
+ is_valid, error_msg = validate_project_name(project_name)
548
+ if not is_valid:
549
+ console.print(f"[bold red]Invalid project name: {error_msg}[/bold red]")
550
+ raise click.Abort()
551
+
552
+ # Validate git repo if provided
553
+ if git_repo:
554
+ is_valid, error_msg = validate_git_repo_url(git_repo)
555
+ if not is_valid:
556
+ console.print(f"[bold red]Invalid git repository URL: {error_msg}[/bold red]")
557
+ raise click.Abort()
558
+ console.print(f"[cyan]Deploying {project_name} from git repository...[/cyan]")
559
+ console.print(f"[dim]Repository: {git_repo} (branch: {branch})[/dim]")
560
+ else:
561
+ # Note: Local folder deployment only works when engine runs locally
562
+ # In cloud API mode, this will fail with a clear error from the server
563
+ console.print(f"[cyan]Deploying {project_name}...[/cyan]")
564
+ console.print("[dim]Note: Git repository recommended for cloud deployments[/dim]")
565
+
566
+ # Validate branch name
567
+ is_valid, error_msg = validate_branch_name(branch)
568
+ if not is_valid:
569
+ console.print(f"[bold red]Invalid branch name: {error_msg}[/bold red]")
570
+ raise click.Abort()
571
+
572
+ # Validate framework if provided
573
+ if framework:
574
+ is_valid, error_msg = validate_framework(framework)
575
+ if not is_valid:
576
+ console.print(f"[bold red]Invalid framework: {error_msg}[/bold red]")
577
+ raise click.Abort()
578
+
579
+ # Retry loop for auto-healing
580
+ attempt = 0
581
+ deployment_id = None
582
+
583
+ try:
584
+ with get_client() as client:
585
+ while attempt < MAX_RETRY_ATTEMPTS:
586
+ attempt += 1
587
+
588
+ if attempt > 1:
589
+ console.print(
590
+ f"\n[cyan]🔄 Retrying deployment (attempt {attempt}/{MAX_RETRY_ATTEMPTS})...[/cyan]"
591
+ )
592
+ else:
593
+ console.print("[cyan]Creating deployment...[/cyan]")
594
+
595
+ # Detect framework if not provided (AI-powered Zen Mode)
596
+ if not framework:
597
+ console.print("[cyan]🔍 AI Auto-detecting project type...[/cyan]")
598
+ try:
599
+ from ..utils.codebase import scan_codebase
600
+ code_snippets = scan_codebase()
601
+ if code_snippets:
602
+ analysis = client.intelligence.analyze_codebase(code_snippets)
603
+ framework = analysis.framework
604
+ # Only use AI's is_dockerized if config didn't explicitly set it
605
+ if not is_dockerized_from_config:
606
+ is_dockerized = analysis.is_dockerized
607
+ # Override port if AI detected it and config didn't set one
608
+ if not port_from_config and analysis.port:
609
+ port = analysis.port
610
+ # Override port and size if AI has strong recommendations
611
+ if not size and analysis.instance_size:
612
+ size = "s-1vcpu-1gb" if analysis.instance_size == "basic" else "s-2vcpu-4gb"
613
+
614
+ mode_str = "Docker" if is_dockerized else "Bare Metal"
615
+ console.print(f"[green]✓ Detected {framework.upper()} project ({mode_str} Mode)[/green] (Port: {port})")
616
+ else:
617
+ console.print("[yellow]⚠ No code files found for AI analysis. Defaulting to 'fastapi'[/yellow]")
618
+ framework = "fastapi"
619
+ is_dockerized = True
620
+ except Exception as e:
621
+ console.print(f"[yellow]⚠ AI detection failed: {e}. Defaulting to 'fastapi'[/yellow]")
622
+ framework = "fastapi"
623
+ is_dockerized = True
624
+
625
+ # Delta upload: if no git_repo, scan and upload local files
626
+ file_manifest = None
627
+ if not git_repo:
628
+ from ..utils.file_sync import scan_project_files_cached, ensure_gitignore_ignored
629
+
630
+ # Protect privacy: ensure .xenfra is in .gitignore
631
+ if ensure_gitignore_ignored():
632
+ console.print("[dim] - Added .xenfra to .gitignore for privacy[/dim]")
633
+
634
+ console.print("[cyan]📁 Scanning project files...[/cyan]")
635
+
636
+ file_manifest = scan_project_files_cached()
637
+ console.print(f"[dim]Found {len(file_manifest)} files[/dim]")
638
+
639
+ if not file_manifest:
640
+ console.print("[bold red]Error: No files found to deploy.[/bold red]")
641
+ raise click.Abort()
642
+
643
+ # Check which files need uploading
644
+ console.print("[cyan]🔍 Checking file cache...[/cyan]")
645
+ check_result = client.files.check(file_manifest)
646
+ missing = check_result.get('missing', [])
647
+ cached = check_result.get('cached', 0)
648
+
649
+ if cached > 0:
650
+ console.print(f"[green]✓ {cached} files already cached[/green]")
651
+
652
+ # Upload missing files
653
+ if missing:
654
+ console.print(f"[cyan]☁️ Uploading {len(missing)} files...[/cyan]")
655
+ uploaded = client.files.upload_files(
656
+ file_manifest,
657
+ missing,
658
+ progress_callback=lambda done, total: console.print(f"[dim] Progress: {done}/{total}[/dim]") if done % 10 == 0 or done == total else None
659
+ )
660
+ console.print(f"[green]✓ Uploaded {uploaded} files[/green]")
661
+ else:
662
+ console.print("[green]✓ All files already cached[/green]")
663
+
664
+ # Remove abs_path from manifest before sending to API
665
+ file_manifest = [{"path": f["path"], "sha": f["sha"], "size": f["size"]} for f in file_manifest]
666
+
667
+ # Create deployment with real-time streaming
668
+ try:
669
+ status_result, deployment_id, logs_data = _stream_deployment(
670
+ client=client,
671
+ project_name=project_name,
672
+ git_repo=git_repo,
673
+ branch=branch,
674
+ framework=framework,
675
+ region=region,
676
+ size_slug=size,
677
+ is_dockerized=is_dockerized,
678
+ port=port,
679
+ command=command,
680
+ entrypoint=entrypoint, # Pass entrypoint to deployment API
681
+ database=database,
682
+ package_manager=package_manager,
683
+ dependency_file=dependency_file,
684
+ file_manifest=file_manifest,
685
+ cleanup_on_failure=cleanup_on_failure,
686
+ services=services, # Microservices support
687
+ mode=mode, # Deployment mode
688
+ )
689
+
690
+
691
+ if status_result == "FAILED" and not no_heal:
692
+ # Hand off to the Zen Nod AI Agent
693
+ should_retry = zen_nod_workflow(
694
+ logs_data,
695
+ client,
696
+ attempt,
697
+ package_manager=package_manager,
698
+ dependency_file=dependency_file,
699
+ services=services
700
+ )
701
+
702
+ if should_retry:
703
+ # The agent applied a fix, loop back for attempt + 1
704
+ continue
705
+ else:
706
+ # Agent couldn't fix it or user declined
707
+ raise click.Abort()
708
+
709
+ # If we got here with success, break the retry loop
710
+ if status_result == "SUCCESS":
711
+ break
712
+ else:
713
+ raise click.Abort()
714
+
715
+ except XenfraAPIError as e:
716
+ # Deployment failed - try to provide helpful error
717
+ from ..utils.errors import detect_error_type, show_error_with_solution
718
+
719
+ console.print(f"[bold red]✗ Deployment failed[/bold red]")
720
+
721
+ # Try to detect error type and show solution
722
+ error_type, error_kwargs = detect_error_type(str(e.detail))
723
+ if error_type:
724
+ show_error_with_solution(error_type, **error_kwargs)
725
+ else:
726
+ console.print(f"[red]{e.detail}[/red]")
727
+
728
+ # Check if we should auto-heal
729
+ if no_heal or attempt >= MAX_RETRY_ATTEMPTS:
730
+ # No auto-healing or max retries reached
731
+ if attempt >= MAX_RETRY_ATTEMPTS:
732
+ console.print(
733
+ f"\n[bold red]❌ Maximum retry attempts ({MAX_RETRY_ATTEMPTS}) reached.[/bold red]"
734
+ )
735
+ console.print(
736
+ "[yellow]Unable to auto-fix the issue. Please review the errors above.[/yellow]"
737
+ )
738
+ raise
739
+ else:
740
+ # Try to get logs for diagnosis
741
+ error_logs = str(e.detail)
742
+ try:
743
+ if deployment_id:
744
+ # This should be a method in the SDK that returns a string
745
+ logs_response = client.deployments.get_logs(deployment_id)
746
+ if isinstance(logs_response, dict):
747
+ error_logs = logs_response.get("logs", str(e.detail))
748
+ else:
749
+ error_logs = str(logs_response) # Assuming it can be a string
750
+ except Exception as log_err:
751
+ console.print(
752
+ f"[yellow]Warning: Could not fetch detailed logs for diagnosis: {log_err}[/yellow]"
753
+ )
754
+ # Fallback to the initial error detail
755
+ pass
756
+
757
+ # Run Zen Nod workflow
758
+ should_retry = zen_nod_workflow(
759
+ error_logs,
760
+ client,
761
+ attempt,
762
+ package_manager=package_manager,
763
+ dependency_file=dependency_file,
764
+ services=services
765
+ )
766
+
767
+ if not should_retry:
768
+ # User declined patch or no patch available
769
+ console.print("\n[dim]Deployment cancelled.[/dim]")
770
+ raise click.Abort()
771
+
772
+ # Continue to next iteration (retry)
773
+ continue
774
+
775
+ except XenfraAPIError as e:
776
+ console.print(f"[bold red]API Error: {e.detail}[/bold red]")
777
+ except XenfraError as e:
778
+ console.print(f"[bold red]Error: {e}[/bold red]")
779
+ except click.Abort:
780
+ pass
781
+ except Exception as e:
782
+ console.print(f"[bold red]Unexpected error: {e}[/bold red]")
783
+
784
+
785
+ @click.command()
786
+ @click.argument("deployment-id")
787
+ @click.option("--follow", "-f", is_flag=True, help="Follow log output (stream)")
788
+ @click.option("--tail", type=int, help="Show last N lines")
789
+ def logs(deployment_id, follow, tail):
790
+ # Validate deployment ID
791
+ is_valid, error_msg = validate_deployment_id(deployment_id)
792
+ if not is_valid:
793
+ console.print(f"[bold red]Invalid deployment ID: {error_msg}[/bold red]")
794
+ raise click.Abort()
795
+ """
796
+ Stream deployment logs.
797
+
798
+ Shows logs for a specific deployment. Use --follow to stream logs in real-time.
799
+ """
800
+ try:
801
+ with get_client() as client:
802
+ console.print(f"[cyan]Fetching logs for deployment {deployment_id}...[/cyan]")
803
+
804
+ log_content = client.deployments.get_logs(deployment_id)
805
+
806
+ if not log_content:
807
+ console.print("[yellow]No logs available yet.[/yellow]")
808
+ console.print("[dim]The deployment may still be starting up.[/dim]")
809
+ return
810
+
811
+ # Process logs
812
+ log_lines = log_content.strip().split("\n")
813
+
814
+ # Apply tail if specified
815
+ if tail:
816
+ log_lines = log_lines[-tail:]
817
+
818
+ # Display logs with syntax highlighting
819
+ console.print(f"\n[bold]Logs for deployment {deployment_id}:[/bold]\n")
820
+
821
+ if follow:
822
+ _follow_deployment(client, deployment_id)
823
+ return
824
+
825
+ # Display logs
826
+ for line in log_lines:
827
+ # Color-code based on log level
828
+ if "ERROR" in line or "FAILED" in line:
829
+ console.print(f"[red]{line}[/red]")
830
+ elif "WARN" in line or "WARNING" in line:
831
+ console.print(f"[yellow]{line}[/yellow]")
832
+ elif "SUCCESS" in line or "COMPLETED" in line:
833
+ console.print(f"[green]{line}[/green]")
834
+ elif "INFO" in line:
835
+ console.print(f"[cyan]{line}[/cyan]")
836
+ else:
837
+ console.print(line)
838
+
839
+ except XenfraAPIError as e:
840
+ console.print(f"[bold red]API Error: {e.detail}[/bold red]")
841
+ except XenfraError as e:
842
+ console.print(f"[bold red]Error: {e}[/bold red]")
843
+ except click.Abort:
844
+ pass
845
+
846
+
847
+ @click.command()
848
+ @click.argument("deployment-id", required=False)
849
+ @click.option("--watch", "-w", is_flag=True, help="Watch status updates")
850
+ def status(deployment_id, watch):
851
+ """
852
+ Show deployment status.
853
+
854
+ Displays current status, progress, and details for a deployment.
855
+ Use --watch to monitor status in real-time.
856
+ """
857
+ try:
858
+ if not deployment_id:
859
+ console.print("[yellow]No deployment ID provided.[/yellow]")
860
+ console.print("[dim]Usage: xenfra status <deployment-id>[/dim]")
861
+ return
862
+
863
+ # Validate deployment ID
864
+ is_valid, error_msg = validate_deployment_id(deployment_id)
865
+ if not is_valid:
866
+ console.print(f"[bold red]Invalid deployment ID: {error_msg}[/bold red]")
867
+ raise click.Abort()
868
+
869
+ with get_client() as client:
870
+ console.print(f"[cyan]Fetching status for deployment {deployment_id}...[/cyan]")
871
+
872
+ deployment_status = client.deployments.get_status(deployment_id)
873
+
874
+ if watch:
875
+ _follow_deployment(client, deployment_id)
876
+ return
877
+
878
+ # Display status
879
+ status_value = deployment_status.get("status", "UNKNOWN")
880
+ state = deployment_status.get("state", "unknown")
881
+ progress = deployment_status.get("progress", 0)
882
+
883
+ # Status panel
884
+ status_color = {
885
+ "PENDING": "yellow",
886
+ "IN_PROGRESS": "cyan",
887
+ "SUCCESS": "green",
888
+ "FAILED": "red",
889
+ "CANCELLED": "dim",
890
+ }.get(status_value, "white")
891
+
892
+ # Create status table
893
+ table = Table(show_header=False, box=None)
894
+ table.add_column("Property", style="cyan")
895
+ table.add_column("Value")
896
+
897
+ table.add_row("Deployment ID", str(deployment_id))
898
+ table.add_row("Status", f"[{status_color}]{status_value}[/{status_color}]")
899
+ table.add_row("State", state)
900
+
901
+ if progress > 0:
902
+ table.add_row("Progress", f"{progress}%")
903
+
904
+ if "project_name" in deployment_status:
905
+ table.add_row("Project", deployment_status["project_name"])
906
+
907
+ if "created_at" in deployment_status:
908
+ table.add_row("Created", deployment_status["created_at"])
909
+
910
+ if "finished_at" in deployment_status:
911
+ table.add_row("Finished", deployment_status["finished_at"])
912
+
913
+ if "url" in deployment_status:
914
+ table.add_row("URL", f"[link]{deployment_status['url']}[/link]")
915
+
916
+ if "ip_address" in deployment_status:
917
+ table.add_row("IP Address", deployment_status["ip_address"])
918
+
919
+ panel = Panel(table, title="[bold]Deployment Status[/bold]", border_style=status_color)
920
+ console.print(panel)
921
+
922
+ # Show error if failed
923
+ if status_value == "FAILED" and "error" in deployment_status:
924
+ error_panel = Panel(
925
+ deployment_status["error"],
926
+ title="[bold red]Error[/bold red]",
927
+ border_style="red",
928
+ )
929
+ console.print("\n", error_panel)
930
+
931
+ console.print("\n[bold]Troubleshooting:[/bold]")
932
+ console.print(f" View logs: [cyan]xenfra logs {deployment_id}[/cyan]")
933
+ console.print(f" • Diagnose: [cyan]xenfra diagnose {deployment_id}[/cyan]")
934
+
935
+ # Show next steps based on status
936
+ elif status_value == "SUCCESS":
937
+ console.print("\n[bold green]Deployment successful! 🎉[/bold green]")
938
+ if "url" in deployment_status:
939
+ console.print(f" • Visit: [link]{deployment_status['url']}[/link]")
940
+
941
+ elif status_value in ["PENDING", "IN_PROGRESS"]:
942
+ console.print("\n[bold]Deployment in progress...[/bold]")
943
+ console.print(f" • View logs: [cyan]xenfra logs {deployment_id}[/cyan]")
944
+ console.print(f" • Check again: [cyan]xenfra status {deployment_id}[/cyan]")
945
+
946
+ except XenfraAPIError as e:
947
+ console.print(f"[bold red]API Error: {e.detail}[/bold red]")
948
+ except XenfraError as e:
949
+ console.print(f"[bold red]Error: {e}[/bold red]")
950
+ except click.Abort:
951
+ pass
952
+
953
+
954
+ @click.command()
955
+ @click.argument("deployment-id")
956
+ @click.option("--format", "output_format", type=click.Choice(["detailed", "summary"], case_sensitive=False), default="detailed", help="Report format (detailed or summary)")
957
+ def report(deployment_id, output_format):
958
+ """
959
+ Generate deployment report with self-healing events.
960
+
961
+ Shows comprehensive deployment information including:
962
+ - Deployment status and timeline
963
+ - Self-healing attempts and outcomes
964
+ - Patches applied during healing
965
+ - Statistics and metrics
966
+ """
967
+ try:
968
+ # Validate deployment ID
969
+ is_valid, error_msg = validate_deployment_id(deployment_id)
970
+ if not is_valid:
971
+ console.print(f"[bold red]Invalid deployment ID: {error_msg}[/bold red]")
972
+ raise click.Abort()
973
+
974
+ with get_client() as client:
975
+ console.print(f"[cyan]Generating report for deployment {deployment_id}...[/cyan]\n")
976
+
977
+ # Get deployment status
978
+ try:
979
+ deployment_status = client.deployments.get_status(deployment_id)
980
+ except XenfraAPIError as e:
981
+ console.print(f"[bold red]Error fetching deployment status: {e.detail}[/bold red]")
982
+ raise click.Abort()
983
+
984
+ # Get deployment logs
985
+ try:
986
+ logs = client.deployments.get_logs(deployment_id)
987
+ except XenfraAPIError:
988
+ logs = None
989
+
990
+ # Parse status
991
+ status_value = deployment_status.get("status", "UNKNOWN")
992
+ state = deployment_status.get("state", "unknown")
993
+ progress = deployment_status.get("progress", 0)
994
+
995
+ # Status color mapping
996
+ status_color = {
997
+ "PENDING": "yellow",
998
+ "IN_PROGRESS": "cyan",
999
+ "SUCCESS": "green",
1000
+ "FAILED": "red",
1001
+ "CANCELLED": "dim",
1002
+ }.get(status_value, "white")
1003
+
1004
+ # Calculate statistics from logs
1005
+ heal_attempts = logs.count("🤖 Analyzing failure") if logs else 0
1006
+ patches_applied = logs.count("✓ Patch applied") if logs else 0
1007
+ diagnoses = logs.count("🔍 Diagnosis") if logs else 0
1008
+
1009
+ # Create main report table
1010
+ report_table = Table(show_header=True, box=None)
1011
+ report_table.add_column("Property", style="cyan", width=25)
1012
+ report_table.add_column("Value", style="white")
1013
+
1014
+ report_table.add_row("Deployment ID", str(deployment_id))
1015
+ report_table.add_row("Status", f"[{status_color}]{status_value}[/{status_color}]")
1016
+ report_table.add_row("State", state)
1017
+
1018
+ if progress > 0:
1019
+ report_table.add_row("Progress", f"{progress}%")
1020
+
1021
+ if "project_name" in deployment_status:
1022
+ report_table.add_row("Project", deployment_status["project_name"])
1023
+
1024
+ if "created_at" in deployment_status:
1025
+ report_table.add_row("Created", deployment_status["created_at"])
1026
+
1027
+ if "finished_at" in deployment_status:
1028
+ report_table.add_row("Finished", deployment_status["finished_at"])
1029
+
1030
+ if "url" in deployment_status:
1031
+ report_table.add_row("URL", f"[link]{deployment_status['url']}[/link]")
1032
+
1033
+ if "ip_address" in deployment_status:
1034
+ report_table.add_row("IP Address", deployment_status["ip_address"])
1035
+
1036
+ # Self-healing statistics
1037
+ report_table.add_row("", "") # Separator
1038
+ report_table.add_row("[bold]Self-Healing Stats[/bold]", "")
1039
+ report_table.add_row("Healing Attempts", str(heal_attempts))
1040
+ report_table.add_row("Patches Applied", str(patches_applied))
1041
+ report_table.add_row("Diagnoses Performed", str(diagnoses))
1042
+
1043
+ if heal_attempts > 0:
1044
+ success_rate = (patches_applied / heal_attempts * 100) if heal_attempts > 0 else 0
1045
+ report_table.add_row("Healing Success Rate", f"{success_rate:.1f}%")
1046
+
1047
+ # Display main report
1048
+ console.print(Panel(report_table, title="[bold]Deployment Report[/bold]", border_style=status_color))
1049
+
1050
+ # Detailed format includes timeline and healing events
1051
+ if output_format == "detailed" and logs:
1052
+ console.print("\n[bold]Self-Healing Timeline[/bold]\n")
1053
+
1054
+ # Extract healing events from logs
1055
+ log_lines = logs.split("\n")
1056
+ timeline_entries = []
1057
+
1058
+ for i, line in enumerate(log_lines):
1059
+ if "🤖 Analyzing failure" in line:
1060
+ attempt_match = None
1061
+ # Try to find attempt number in surrounding lines
1062
+ for j in range(max(0, i-5), min(len(log_lines), i+10)):
1063
+ if "attempt" in log_lines[j].lower():
1064
+ timeline_entries.append(("Healing Attempt", log_lines[j].strip()))
1065
+ break
1066
+ elif "🔍 Diagnosis" in line or "Diagnosis" in line:
1067
+ # Extract diagnosis text from next few lines
1068
+ diagnosis_text = line.strip()
1069
+ if i+1 < len(log_lines) and log_lines[i+1].strip():
1070
+ diagnosis_text += "\n " + log_lines[i+1].strip()[:100]
1071
+ timeline_entries.append(("Diagnosis", diagnosis_text))
1072
+ elif "✓ Patch applied" in line or "Patch applied" in line:
1073
+ timeline_entries.append(("Patch Applied", line.strip()))
1074
+ elif "🔄 Retrying deployment" in line:
1075
+ timeline_entries.append(("Retry", line.strip()))
1076
+
1077
+ if timeline_entries:
1078
+ timeline_table = Table(show_header=True, box=None)
1079
+ timeline_table.add_column("Event", style="cyan", width=20)
1080
+ timeline_table.add_column("Details", style="white")
1081
+
1082
+ for event_type, details in timeline_entries[:20]: # Limit to 20 entries
1083
+ timeline_table.add_row(event_type, details)
1084
+
1085
+ console.print(timeline_table)
1086
+ else:
1087
+ console.print("[dim]No self-healing events detected in logs.[/dim]")
1088
+
1089
+ # Show error if failed
1090
+ if status_value == "FAILED":
1091
+ console.print("\n[bold red]⚠ Deployment Failed[/bold red]")
1092
+ if "error" in deployment_status:
1093
+ error_panel = Panel(
1094
+ deployment_status["error"],
1095
+ title="[bold red]Error Details[/bold red]",
1096
+ border_style="red",
1097
+ )
1098
+ console.print("\n", error_panel)
1099
+
1100
+ console.print("\n[bold]Troubleshooting:[/bold]")
1101
+ console.print(f" • View logs: [cyan]xenfra logs {deployment_id}[/cyan]")
1102
+ console.print(f" • Diagnose: [cyan]xenfra diagnose {deployment_id}[/cyan]")
1103
+ console.print(f" • View status: [cyan]xenfra status {deployment_id}[/cyan]")
1104
+
1105
+ # Success summary
1106
+ elif status_value == "SUCCESS":
1107
+ console.print("\n[bold green]✓ Deployment Successful[/bold green]")
1108
+ if heal_attempts > 0:
1109
+ console.print(f"[dim]Deployment succeeded after {heal_attempts} self-healing attempt(s).[/dim]")
1110
+ if "url" in deployment_status:
1111
+ console.print(f" • Visit: [link]{deployment_status['url']}[/link]")
1112
+
1113
+ # Export summary format (JSON-like structure for programmatic use)
1114
+ if output_format == "summary":
1115
+ console.print("\n[bold]Summary Format:[/bold]")
1116
+ import json
1117
+ summary = {
1118
+ "deployment_id": deployment_id,
1119
+ "status": status_value,
1120
+ "healing_attempts": heal_attempts,
1121
+ "patches_applied": patches_applied,
1122
+ "success": status_value == "SUCCESS",
1123
+ }
1124
+ console.print(f"[dim]{json.dumps(summary, indent=2)}[/dim]")
1125
+
1126
+ except XenfraAPIError as e:
1127
+ console.print(f"[bold red]API Error: {e.detail}[/bold red]")
1128
+ except XenfraError as e:
1129
+ console.print(f"[bold red]Error: {e}[/bold red]")
1130
+ except click.Abort:
1131
+ pass
1132
+ except Exception as e:
1133
+ console.print(f"[bold red]Unexpected error: {e}[/bold red]")