xenfra 0.4.2__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,912 +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, database: str = 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
- database=database,
97
- ):
98
- event_type = event.get("event", "message")
99
- data = event.get("data", "")
100
-
101
- if event_type == "deployment_created":
102
- # Extract deployment ID
103
- if isinstance(data, dict):
104
- deployment_id = data.get("deployment_id")
105
- console.print(f"[bold green]✓[/bold green] Deployment created: [cyan]{deployment_id}[/cyan]\n")
106
-
107
- elif event_type == "log":
108
- # Real-time log output
109
- log_line = str(data)
110
- logs_buffer.append(log_line)
111
-
112
- # Colorize output
113
- if any(x in log_line for x in ["ERROR", "FAILED", "✗"]):
114
- console.print(f"[bold red]{log_line}[/bold red]")
115
- elif any(x in log_line for x in ["WARN", "WARNING", "⚠"]):
116
- console.print(f"[yellow]{log_line}[/yellow]")
117
- elif any(x in log_line for x in ["SUCCESS", "COMPLETED", "✓", "passed!"]):
118
- console.print(f"[bold green]{log_line}[/bold green]")
119
- elif "PHASE" in log_line:
120
- console.print(f"\n[bold blue]{log_line}[/bold blue]")
121
- elif "[InfraEngine]" in log_line or "[INFO]" in log_line:
122
- console.print(f"[cyan]›[/cyan] {log_line}")
123
- else:
124
- console.print(f"[dim]{log_line}[/dim]")
125
-
126
- elif event_type == "error":
127
- error_msg = str(data)
128
- logs_buffer.append(f"ERROR: {error_msg}")
129
- console.print(f"\n[bold red]❌ Error: {error_msg}[/bold red]")
130
- status_val = "FAILED"
131
-
132
- elif event_type == "deployment_complete":
133
- # Final status
134
- if isinstance(data, dict):
135
- status_val = data.get("status", "UNKNOWN")
136
- ip_address = data.get("ip_address")
137
-
138
- console.print()
139
- if status_val == "SUCCESS":
140
- console.print("[bold green]✨ SUCCESS: Your application is live![/bold green]")
141
- if ip_address and ip_address != "unknown":
142
- console.print(f"[bold]Accessible at:[/bold] [link=http://{ip_address}]http://{ip_address}[/link]")
143
- elif status_val == "FAILED":
144
- console.print("[bold red]❌ DEPLOYMENT FAILED[/bold red]")
145
- error = data.get("error")
146
- if error:
147
- console.print(f"[red]Error: {error}[/red]")
148
- break
149
-
150
- except Exception as e:
151
- console.print(f"\n[bold red] Streaming error: {e}[/bold red]")
152
- status_val = "FAILED"
153
- logs_buffer.append(f"Streaming error: {e}")
154
-
155
- return (status_val, deployment_id, "\n".join(logs_buffer))
156
-
157
-
158
- def _follow_deployment(client: XenfraClient, deployment_id: str):
159
- """
160
- Polls logs and status in real-time until completion with CI/CD style output.
161
- (LEGACY - Used for backward compatibility)
162
- """
163
- console.print(Panel(
164
- f"[bold cyan]Deployment ID:[/bold cyan] {deployment_id}\n"
165
- f"[bold cyan]Mode:[/bold cyan] Streaming Real-time Infrastructure Logs",
166
- title="[bold green]🚀 Deployment Monitor[/bold green]",
167
- border_style="green"
168
- ))
169
-
170
- last_log_len = 0
171
- status_val = "PENDING"
172
-
173
- # Use a live display for the progress bar at the bottom
174
- with Progress(
175
- SpinnerColumn(),
176
- TextColumn("[bold blue]{task.description}"),
177
- BarColumn(bar_width=40),
178
- TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
179
- console=console,
180
- transient=False,
181
- ) as progress:
182
- task = progress.add_task("Waiting for server response...", total=100)
183
-
184
- while status_val not in ["SUCCESS", "FAILED", "CANCELLED"]:
185
- try:
186
- # 1. Update Status
187
- dep_status = client.deployments.get_status(deployment_id)
188
- status_val = dep_status.get("status", "PENDING")
189
- progress_val = dep_status.get("progress", 0)
190
- state = dep_status.get("state", "preparing")
191
-
192
- # Use a more descriptive description for the progress task
193
- desc = f"Phase: {state}"
194
- if status_val == "FAILED":
195
- desc = "[bold red]FAILED[/bold red]"
196
- elif status_val == "SUCCESS":
197
- desc = "[bold green]SUCCESS[/bold green]"
198
-
199
- progress.update(task, completed=progress_val, description=desc)
200
-
201
- # 2. Update Logs
202
- log_content = client.deployments.get_logs(deployment_id)
203
- if log_content and len(log_content) > last_log_len:
204
- new_logs = log_content[last_log_len:].strip()
205
- for line in new_logs.split("\n"):
206
- # Process and colorize lines
207
- clean_line = line.strip()
208
- if not clean_line:
209
- continue
210
-
211
- if any(x in clean_line for x in ["ERROR", "FAILED", "✗"]):
212
- progress.console.print(f"[bold red]{clean_line}[/bold red]")
213
- elif any(x in clean_line for x in ["WARN", "WARNING", "⚠"]):
214
- progress.console.print(f"[yellow]{clean_line}[/yellow]")
215
- elif any(x in clean_line for x in ["SUCCESS", "COMPLETED", "✓", "passed!"]):
216
- progress.console.print(f"[bold green]{clean_line}[/bold green]")
217
- elif "PHASE" in clean_line:
218
- progress.console.print(f"\n[bold blue]{clean_line}[/bold blue]")
219
- elif "[InfraEngine]" in clean_line:
220
- progress.console.print(f"[dim]{clean_line}[/dim]")
221
- else:
222
- progress.console.print(f"[cyan]›[/cyan] {clean_line}")
223
-
224
- last_log_len = len(log_content)
225
-
226
- if status_val in ["SUCCESS", "FAILED", "CANCELLED"]:
227
- break
228
-
229
- time.sleep(1.5) # Slightly faster polling for better feel
230
- except Exception as e:
231
- # progress.console.print(f"[dim]Transient connection issue: {e}[/dim]")
232
- time.sleep(3)
233
- continue
234
-
235
- console.print()
236
- if status_val == "SUCCESS":
237
- console.print("[bold green]✨ SUCCESS: Your application is live![/bold green]")
238
- # Try to get the IP address
239
- try:
240
- final_status = client.deployments.get_status(deployment_id)
241
- ip = final_status.get("ip_address")
242
- if ip:
243
- console.print(f"[bold]Accessible at:[/bold] [link=http://{ip}]http://{ip}[/link]")
244
- except:
245
- pass
246
- elif status_val == "FAILED":
247
- console.print("\n[bold red]❌ FAILURE DETECTED: Entering AI Diagnosis Mode...[/bold red]")
248
-
249
- return status_val
250
-
251
-
252
- def zen_nod_workflow(logs: str, client: XenfraClient, attempt: int) -> bool:
253
- """
254
- Execute the Zen Nod auto-healing workflow.
255
-
256
- Args:
257
- logs: Deployment error logs
258
- client: Authenticated SDK client
259
- attempt: Current attempt number
260
-
261
- Returns:
262
- True if patch was applied and user wants to retry, False otherwise
263
- """
264
- console.print()
265
- console.print(f"[cyan]🤖 Analyzing failure (attempt {attempt}/{MAX_RETRY_ATTEMPTS})...[/cyan]")
266
-
267
- # Slice logs to last 300 lines for focused diagnosis (Fix #26)
268
- log_lines = logs.split("\n")
269
- if len(log_lines) > 300:
270
- logs = "\n".join(log_lines[-300:])
271
- console.print("[dim]Note: Analyzing only the last 300 lines of logs for efficiency.[/dim]")
272
-
273
- # Scrub sensitive data from logs
274
- scrubbed_logs = scrub_logs(logs)
275
-
276
- # Diagnose with AI
277
- try:
278
- diagnosis_result = client.intelligence.diagnose(scrubbed_logs)
279
- except Exception as e:
280
- console.print(f"[yellow]Could not diagnose failure: {e}[/yellow]")
281
- return False
282
-
283
- # Show diagnosis
284
- show_diagnosis_panel(diagnosis_result.diagnosis, diagnosis_result.suggestion)
285
-
286
- # Check if there's an automatic patch
287
- if diagnosis_result.patch and diagnosis_result.patch.file:
288
- show_patch_preview(diagnosis_result.patch.model_dump())
289
-
290
- # Zen Nod confirmation
291
- if click.confirm("Apply this fix and retry deployment?", default=True):
292
- try:
293
- # Apply patch (with automatic backup)
294
- backup_path = apply_patch(diagnosis_result.patch.model_dump())
295
- console.print("[bold green]✓ Patch applied[/bold green]")
296
- if backup_path:
297
- console.print(f"[dim]Backup saved: {backup_path}[/dim]")
298
- return True # Signal to retry
299
- except Exception as e:
300
- console.print(f"[bold red]Failed to apply patch: {e}[/bold red]")
301
- return False
302
- else:
303
- console.print()
304
- console.print("[yellow] Patch declined. Follow the manual steps above.[/yellow]")
305
- return False
306
- else:
307
- console.print()
308
- console.print(
309
- "[yellow]No automatic fix available. Please follow the manual steps above.[/yellow]"
310
- )
311
- return False
312
-
313
-
314
- @click.command()
315
- @click.option("--project-name", help="Project name (defaults to current directory name)")
316
- @click.option("--git-repo", help="Git repository URL (if deploying from git)")
317
- @click.option("--branch", default="main", help="Git branch (default: main)")
318
- @click.option("--framework", help="Framework override (fastapi, flask, django)")
319
- @click.option("--region", help="DigitalOcean region override")
320
- @click.option("--size", help="DigitalOcean size slug override")
321
- @click.option("--no-heal", is_flag=True, help="Disable auto-healing on failure")
322
- def deploy(project_name, git_repo, branch, framework, region, size, no_heal):
323
- """
324
- Deploy current project to DigitalOcean with auto-healing.
325
-
326
- Deploys your application with zero configuration. The CLI will:
327
- 1. Check for xenfra.yaml (or run init if missing)
328
- 2. Create a deployment
329
- 3. Auto-diagnose and fix failures (unless --no-heal is set or XENFRA_NO_AI=1)
330
-
331
- Set XENFRA_NO_AI=1 environment variable to disable all AI features.
332
- """
333
- # Check XENFRA_NO_AI environment variable
334
- no_ai = os.environ.get("XENFRA_NO_AI", "0") == "1"
335
- if no_ai:
336
- console.print("[yellow]XENFRA_NO_AI is set. Auto-healing disabled.[/yellow]")
337
- no_heal = True
338
-
339
- # Check for xenfra.yaml
340
- if not has_xenfra_config():
341
- console.print("[yellow]No xenfra.yaml found.[/yellow]")
342
- if click.confirm("Run 'xenfra init' to create configuration?", default=True):
343
- from .intelligence import init
344
-
345
- ctx = click.get_current_context()
346
- ctx.invoke(init, manual=no_ai, accept_all=False)
347
- else:
348
- console.print("[dim]Deployment cancelled.[/dim]")
349
- return
350
-
351
- # Load configuration from xenfra.yaml if it exists
352
- config = {}
353
- if has_xenfra_config():
354
- try:
355
- config = read_xenfra_yaml()
356
- except Exception as e:
357
- console.print(f"[yellow]Warning: Could not read xenfra.yaml: {e}[/dim]")
358
-
359
- # Resolve values with precedence: 1. CLI Flag, 2. xenfra.yaml, 3. Default
360
- project_name = project_name or config.get("name") or os.path.basename(os.getcwd())
361
- framework = framework or config.get("framework")
362
- is_dockerized = config.get("is_dockerized", True)
363
- region = region or config.get("region") or "nyc3"
364
-
365
- # Resolve size slug (complex mapping)
366
- if not size:
367
- if config.get("size"):
368
- size = config.get("size")
369
- else:
370
- instance_size = config.get("instance_size", "basic")
371
- resources = config.get("resources", {})
372
- cpu = resources.get("cpu", 1)
373
-
374
- if instance_size == "standard" or cpu >= 2:
375
- size = "s-2vcpu-4gb"
376
- elif instance_size == "premium" or cpu >= 4:
377
- size = "s-4vcpu-8gb"
378
- else:
379
- size = "s-1vcpu-1gb"
380
-
381
- # Extract port, command, database from config
382
- port = config.get("port", 8000)
383
- command = config.get("command") # Auto-detected if not provided
384
- database_config = config.get("database", {})
385
- database = database_config.get("type") if isinstance(database_config, dict) else None
386
-
387
- # Default project name to current directory
388
- if not project_name:
389
- project_name = os.path.basename(os.getcwd())
390
-
391
- # Validate project name
392
- is_valid, error_msg = validate_project_name(project_name)
393
- if not is_valid:
394
- console.print(f"[bold red]Invalid project name: {error_msg}[/bold red]")
395
- raise click.Abort()
396
-
397
- # Validate git repo if provided
398
- if git_repo:
399
- is_valid, error_msg = validate_git_repo_url(git_repo)
400
- if not is_valid:
401
- console.print(f"[bold red]Invalid git repository URL: {error_msg}[/bold red]")
402
- raise click.Abort()
403
- console.print(f"[cyan]Deploying {project_name} from git repository...[/cyan]")
404
- console.print(f"[dim]Repository: {git_repo} (branch: {branch})[/dim]")
405
- else:
406
- console.print(f"[cyan]Deploying {project_name} from local directory...[/cyan]")
407
- console.print("[dim]Code will be uploaded to the droplet[/dim]")
408
- # Note: SDK's InfraEngine handles local upload via fabric.transfer.Upload()
409
-
410
- # Validate branch name
411
- is_valid, error_msg = validate_branch_name(branch)
412
- if not is_valid:
413
- console.print(f"[bold red]Invalid branch name: {error_msg}[/bold red]")
414
- raise click.Abort()
415
-
416
- # Validate framework if provided
417
- if framework:
418
- is_valid, error_msg = validate_framework(framework)
419
- if not is_valid:
420
- console.print(f"[bold red]Invalid framework: {error_msg}[/bold red]")
421
- raise click.Abort()
422
-
423
- # Retry loop for auto-healing
424
- attempt = 0
425
- deployment_id = None
426
-
427
- try:
428
- with get_client() as client:
429
- while attempt < MAX_RETRY_ATTEMPTS:
430
- # Safety check to prevent infinite loops
431
- if attempt > MAX_RETRY_ATTEMPTS:
432
- raise RuntimeError("Safety break: Retry loop exceeded MAX_RETRY_ATTEMPTS.")
433
-
434
- attempt += 1
435
-
436
- if attempt > 1:
437
- console.print(
438
- f"\n[cyan]🔄 Retrying deployment (attempt {attempt}/{MAX_RETRY_ATTEMPTS})...[/cyan]"
439
- )
440
- else:
441
- console.print("[cyan]Creating deployment...[/cyan]")
442
-
443
- # Detect framework if not provided (AI-powered Zen Mode)
444
- if not framework:
445
- console.print("[cyan]🔍 AI Auto-detecting project type...[/cyan]")
446
- try:
447
- from ..utils.codebase import scan_codebase
448
- code_snippets = scan_codebase()
449
- if code_snippets:
450
- analysis = client.intelligence.analyze_codebase(code_snippets)
451
- framework = analysis.framework
452
- is_dockerized = analysis.is_dockerized
453
- # Override port and size if AI has strong recommendations
454
- if not size and analysis.instance_size:
455
- size = "s-1vcpu-1gb" if analysis.instance_size == "basic" else "s-2vcpu-4gb"
456
-
457
- mode_str = "Docker" if is_dockerized else "Bare Metal"
458
- console.print(f"[green]✓ Detected {framework.upper()} project ({mode_str} Mode)[/green] (Port: {analysis.port})")
459
- else:
460
- console.print("[yellow]⚠ No code files found for AI analysis. Defaulting to 'fastapi'[/yellow]")
461
- framework = "fastapi"
462
- is_dockerized = True
463
- except Exception as e:
464
- console.print(f"[yellow]⚠ AI detection failed: {e}. Defaulting to 'fastapi'[/yellow]")
465
- framework = "fastapi"
466
- is_dockerized = True
467
-
468
- # Create deployment with real-time streaming
469
- try:
470
- status_result, deployment_id, logs_data = _stream_deployment(
471
- client=client,
472
- project_name=project_name,
473
- git_repo=git_repo,
474
- branch=branch,
475
- framework=framework,
476
- region=region,
477
- size_slug=size,
478
- is_dockerized=is_dockerized,
479
- port=port,
480
- command=command,
481
- database=database,
482
- )
483
-
484
- if status_result == "FAILED" and not no_heal:
485
- # Hand off to the Zen Nod AI Agent
486
- should_retry = zen_nod_workflow(logs_data, client, attempt)
487
-
488
- if should_retry:
489
- # The agent applied a fix, loop back for attempt + 1
490
- continue
491
- else:
492
- # Agent couldn't fix it or user declined
493
- raise click.Abort()
494
-
495
- # If we got here with success, break the retry loop
496
- if status_result == "SUCCESS":
497
- break
498
- else:
499
- raise click.Abort()
500
-
501
- except XenfraAPIError as e:
502
- # Deployment failed - try to provide helpful error
503
- from ..utils.errors import detect_error_type, show_error_with_solution
504
-
505
- console.print(f"[bold red]✗ Deployment failed[/bold red]")
506
-
507
- # Try to detect error type and show solution
508
- error_type, error_kwargs = detect_error_type(str(e.detail))
509
- if error_type:
510
- show_error_with_solution(error_type, **error_kwargs)
511
- else:
512
- console.print(f"[red]{e.detail}[/red]")
513
-
514
- # Check if we should auto-heal
515
- if no_heal or attempt >= MAX_RETRY_ATTEMPTS:
516
- # No auto-healing or max retries reached
517
- if attempt >= MAX_RETRY_ATTEMPTS:
518
- console.print(
519
- f"\n[bold red]❌ Maximum retry attempts ({MAX_RETRY_ATTEMPTS}) reached.[/bold red]"
520
- )
521
- console.print(
522
- "[yellow]Unable to auto-fix the issue. Please review the errors above.[/yellow]"
523
- )
524
- raise
525
- else:
526
- # Try to get logs for diagnosis
527
- error_logs = str(e.detail)
528
- try:
529
- if deployment_id:
530
- # This should be a method in the SDK that returns a string
531
- logs_response = client.deployments.get_logs(deployment_id)
532
- if isinstance(logs_response, dict):
533
- error_logs = logs_response.get("logs", str(e.detail))
534
- else:
535
- error_logs = str(logs_response) # Assuming it can be a string
536
- except Exception as log_err:
537
- console.print(
538
- f"[yellow]Warning: Could not fetch detailed logs for diagnosis: {log_err}[/yellow]"
539
- )
540
- # Fallback to the initial error detail
541
- pass
542
-
543
- # Run Zen Nod workflow
544
- should_retry = zen_nod_workflow(error_logs, client, attempt)
545
-
546
- if not should_retry:
547
- # User declined patch or no patch available
548
- console.print("\n[dim]Deployment cancelled.[/dim]")
549
- raise click.Abort()
550
-
551
- # Continue to next iteration (retry)
552
- continue
553
-
554
- except XenfraAPIError as e:
555
- console.print(f"[bold red]API Error: {e.detail}[/bold red]")
556
- except XenfraError as e:
557
- console.print(f"[bold red]Error: {e}[/bold red]")
558
- except click.Abort:
559
- pass
560
- except Exception as e:
561
- console.print(f"[bold red]Unexpected error: {e}[/bold red]")
562
-
563
-
564
- @click.command()
565
- @click.argument("deployment-id")
566
- @click.option("--follow", "-f", is_flag=True, help="Follow log output (stream)")
567
- @click.option("--tail", type=int, help="Show last N lines")
568
- def logs(deployment_id, follow, tail):
569
- # Validate deployment ID
570
- is_valid, error_msg = validate_deployment_id(deployment_id)
571
- if not is_valid:
572
- console.print(f"[bold red]Invalid deployment ID: {error_msg}[/bold red]")
573
- raise click.Abort()
574
- """
575
- Stream deployment logs.
576
-
577
- Shows logs for a specific deployment. Use --follow to stream logs in real-time.
578
- """
579
- try:
580
- with get_client() as client:
581
- console.print(f"[cyan]Fetching logs for deployment {deployment_id}...[/cyan]")
582
-
583
- log_content = client.deployments.get_logs(deployment_id)
584
-
585
- if not log_content:
586
- console.print("[yellow]No logs available yet.[/yellow]")
587
- console.print("[dim]The deployment may still be starting up.[/dim]")
588
- return
589
-
590
- # Process logs
591
- log_lines = log_content.strip().split("\n")
592
-
593
- # Apply tail if specified
594
- if tail:
595
- log_lines = log_lines[-tail:]
596
-
597
- # Display logs with syntax highlighting
598
- console.print(f"\n[bold]Logs for deployment {deployment_id}:[/bold]\n")
599
-
600
- if follow:
601
- _follow_deployment(client, deployment_id)
602
- return
603
-
604
- # Display logs
605
- for line in log_lines:
606
- # Color-code based on log level
607
- if "ERROR" in line or "FAILED" in line:
608
- console.print(f"[red]{line}[/red]")
609
- elif "WARN" in line or "WARNING" in line:
610
- console.print(f"[yellow]{line}[/yellow]")
611
- elif "SUCCESS" in line or "COMPLETED" in line:
612
- console.print(f"[green]{line}[/green]")
613
- elif "INFO" in line:
614
- console.print(f"[cyan]{line}[/cyan]")
615
- else:
616
- console.print(line)
617
-
618
- except XenfraAPIError as e:
619
- console.print(f"[bold red]API Error: {e.detail}[/bold red]")
620
- except XenfraError as e:
621
- console.print(f"[bold red]Error: {e}[/bold red]")
622
- except click.Abort:
623
- pass
624
-
625
-
626
- @click.command()
627
- @click.argument("deployment-id", required=False)
628
- @click.option("--watch", "-w", is_flag=True, help="Watch status updates")
629
- def status(deployment_id, watch):
630
- """
631
- Show deployment status.
632
-
633
- Displays current status, progress, and details for a deployment.
634
- Use --watch to monitor status in real-time.
635
- """
636
- try:
637
- if not deployment_id:
638
- console.print("[yellow]No deployment ID provided.[/yellow]")
639
- console.print("[dim]Usage: xenfra status <deployment-id>[/dim]")
640
- return
641
-
642
- # Validate deployment ID
643
- is_valid, error_msg = validate_deployment_id(deployment_id)
644
- if not is_valid:
645
- console.print(f"[bold red]Invalid deployment ID: {error_msg}[/bold red]")
646
- raise click.Abort()
647
-
648
- with get_client() as client:
649
- console.print(f"[cyan]Fetching status for deployment {deployment_id}...[/cyan]")
650
-
651
- deployment_status = client.deployments.get_status(deployment_id)
652
-
653
- if watch:
654
- _follow_deployment(client, deployment_id)
655
- return
656
-
657
- # Display status
658
- status_value = deployment_status.get("status", "UNKNOWN")
659
- state = deployment_status.get("state", "unknown")
660
- progress = deployment_status.get("progress", 0)
661
-
662
- # Status panel
663
- status_color = {
664
- "PENDING": "yellow",
665
- "IN_PROGRESS": "cyan",
666
- "SUCCESS": "green",
667
- "FAILED": "red",
668
- "CANCELLED": "dim",
669
- }.get(status_value, "white")
670
-
671
- # Create status table
672
- table = Table(show_header=False, box=None)
673
- table.add_column("Property", style="cyan")
674
- table.add_column("Value")
675
-
676
- table.add_row("Deployment ID", str(deployment_id))
677
- table.add_row("Status", f"[{status_color}]{status_value}[/{status_color}]")
678
- table.add_row("State", state)
679
-
680
- if progress > 0:
681
- table.add_row("Progress", f"{progress}%")
682
-
683
- if "project_name" in deployment_status:
684
- table.add_row("Project", deployment_status["project_name"])
685
-
686
- if "created_at" in deployment_status:
687
- table.add_row("Created", deployment_status["created_at"])
688
-
689
- if "finished_at" in deployment_status:
690
- table.add_row("Finished", deployment_status["finished_at"])
691
-
692
- if "url" in deployment_status:
693
- table.add_row("URL", f"[link]{deployment_status['url']}[/link]")
694
-
695
- if "ip_address" in deployment_status:
696
- table.add_row("IP Address", deployment_status["ip_address"])
697
-
698
- panel = Panel(table, title="[bold]Deployment Status[/bold]", border_style=status_color)
699
- console.print(panel)
700
-
701
- # Show error if failed
702
- if status_value == "FAILED" and "error" in deployment_status:
703
- error_panel = Panel(
704
- deployment_status["error"],
705
- title="[bold red]Error[/bold red]",
706
- border_style="red",
707
- )
708
- console.print("\n", error_panel)
709
-
710
- console.print("\n[bold]Troubleshooting:[/bold]")
711
- console.print(f" • View logs: [cyan]xenfra logs {deployment_id}[/cyan]")
712
- console.print(f" • Diagnose: [cyan]xenfra diagnose {deployment_id}[/cyan]")
713
-
714
- # Show next steps based on status
715
- elif status_value == "SUCCESS":
716
- console.print("\n[bold green]Deployment successful! 🎉[/bold green]")
717
- if "url" in deployment_status:
718
- console.print(f" • Visit: [link]{deployment_status['url']}[/link]")
719
-
720
- elif status_value in ["PENDING", "IN_PROGRESS"]:
721
- console.print("\n[bold]Deployment in progress...[/bold]")
722
- console.print(f" • View logs: [cyan]xenfra logs {deployment_id}[/cyan]")
723
- console.print(f" • Check again: [cyan]xenfra status {deployment_id}[/cyan]")
724
-
725
- except XenfraAPIError as e:
726
- console.print(f"[bold red]API Error: {e.detail}[/bold red]")
727
- except XenfraError as e:
728
- console.print(f"[bold red]Error: {e}[/bold red]")
729
- except click.Abort:
730
- pass
731
-
732
-
733
- @click.command()
734
- @click.argument("deployment-id")
735
- @click.option("--format", "output_format", type=click.Choice(["detailed", "summary"], case_sensitive=False), default="detailed", help="Report format (detailed or summary)")
736
- def report(deployment_id, output_format):
737
- """
738
- Generate deployment report with self-healing events.
739
-
740
- Shows comprehensive deployment information including:
741
- - Deployment status and timeline
742
- - Self-healing attempts and outcomes
743
- - Patches applied during healing
744
- - Statistics and metrics
745
- """
746
- try:
747
- # Validate deployment ID
748
- is_valid, error_msg = validate_deployment_id(deployment_id)
749
- if not is_valid:
750
- console.print(f"[bold red]Invalid deployment ID: {error_msg}[/bold red]")
751
- raise click.Abort()
752
-
753
- with get_client() as client:
754
- console.print(f"[cyan]Generating report for deployment {deployment_id}...[/cyan]\n")
755
-
756
- # Get deployment status
757
- try:
758
- deployment_status = client.deployments.get_status(deployment_id)
759
- except XenfraAPIError as e:
760
- console.print(f"[bold red]Error fetching deployment status: {e.detail}[/bold red]")
761
- raise click.Abort()
762
-
763
- # Get deployment logs
764
- try:
765
- logs = client.deployments.get_logs(deployment_id)
766
- except XenfraAPIError:
767
- logs = None
768
-
769
- # Parse status
770
- status_value = deployment_status.get("status", "UNKNOWN")
771
- state = deployment_status.get("state", "unknown")
772
- progress = deployment_status.get("progress", 0)
773
-
774
- # Status color mapping
775
- status_color = {
776
- "PENDING": "yellow",
777
- "IN_PROGRESS": "cyan",
778
- "SUCCESS": "green",
779
- "FAILED": "red",
780
- "CANCELLED": "dim",
781
- }.get(status_value, "white")
782
-
783
- # Calculate statistics from logs
784
- heal_attempts = logs.count("🤖 Analyzing failure") if logs else 0
785
- patches_applied = logs.count("✓ Patch applied") if logs else 0
786
- diagnoses = logs.count("🔍 Diagnosis") if logs else 0
787
-
788
- # Create main report table
789
- report_table = Table(show_header=True, box=None)
790
- report_table.add_column("Property", style="cyan", width=25)
791
- report_table.add_column("Value", style="white")
792
-
793
- report_table.add_row("Deployment ID", str(deployment_id))
794
- report_table.add_row("Status", f"[{status_color}]{status_value}[/{status_color}]")
795
- report_table.add_row("State", state)
796
-
797
- if progress > 0:
798
- report_table.add_row("Progress", f"{progress}%")
799
-
800
- if "project_name" in deployment_status:
801
- report_table.add_row("Project", deployment_status["project_name"])
802
-
803
- if "created_at" in deployment_status:
804
- report_table.add_row("Created", deployment_status["created_at"])
805
-
806
- if "finished_at" in deployment_status:
807
- report_table.add_row("Finished", deployment_status["finished_at"])
808
-
809
- if "url" in deployment_status:
810
- report_table.add_row("URL", f"[link]{deployment_status['url']}[/link]")
811
-
812
- if "ip_address" in deployment_status:
813
- report_table.add_row("IP Address", deployment_status["ip_address"])
814
-
815
- # Self-healing statistics
816
- report_table.add_row("", "") # Separator
817
- report_table.add_row("[bold]Self-Healing Stats[/bold]", "")
818
- report_table.add_row("Healing Attempts", str(heal_attempts))
819
- report_table.add_row("Patches Applied", str(patches_applied))
820
- report_table.add_row("Diagnoses Performed", str(diagnoses))
821
-
822
- if heal_attempts > 0:
823
- success_rate = (patches_applied / heal_attempts * 100) if heal_attempts > 0 else 0
824
- report_table.add_row("Healing Success Rate", f"{success_rate:.1f}%")
825
-
826
- # Display main report
827
- console.print(Panel(report_table, title="[bold]Deployment Report[/bold]", border_style=status_color))
828
-
829
- # Detailed format includes timeline and healing events
830
- if output_format == "detailed" and logs:
831
- console.print("\n[bold]Self-Healing Timeline[/bold]\n")
832
-
833
- # Extract healing events from logs
834
- log_lines = logs.split("\n")
835
- timeline_entries = []
836
-
837
- for i, line in enumerate(log_lines):
838
- if "🤖 Analyzing failure" in line:
839
- attempt_match = None
840
- # Try to find attempt number in surrounding lines
841
- for j in range(max(0, i-5), min(len(log_lines), i+10)):
842
- if "attempt" in log_lines[j].lower():
843
- timeline_entries.append(("Healing Attempt", log_lines[j].strip()))
844
- break
845
- elif "🔍 Diagnosis" in line or "Diagnosis" in line:
846
- # Extract diagnosis text from next few lines
847
- diagnosis_text = line.strip()
848
- if i+1 < len(log_lines) and log_lines[i+1].strip():
849
- diagnosis_text += "\n " + log_lines[i+1].strip()[:100]
850
- timeline_entries.append(("Diagnosis", diagnosis_text))
851
- elif "✓ Patch applied" in line or "Patch applied" in line:
852
- timeline_entries.append(("Patch Applied", line.strip()))
853
- elif "🔄 Retrying deployment" in line:
854
- timeline_entries.append(("Retry", line.strip()))
855
-
856
- if timeline_entries:
857
- timeline_table = Table(show_header=True, box=None)
858
- timeline_table.add_column("Event", style="cyan", width=20)
859
- timeline_table.add_column("Details", style="white")
860
-
861
- for event_type, details in timeline_entries[:20]: # Limit to 20 entries
862
- timeline_table.add_row(event_type, details)
863
-
864
- console.print(timeline_table)
865
- else:
866
- console.print("[dim]No self-healing events detected in logs.[/dim]")
867
-
868
- # Show error if failed
869
- if status_value == "FAILED":
870
- console.print("\n[bold red] Deployment Failed[/bold red]")
871
- if "error" in deployment_status:
872
- error_panel = Panel(
873
- deployment_status["error"],
874
- title="[bold red]Error Details[/bold red]",
875
- border_style="red",
876
- )
877
- console.print("\n", error_panel)
878
-
879
- console.print("\n[bold]Troubleshooting:[/bold]")
880
- console.print(f" View logs: [cyan]xenfra logs {deployment_id}[/cyan]")
881
- console.print(f" Diagnose: [cyan]xenfra diagnose {deployment_id}[/cyan]")
882
- console.print(f" • View status: [cyan]xenfra status {deployment_id}[/cyan]")
883
-
884
- # Success summary
885
- elif status_value == "SUCCESS":
886
- console.print("\n[bold green]✓ Deployment Successful[/bold green]")
887
- if heal_attempts > 0:
888
- console.print(f"[dim]Deployment succeeded after {heal_attempts} self-healing attempt(s).[/dim]")
889
- if "url" in deployment_status:
890
- console.print(f" • Visit: [link]{deployment_status['url']}[/link]")
891
-
892
- # Export summary format (JSON-like structure for programmatic use)
893
- if output_format == "summary":
894
- console.print("\n[bold]Summary Format:[/bold]")
895
- import json
896
- summary = {
897
- "deployment_id": deployment_id,
898
- "status": status_value,
899
- "healing_attempts": heal_attempts,
900
- "patches_applied": patches_applied,
901
- "success": status_value == "SUCCESS",
902
- }
903
- console.print(f"[dim]{json.dumps(summary, indent=2)}[/dim]")
904
-
905
- except XenfraAPIError as e:
906
- console.print(f"[bold red]API Error: {e.detail}[/bold red]")
907
- except XenfraError as e:
908
- console.print(f"[bold red]Error: {e}[/bold red]")
909
- except click.Abort:
910
- pass
911
- except Exception as e:
912
- 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]")