xenfra 0.2.8__py3-none-any.whl → 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xenfra/commands/__init__.py +3 -3
- xenfra/commands/auth.py +137 -291
- xenfra/commands/auth_device.py +159 -0
- xenfra/commands/deployments.py +666 -484
- xenfra/commands/intelligence.py +343 -343
- xenfra/commands/projects.py +204 -204
- xenfra/commands/security_cmd.py +233 -233
- xenfra/main.py +75 -74
- xenfra/utils/__init__.py +3 -3
- xenfra/utils/auth.py +243 -226
- xenfra/utils/codebase.py +126 -126
- xenfra/utils/config.py +363 -363
- xenfra/utils/security.py +336 -336
- xenfra/utils/validation.py +234 -234
- {xenfra-0.2.8.dist-info → xenfra-0.3.0.dist-info}/METADATA +84 -84
- xenfra-0.3.0.dist-info/RECORD +19 -0
- {xenfra-0.2.8.dist-info → xenfra-0.3.0.dist-info}/WHEEL +1 -1
- xenfra-0.2.8.dist-info/RECORD +0 -18
- {xenfra-0.2.8.dist-info → xenfra-0.3.0.dist-info}/entry_points.txt +0 -0
xenfra/commands/deployments.py
CHANGED
|
@@ -1,484 +1,666 @@
|
|
|
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
|
|
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
|
-
console = Console()
|
|
27
|
-
|
|
28
|
-
# Maximum number of retry attempts for auto-healing
|
|
29
|
-
MAX_RETRY_ATTEMPTS = 3
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def get_client() -> XenfraClient:
|
|
33
|
-
"""Get authenticated SDK client."""
|
|
34
|
-
token = get_auth_token()
|
|
35
|
-
if not token:
|
|
36
|
-
console.print("[bold red]Not logged in. Run 'xenfra auth login' first.[/bold red]")
|
|
37
|
-
raise click.Abort()
|
|
38
|
-
|
|
39
|
-
return XenfraClient(token=token, api_url=API_BASE_URL)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def show_diagnosis_panel(diagnosis: str, suggestion: str):
|
|
43
|
-
"""Display diagnosis and suggestion in formatted panels."""
|
|
44
|
-
console.print()
|
|
45
|
-
console.print(Panel(diagnosis, title="[bold red]🔍 Diagnosis[/bold red]", border_style="red"))
|
|
46
|
-
console.print()
|
|
47
|
-
console.print(
|
|
48
|
-
Panel(suggestion, title="[bold yellow]💡 Suggestion[/bold yellow]", border_style="yellow")
|
|
49
|
-
)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def show_patch_preview(patch_data: dict):
|
|
53
|
-
"""Show a preview of the patch that will be applied."""
|
|
54
|
-
console.print()
|
|
55
|
-
console.print("[bold green]🔧 Automatic Fix Available[/bold green]")
|
|
56
|
-
console.print(f" [cyan]File:[/cyan] {patch_data.get('file')}")
|
|
57
|
-
console.print(f" [cyan]Operation:[/cyan] {patch_data.get('operation')}")
|
|
58
|
-
console.print(f" [cyan]Value:[/cyan] {patch_data.get('value')}")
|
|
59
|
-
console.print()
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def zen_nod_workflow(logs: str, client: XenfraClient, attempt: int) -> bool:
|
|
63
|
-
"""
|
|
64
|
-
Execute the Zen Nod auto-healing workflow.
|
|
65
|
-
|
|
66
|
-
Args:
|
|
67
|
-
logs: Deployment error logs
|
|
68
|
-
client: Authenticated SDK client
|
|
69
|
-
attempt: Current attempt number
|
|
70
|
-
|
|
71
|
-
Returns:
|
|
72
|
-
True if patch was applied and user wants to retry, False otherwise
|
|
73
|
-
"""
|
|
74
|
-
console.print()
|
|
75
|
-
console.print(f"[cyan]🤖 Analyzing failure (attempt {attempt}/{MAX_RETRY_ATTEMPTS})...[/cyan]")
|
|
76
|
-
|
|
77
|
-
# Scrub sensitive data from logs
|
|
78
|
-
scrubbed_logs = scrub_logs(logs)
|
|
79
|
-
|
|
80
|
-
# Diagnose with AI
|
|
81
|
-
try:
|
|
82
|
-
diagnosis_result = client.intelligence.diagnose(scrubbed_logs)
|
|
83
|
-
except Exception as e:
|
|
84
|
-
console.print(f"[yellow]Could not diagnose failure: {e}[/yellow]")
|
|
85
|
-
return False
|
|
86
|
-
|
|
87
|
-
# Show diagnosis
|
|
88
|
-
show_diagnosis_panel(diagnosis_result.diagnosis, diagnosis_result.suggestion)
|
|
89
|
-
|
|
90
|
-
# Check if there's an automatic patch
|
|
91
|
-
if diagnosis_result.patch and diagnosis_result.patch.file:
|
|
92
|
-
show_patch_preview(diagnosis_result.patch.model_dump())
|
|
93
|
-
|
|
94
|
-
# Zen Nod confirmation
|
|
95
|
-
if click.confirm("Apply this fix and retry deployment?", default=True):
|
|
96
|
-
try:
|
|
97
|
-
# Apply patch (with automatic backup)
|
|
98
|
-
backup_path = apply_patch(diagnosis_result.patch.model_dump())
|
|
99
|
-
console.print("[bold green]✓ Patch applied[/bold green]")
|
|
100
|
-
if backup_path:
|
|
101
|
-
console.print(f"[dim]Backup saved: {backup_path}[/dim]")
|
|
102
|
-
return True # Signal to retry
|
|
103
|
-
except Exception as e:
|
|
104
|
-
console.print(f"[bold red]Failed to apply patch: {e}[/bold red]")
|
|
105
|
-
return False
|
|
106
|
-
else:
|
|
107
|
-
console.print()
|
|
108
|
-
console.print("[yellow]❌ Patch declined. Follow the manual steps above.[/yellow]")
|
|
109
|
-
return False
|
|
110
|
-
else:
|
|
111
|
-
console.print()
|
|
112
|
-
console.print(
|
|
113
|
-
"[yellow]No automatic fix available. Please follow the manual steps above.[/yellow]"
|
|
114
|
-
)
|
|
115
|
-
return False
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
@click.command()
|
|
119
|
-
@click.option("--project-name", help="Project name (defaults to current directory name)")
|
|
120
|
-
@click.option("--git-repo", help="Git repository URL (if deploying from git)")
|
|
121
|
-
@click.option("--branch", default="main", help="Git branch (default: main)")
|
|
122
|
-
@click.option("--framework", help="Framework override (fastapi, flask, django)")
|
|
123
|
-
@click.option("--no-heal", is_flag=True, help="Disable auto-healing on failure")
|
|
124
|
-
def deploy(project_name, git_repo, branch, framework, no_heal):
|
|
125
|
-
"""
|
|
126
|
-
Deploy current project to DigitalOcean with auto-healing.
|
|
127
|
-
|
|
128
|
-
Deploys your application with zero configuration. The CLI will:
|
|
129
|
-
1. Check for xenfra.yaml (or run init if missing)
|
|
130
|
-
2. Create a deployment
|
|
131
|
-
3. Auto-diagnose and fix failures (unless --no-heal is set or XENFRA_NO_AI=1)
|
|
132
|
-
|
|
133
|
-
Set XENFRA_NO_AI=1 environment variable to disable all AI features.
|
|
134
|
-
"""
|
|
135
|
-
# Check XENFRA_NO_AI environment variable
|
|
136
|
-
no_ai = os.environ.get("XENFRA_NO_AI", "0") == "1"
|
|
137
|
-
if no_ai:
|
|
138
|
-
console.print("[yellow]XENFRA_NO_AI is set. Auto-healing disabled.[/yellow]")
|
|
139
|
-
no_heal = True
|
|
140
|
-
|
|
141
|
-
# Check for xenfra.yaml
|
|
142
|
-
if not has_xenfra_config():
|
|
143
|
-
console.print("[yellow]No xenfra.yaml found.[/yellow]")
|
|
144
|
-
if click.confirm("Run 'xenfra init' to create configuration?", default=True):
|
|
145
|
-
from .intelligence import init
|
|
146
|
-
|
|
147
|
-
ctx = click.get_current_context()
|
|
148
|
-
ctx.invoke(init, manual=no_ai, accept_all=False)
|
|
149
|
-
else:
|
|
150
|
-
console.print("[dim]Deployment cancelled.[/dim]")
|
|
151
|
-
return
|
|
152
|
-
|
|
153
|
-
# Default project name to current directory
|
|
154
|
-
if not project_name:
|
|
155
|
-
project_name = os.path.basename(os.getcwd())
|
|
156
|
-
|
|
157
|
-
# Validate project name
|
|
158
|
-
is_valid, error_msg = validate_project_name(project_name)
|
|
159
|
-
if not is_valid:
|
|
160
|
-
console.print(f"[bold red]Invalid project name: {error_msg}[/bold red]")
|
|
161
|
-
raise click.Abort()
|
|
162
|
-
|
|
163
|
-
# Validate git repo if provided
|
|
164
|
-
if git_repo:
|
|
165
|
-
is_valid, error_msg = validate_git_repo_url(git_repo)
|
|
166
|
-
if not is_valid:
|
|
167
|
-
console.print(f"[bold red]Invalid git repository URL: {error_msg}[/bold red]")
|
|
168
|
-
raise click.Abort()
|
|
169
|
-
console.print(f"[cyan]Deploying {project_name} from git repository...[/cyan]")
|
|
170
|
-
else:
|
|
171
|
-
console.print(f"[cyan]Deploying {project_name} from local directory...[/cyan]")
|
|
172
|
-
console.print(
|
|
173
|
-
"[yellow]Local deployment requires code upload (not yet fully implemented).[/yellow]"
|
|
174
|
-
)
|
|
175
|
-
console.print("[dim]Please use --git-repo for now.[/dim]")
|
|
176
|
-
return
|
|
177
|
-
|
|
178
|
-
# Validate branch name
|
|
179
|
-
is_valid, error_msg = validate_branch_name(branch)
|
|
180
|
-
if not is_valid:
|
|
181
|
-
console.print(f"[bold red]Invalid branch name: {error_msg}[/bold red]")
|
|
182
|
-
raise click.Abort()
|
|
183
|
-
|
|
184
|
-
# Validate framework if provided
|
|
185
|
-
if framework:
|
|
186
|
-
is_valid, error_msg = validate_framework(framework)
|
|
187
|
-
if not is_valid:
|
|
188
|
-
console.print(f"[bold red]Invalid framework: {error_msg}[/bold red]")
|
|
189
|
-
raise click.Abort()
|
|
190
|
-
|
|
191
|
-
# Retry loop for auto-healing
|
|
192
|
-
attempt = 0
|
|
193
|
-
deployment_id = None
|
|
194
|
-
|
|
195
|
-
try:
|
|
196
|
-
with get_client() as client:
|
|
197
|
-
while attempt < MAX_RETRY_ATTEMPTS:
|
|
198
|
-
# Safety check to prevent infinite loops
|
|
199
|
-
if attempt > MAX_RETRY_ATTEMPTS:
|
|
200
|
-
raise RuntimeError("Safety break: Retry loop exceeded MAX_RETRY_ATTEMPTS.")
|
|
201
|
-
|
|
202
|
-
attempt += 1
|
|
203
|
-
|
|
204
|
-
if attempt > 1:
|
|
205
|
-
console.print(
|
|
206
|
-
f"\n[cyan]🔄 Retrying deployment (attempt {attempt}/{MAX_RETRY_ATTEMPTS})...[/cyan]"
|
|
207
|
-
)
|
|
208
|
-
else:
|
|
209
|
-
console.print("[cyan]Creating deployment...[/cyan]")
|
|
210
|
-
|
|
211
|
-
# Detect framework if not provided
|
|
212
|
-
if not framework:
|
|
213
|
-
console.print("[dim]Auto-detecting framework...[/dim]")
|
|
214
|
-
framework = "fastapi" # Default for now
|
|
215
|
-
|
|
216
|
-
# Create deployment
|
|
217
|
-
try:
|
|
218
|
-
deployment = client.deployments.create(
|
|
219
|
-
project_name=project_name,
|
|
220
|
-
git_repo=git_repo,
|
|
221
|
-
branch=branch,
|
|
222
|
-
framework=framework,
|
|
223
|
-
)
|
|
224
|
-
|
|
225
|
-
deployment_id = deployment["deployment_id"]
|
|
226
|
-
console.print(
|
|
227
|
-
f"[bold green]✓[/bold green] Deployment created: [cyan]{deployment_id}[/cyan]"
|
|
228
|
-
)
|
|
229
|
-
|
|
230
|
-
# Show deployment details
|
|
231
|
-
details_table = Table(show_header=False, box=None)
|
|
232
|
-
details_table.add_column("Property", style="cyan")
|
|
233
|
-
details_table.add_column("Value", style="white")
|
|
234
|
-
|
|
235
|
-
details_table.add_row("Deployment ID", str(deployment_id))
|
|
236
|
-
details_table.add_row("Project", project_name)
|
|
237
|
-
if git_repo:
|
|
238
|
-
details_table.add_row("Repository", git_repo)
|
|
239
|
-
details_table.add_row("Branch", branch)
|
|
240
|
-
details_table.add_row("Framework", framework)
|
|
241
|
-
details_table.add_row("Status", deployment.get("status", "PENDING"))
|
|
242
|
-
|
|
243
|
-
panel = Panel(
|
|
244
|
-
details_table,
|
|
245
|
-
title="[bold green]Deployment Started[/bold green]",
|
|
246
|
-
border_style="green",
|
|
247
|
-
)
|
|
248
|
-
console.print(panel)
|
|
249
|
-
|
|
250
|
-
# Show next steps
|
|
251
|
-
console.print("\n[bold]Next steps:[/bold]")
|
|
252
|
-
console.print(f" • Monitor status: [cyan]xenfra status {deployment_id}[/cyan]")
|
|
253
|
-
console.print(f" • View logs: [cyan]xenfra logs {deployment_id}[/cyan]")
|
|
254
|
-
if not no_heal:
|
|
255
|
-
console.print(
|
|
256
|
-
f" • Diagnose issues: [cyan]xenfra diagnose {deployment_id}[/cyan]"
|
|
257
|
-
)
|
|
258
|
-
|
|
259
|
-
# Success - break out of retry loop
|
|
260
|
-
break
|
|
261
|
-
|
|
262
|
-
except XenfraAPIError as e:
|
|
263
|
-
# Deployment failed
|
|
264
|
-
console.print(f"[bold red]✗ Deployment failed: {e.detail}[/bold red]")
|
|
265
|
-
|
|
266
|
-
# Check if we should auto-heal
|
|
267
|
-
if no_heal or attempt >= MAX_RETRY_ATTEMPTS:
|
|
268
|
-
# No auto-healing or max retries reached
|
|
269
|
-
if attempt >= MAX_RETRY_ATTEMPTS:
|
|
270
|
-
console.print(
|
|
271
|
-
f"\n[bold red]❌ Maximum retry attempts ({MAX_RETRY_ATTEMPTS}) reached.[/bold red]"
|
|
272
|
-
)
|
|
273
|
-
console.print(
|
|
274
|
-
"[yellow]Unable to auto-fix the issue. Please review the errors above.[/yellow]"
|
|
275
|
-
)
|
|
276
|
-
raise
|
|
277
|
-
else:
|
|
278
|
-
# Try to get logs for diagnosis
|
|
279
|
-
error_logs = str(e.detail)
|
|
280
|
-
try:
|
|
281
|
-
if deployment_id:
|
|
282
|
-
# This should be a method in the SDK that returns a string
|
|
283
|
-
logs_response = client.deployments.get_logs(deployment_id)
|
|
284
|
-
if isinstance(logs_response, dict):
|
|
285
|
-
error_logs = logs_response.get("logs", str(e.detail))
|
|
286
|
-
else:
|
|
287
|
-
error_logs = str(logs_response) # Assuming it can be a string
|
|
288
|
-
except Exception as log_err:
|
|
289
|
-
console.print(
|
|
290
|
-
f"[yellow]Warning: Could not fetch detailed logs for diagnosis: {log_err}[/yellow]"
|
|
291
|
-
)
|
|
292
|
-
# Fallback to the initial error detail
|
|
293
|
-
pass
|
|
294
|
-
|
|
295
|
-
# Run Zen Nod workflow
|
|
296
|
-
should_retry = zen_nod_workflow(error_logs, client, attempt)
|
|
297
|
-
|
|
298
|
-
if not should_retry:
|
|
299
|
-
# User declined patch or no patch available
|
|
300
|
-
console.print("\n[dim]Deployment cancelled.[/dim]")
|
|
301
|
-
raise click.Abort()
|
|
302
|
-
|
|
303
|
-
# Continue to next iteration (retry)
|
|
304
|
-
continue
|
|
305
|
-
|
|
306
|
-
except XenfraAPIError as e:
|
|
307
|
-
console.print(f"[bold red]API Error: {e.detail}[/bold red]")
|
|
308
|
-
except XenfraError as e:
|
|
309
|
-
console.print(f"[bold red]Error: {e}[/bold red]")
|
|
310
|
-
except click.Abort:
|
|
311
|
-
pass
|
|
312
|
-
except Exception as e:
|
|
313
|
-
console.print(f"[bold red]Unexpected error: {e}[/bold red]")
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
@click.command()
|
|
317
|
-
@click.argument("deployment-id")
|
|
318
|
-
@click.option("--follow", "-f", is_flag=True, help="Follow log output (stream)")
|
|
319
|
-
@click.option("--tail", type=int, help="Show last N lines")
|
|
320
|
-
def logs(deployment_id, follow, tail):
|
|
321
|
-
# Validate deployment ID
|
|
322
|
-
is_valid, error_msg = validate_deployment_id(deployment_id)
|
|
323
|
-
if not is_valid:
|
|
324
|
-
console.print(f"[bold red]Invalid deployment ID: {error_msg}[/bold red]")
|
|
325
|
-
raise click.Abort()
|
|
326
|
-
"""
|
|
327
|
-
Stream deployment logs.
|
|
328
|
-
|
|
329
|
-
Shows logs for a specific deployment. Use --follow to stream logs in real-time.
|
|
330
|
-
"""
|
|
331
|
-
try:
|
|
332
|
-
with get_client() as client:
|
|
333
|
-
console.print(f"[cyan]Fetching logs for deployment {deployment_id}...[/cyan]")
|
|
334
|
-
|
|
335
|
-
log_content = client.deployments.get_logs(deployment_id)
|
|
336
|
-
|
|
337
|
-
if not log_content:
|
|
338
|
-
console.print("[yellow]No logs available yet.[/yellow]")
|
|
339
|
-
console.print("[dim]The deployment may still be starting up.[/dim]")
|
|
340
|
-
return
|
|
341
|
-
|
|
342
|
-
# Process logs
|
|
343
|
-
log_lines = log_content.strip().split("\n")
|
|
344
|
-
|
|
345
|
-
# Apply tail if specified
|
|
346
|
-
if tail:
|
|
347
|
-
log_lines = log_lines[-tail:]
|
|
348
|
-
|
|
349
|
-
# Display logs with syntax highlighting
|
|
350
|
-
console.print(f"\n[bold]Logs for deployment {deployment_id}:[/bold]\n")
|
|
351
|
-
|
|
352
|
-
if follow:
|
|
353
|
-
console.print(
|
|
354
|
-
"[yellow]Note: --follow flag not yet implemented (showing static logs)[/yellow]\n"
|
|
355
|
-
)
|
|
356
|
-
|
|
357
|
-
# Display logs
|
|
358
|
-
for line in log_lines:
|
|
359
|
-
# Color-code based on log level
|
|
360
|
-
if "ERROR" in line or "FAILED" in line:
|
|
361
|
-
console.print(f"[red]{line}[/red]")
|
|
362
|
-
elif "WARN" in line or "WARNING" in line:
|
|
363
|
-
console.print(f"[yellow]{line}[/yellow]")
|
|
364
|
-
elif "SUCCESS" in line or "COMPLETED" in line:
|
|
365
|
-
console.print(f"[green]{line}[/green]")
|
|
366
|
-
elif "INFO" in line:
|
|
367
|
-
console.print(f"[cyan]{line}[/cyan]")
|
|
368
|
-
else:
|
|
369
|
-
console.print(line)
|
|
370
|
-
|
|
371
|
-
except XenfraAPIError as e:
|
|
372
|
-
console.print(f"[bold red]API Error: {e.detail}[/bold red]")
|
|
373
|
-
except XenfraError as e:
|
|
374
|
-
console.print(f"[bold red]Error: {e}[/bold red]")
|
|
375
|
-
except click.Abort:
|
|
376
|
-
pass
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
@click.command()
|
|
380
|
-
@click.argument("deployment-id", required=False)
|
|
381
|
-
@click.option("--watch", "-w", is_flag=True, help="Watch status updates")
|
|
382
|
-
def status(deployment_id, watch):
|
|
383
|
-
"""
|
|
384
|
-
Show deployment status.
|
|
385
|
-
|
|
386
|
-
Displays current status, progress, and details for a deployment.
|
|
387
|
-
Use --watch to monitor status in real-time.
|
|
388
|
-
"""
|
|
389
|
-
try:
|
|
390
|
-
if not deployment_id:
|
|
391
|
-
console.print("[yellow]No deployment ID provided.[/yellow]")
|
|
392
|
-
console.print("[dim]Usage: xenfra status <deployment-id>[/dim]")
|
|
393
|
-
return
|
|
394
|
-
|
|
395
|
-
# Validate deployment ID
|
|
396
|
-
is_valid, error_msg = validate_deployment_id(deployment_id)
|
|
397
|
-
if not is_valid:
|
|
398
|
-
console.print(f"[bold red]Invalid deployment ID: {error_msg}[/bold red]")
|
|
399
|
-
raise click.Abort()
|
|
400
|
-
|
|
401
|
-
with get_client() as client:
|
|
402
|
-
console.print(f"[cyan]Fetching status for deployment {deployment_id}...[/cyan]")
|
|
403
|
-
|
|
404
|
-
deployment_status = client.deployments.get_status(deployment_id)
|
|
405
|
-
|
|
406
|
-
if watch:
|
|
407
|
-
console.print(
|
|
408
|
-
"[yellow]Note: --watch flag not yet implemented (showing current status)[/yellow]\n"
|
|
409
|
-
)
|
|
410
|
-
|
|
411
|
-
# Display status
|
|
412
|
-
status_value = deployment_status.get("status", "UNKNOWN")
|
|
413
|
-
state = deployment_status.get("state", "unknown")
|
|
414
|
-
progress = deployment_status.get("progress", 0)
|
|
415
|
-
|
|
416
|
-
# Status panel
|
|
417
|
-
status_color = {
|
|
418
|
-
"PENDING": "yellow",
|
|
419
|
-
"IN_PROGRESS": "cyan",
|
|
420
|
-
"SUCCESS": "green",
|
|
421
|
-
"FAILED": "red",
|
|
422
|
-
"CANCELLED": "dim",
|
|
423
|
-
}.get(status_value, "white")
|
|
424
|
-
|
|
425
|
-
# Create status table
|
|
426
|
-
table = Table(show_header=False, box=None)
|
|
427
|
-
table.add_column("Property", style="cyan")
|
|
428
|
-
table.add_column("Value")
|
|
429
|
-
|
|
430
|
-
table.add_row("Deployment ID", str(deployment_id))
|
|
431
|
-
table.add_row("Status", f"[{status_color}]{status_value}[/{status_color}]")
|
|
432
|
-
table.add_row("State", state)
|
|
433
|
-
|
|
434
|
-
if progress > 0:
|
|
435
|
-
table.add_row("Progress", f"{progress}%")
|
|
436
|
-
|
|
437
|
-
if "project_name" in deployment_status:
|
|
438
|
-
table.add_row("Project", deployment_status["project_name"])
|
|
439
|
-
|
|
440
|
-
if "created_at" in deployment_status:
|
|
441
|
-
table.add_row("Created", deployment_status["created_at"])
|
|
442
|
-
|
|
443
|
-
if "finished_at" in deployment_status:
|
|
444
|
-
table.add_row("Finished", deployment_status["finished_at"])
|
|
445
|
-
|
|
446
|
-
if "url" in deployment_status:
|
|
447
|
-
table.add_row("URL", f"[link]{deployment_status['url']}[/link]")
|
|
448
|
-
|
|
449
|
-
if "ip_address" in deployment_status:
|
|
450
|
-
table.add_row("IP Address", deployment_status["ip_address"])
|
|
451
|
-
|
|
452
|
-
panel = Panel(table, title="[bold]Deployment Status[/bold]", border_style=status_color)
|
|
453
|
-
console.print(panel)
|
|
454
|
-
|
|
455
|
-
# Show error if failed
|
|
456
|
-
if status_value == "FAILED" and "error" in deployment_status:
|
|
457
|
-
error_panel = Panel(
|
|
458
|
-
deployment_status["error"],
|
|
459
|
-
title="[bold red]Error[/bold red]",
|
|
460
|
-
border_style="red",
|
|
461
|
-
)
|
|
462
|
-
console.print("\n", error_panel)
|
|
463
|
-
|
|
464
|
-
console.print("\n[bold]Troubleshooting:[/bold]")
|
|
465
|
-
console.print(f" • View logs: [cyan]xenfra logs {deployment_id}[/cyan]")
|
|
466
|
-
console.print(f" • Diagnose: [cyan]xenfra diagnose {deployment_id}[/cyan]")
|
|
467
|
-
|
|
468
|
-
# Show next steps based on status
|
|
469
|
-
elif status_value == "SUCCESS":
|
|
470
|
-
console.print("\n[bold green]Deployment successful! 🎉[/bold green]")
|
|
471
|
-
if "url" in deployment_status:
|
|
472
|
-
console.print(f" • Visit: [link]{deployment_status['url']}[/link]")
|
|
473
|
-
|
|
474
|
-
elif status_value in ["PENDING", "IN_PROGRESS"]:
|
|
475
|
-
console.print("\n[bold]Deployment in progress...[/bold]")
|
|
476
|
-
console.print(f" • View logs: [cyan]xenfra logs {deployment_id}[/cyan]")
|
|
477
|
-
console.print(f" • Check again: [cyan]xenfra status {deployment_id}[/cyan]")
|
|
478
|
-
|
|
479
|
-
except XenfraAPIError as e:
|
|
480
|
-
console.print(f"[bold red]API Error: {e.detail}[/bold red]")
|
|
481
|
-
except XenfraError as e:
|
|
482
|
-
console.print(f"[bold red]Error: {e}[/bold red]")
|
|
483
|
-
except click.Abort:
|
|
484
|
-
pass
|
|
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
|
|
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
|
+
console = Console()
|
|
27
|
+
|
|
28
|
+
# Maximum number of retry attempts for auto-healing
|
|
29
|
+
MAX_RETRY_ATTEMPTS = 3
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_client() -> XenfraClient:
|
|
33
|
+
"""Get authenticated SDK client."""
|
|
34
|
+
token = get_auth_token()
|
|
35
|
+
if not token:
|
|
36
|
+
console.print("[bold red]Not logged in. Run 'xenfra auth login' first.[/bold red]")
|
|
37
|
+
raise click.Abort()
|
|
38
|
+
|
|
39
|
+
return XenfraClient(token=token, api_url=API_BASE_URL)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def show_diagnosis_panel(diagnosis: str, suggestion: str):
|
|
43
|
+
"""Display diagnosis and suggestion in formatted panels."""
|
|
44
|
+
console.print()
|
|
45
|
+
console.print(Panel(diagnosis, title="[bold red]🔍 Diagnosis[/bold red]", border_style="red"))
|
|
46
|
+
console.print()
|
|
47
|
+
console.print(
|
|
48
|
+
Panel(suggestion, title="[bold yellow]💡 Suggestion[/bold yellow]", border_style="yellow")
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def show_patch_preview(patch_data: dict):
|
|
53
|
+
"""Show a preview of the patch that will be applied."""
|
|
54
|
+
console.print()
|
|
55
|
+
console.print("[bold green]🔧 Automatic Fix Available[/bold green]")
|
|
56
|
+
console.print(f" [cyan]File:[/cyan] {patch_data.get('file')}")
|
|
57
|
+
console.print(f" [cyan]Operation:[/cyan] {patch_data.get('operation')}")
|
|
58
|
+
console.print(f" [cyan]Value:[/cyan] {patch_data.get('value')}")
|
|
59
|
+
console.print()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def zen_nod_workflow(logs: str, client: XenfraClient, attempt: int) -> bool:
|
|
63
|
+
"""
|
|
64
|
+
Execute the Zen Nod auto-healing workflow.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
logs: Deployment error logs
|
|
68
|
+
client: Authenticated SDK client
|
|
69
|
+
attempt: Current attempt number
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
True if patch was applied and user wants to retry, False otherwise
|
|
73
|
+
"""
|
|
74
|
+
console.print()
|
|
75
|
+
console.print(f"[cyan]🤖 Analyzing failure (attempt {attempt}/{MAX_RETRY_ATTEMPTS})...[/cyan]")
|
|
76
|
+
|
|
77
|
+
# Scrub sensitive data from logs
|
|
78
|
+
scrubbed_logs = scrub_logs(logs)
|
|
79
|
+
|
|
80
|
+
# Diagnose with AI
|
|
81
|
+
try:
|
|
82
|
+
diagnosis_result = client.intelligence.diagnose(scrubbed_logs)
|
|
83
|
+
except Exception as e:
|
|
84
|
+
console.print(f"[yellow]Could not diagnose failure: {e}[/yellow]")
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
# Show diagnosis
|
|
88
|
+
show_diagnosis_panel(diagnosis_result.diagnosis, diagnosis_result.suggestion)
|
|
89
|
+
|
|
90
|
+
# Check if there's an automatic patch
|
|
91
|
+
if diagnosis_result.patch and diagnosis_result.patch.file:
|
|
92
|
+
show_patch_preview(diagnosis_result.patch.model_dump())
|
|
93
|
+
|
|
94
|
+
# Zen Nod confirmation
|
|
95
|
+
if click.confirm("Apply this fix and retry deployment?", default=True):
|
|
96
|
+
try:
|
|
97
|
+
# Apply patch (with automatic backup)
|
|
98
|
+
backup_path = apply_patch(diagnosis_result.patch.model_dump())
|
|
99
|
+
console.print("[bold green]✓ Patch applied[/bold green]")
|
|
100
|
+
if backup_path:
|
|
101
|
+
console.print(f"[dim]Backup saved: {backup_path}[/dim]")
|
|
102
|
+
return True # Signal to retry
|
|
103
|
+
except Exception as e:
|
|
104
|
+
console.print(f"[bold red]Failed to apply patch: {e}[/bold red]")
|
|
105
|
+
return False
|
|
106
|
+
else:
|
|
107
|
+
console.print()
|
|
108
|
+
console.print("[yellow]❌ Patch declined. Follow the manual steps above.[/yellow]")
|
|
109
|
+
return False
|
|
110
|
+
else:
|
|
111
|
+
console.print()
|
|
112
|
+
console.print(
|
|
113
|
+
"[yellow]No automatic fix available. Please follow the manual steps above.[/yellow]"
|
|
114
|
+
)
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@click.command()
|
|
119
|
+
@click.option("--project-name", help="Project name (defaults to current directory name)")
|
|
120
|
+
@click.option("--git-repo", help="Git repository URL (if deploying from git)")
|
|
121
|
+
@click.option("--branch", default="main", help="Git branch (default: main)")
|
|
122
|
+
@click.option("--framework", help="Framework override (fastapi, flask, django)")
|
|
123
|
+
@click.option("--no-heal", is_flag=True, help="Disable auto-healing on failure")
|
|
124
|
+
def deploy(project_name, git_repo, branch, framework, no_heal):
|
|
125
|
+
"""
|
|
126
|
+
Deploy current project to DigitalOcean with auto-healing.
|
|
127
|
+
|
|
128
|
+
Deploys your application with zero configuration. The CLI will:
|
|
129
|
+
1. Check for xenfra.yaml (or run init if missing)
|
|
130
|
+
2. Create a deployment
|
|
131
|
+
3. Auto-diagnose and fix failures (unless --no-heal is set or XENFRA_NO_AI=1)
|
|
132
|
+
|
|
133
|
+
Set XENFRA_NO_AI=1 environment variable to disable all AI features.
|
|
134
|
+
"""
|
|
135
|
+
# Check XENFRA_NO_AI environment variable
|
|
136
|
+
no_ai = os.environ.get("XENFRA_NO_AI", "0") == "1"
|
|
137
|
+
if no_ai:
|
|
138
|
+
console.print("[yellow]XENFRA_NO_AI is set. Auto-healing disabled.[/yellow]")
|
|
139
|
+
no_heal = True
|
|
140
|
+
|
|
141
|
+
# Check for xenfra.yaml
|
|
142
|
+
if not has_xenfra_config():
|
|
143
|
+
console.print("[yellow]No xenfra.yaml found.[/yellow]")
|
|
144
|
+
if click.confirm("Run 'xenfra init' to create configuration?", default=True):
|
|
145
|
+
from .intelligence import init
|
|
146
|
+
|
|
147
|
+
ctx = click.get_current_context()
|
|
148
|
+
ctx.invoke(init, manual=no_ai, accept_all=False)
|
|
149
|
+
else:
|
|
150
|
+
console.print("[dim]Deployment cancelled.[/dim]")
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
# Default project name to current directory
|
|
154
|
+
if not project_name:
|
|
155
|
+
project_name = os.path.basename(os.getcwd())
|
|
156
|
+
|
|
157
|
+
# Validate project name
|
|
158
|
+
is_valid, error_msg = validate_project_name(project_name)
|
|
159
|
+
if not is_valid:
|
|
160
|
+
console.print(f"[bold red]Invalid project name: {error_msg}[/bold red]")
|
|
161
|
+
raise click.Abort()
|
|
162
|
+
|
|
163
|
+
# Validate git repo if provided
|
|
164
|
+
if git_repo:
|
|
165
|
+
is_valid, error_msg = validate_git_repo_url(git_repo)
|
|
166
|
+
if not is_valid:
|
|
167
|
+
console.print(f"[bold red]Invalid git repository URL: {error_msg}[/bold red]")
|
|
168
|
+
raise click.Abort()
|
|
169
|
+
console.print(f"[cyan]Deploying {project_name} from git repository...[/cyan]")
|
|
170
|
+
else:
|
|
171
|
+
console.print(f"[cyan]Deploying {project_name} from local directory...[/cyan]")
|
|
172
|
+
console.print(
|
|
173
|
+
"[yellow]Local deployment requires code upload (not yet fully implemented).[/yellow]"
|
|
174
|
+
)
|
|
175
|
+
console.print("[dim]Please use --git-repo for now.[/dim]")
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
# Validate branch name
|
|
179
|
+
is_valid, error_msg = validate_branch_name(branch)
|
|
180
|
+
if not is_valid:
|
|
181
|
+
console.print(f"[bold red]Invalid branch name: {error_msg}[/bold red]")
|
|
182
|
+
raise click.Abort()
|
|
183
|
+
|
|
184
|
+
# Validate framework if provided
|
|
185
|
+
if framework:
|
|
186
|
+
is_valid, error_msg = validate_framework(framework)
|
|
187
|
+
if not is_valid:
|
|
188
|
+
console.print(f"[bold red]Invalid framework: {error_msg}[/bold red]")
|
|
189
|
+
raise click.Abort()
|
|
190
|
+
|
|
191
|
+
# Retry loop for auto-healing
|
|
192
|
+
attempt = 0
|
|
193
|
+
deployment_id = None
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
with get_client() as client:
|
|
197
|
+
while attempt < MAX_RETRY_ATTEMPTS:
|
|
198
|
+
# Safety check to prevent infinite loops
|
|
199
|
+
if attempt > MAX_RETRY_ATTEMPTS:
|
|
200
|
+
raise RuntimeError("Safety break: Retry loop exceeded MAX_RETRY_ATTEMPTS.")
|
|
201
|
+
|
|
202
|
+
attempt += 1
|
|
203
|
+
|
|
204
|
+
if attempt > 1:
|
|
205
|
+
console.print(
|
|
206
|
+
f"\n[cyan]🔄 Retrying deployment (attempt {attempt}/{MAX_RETRY_ATTEMPTS})...[/cyan]"
|
|
207
|
+
)
|
|
208
|
+
else:
|
|
209
|
+
console.print("[cyan]Creating deployment...[/cyan]")
|
|
210
|
+
|
|
211
|
+
# Detect framework if not provided
|
|
212
|
+
if not framework:
|
|
213
|
+
console.print("[dim]Auto-detecting framework...[/dim]")
|
|
214
|
+
framework = "fastapi" # Default for now
|
|
215
|
+
|
|
216
|
+
# Create deployment
|
|
217
|
+
try:
|
|
218
|
+
deployment = client.deployments.create(
|
|
219
|
+
project_name=project_name,
|
|
220
|
+
git_repo=git_repo,
|
|
221
|
+
branch=branch,
|
|
222
|
+
framework=framework,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
deployment_id = deployment["deployment_id"]
|
|
226
|
+
console.print(
|
|
227
|
+
f"[bold green]✓[/bold green] Deployment created: [cyan]{deployment_id}[/cyan]"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# Show deployment details
|
|
231
|
+
details_table = Table(show_header=False, box=None)
|
|
232
|
+
details_table.add_column("Property", style="cyan")
|
|
233
|
+
details_table.add_column("Value", style="white")
|
|
234
|
+
|
|
235
|
+
details_table.add_row("Deployment ID", str(deployment_id))
|
|
236
|
+
details_table.add_row("Project", project_name)
|
|
237
|
+
if git_repo:
|
|
238
|
+
details_table.add_row("Repository", git_repo)
|
|
239
|
+
details_table.add_row("Branch", branch)
|
|
240
|
+
details_table.add_row("Framework", framework)
|
|
241
|
+
details_table.add_row("Status", deployment.get("status", "PENDING"))
|
|
242
|
+
|
|
243
|
+
panel = Panel(
|
|
244
|
+
details_table,
|
|
245
|
+
title="[bold green]Deployment Started[/bold green]",
|
|
246
|
+
border_style="green",
|
|
247
|
+
)
|
|
248
|
+
console.print(panel)
|
|
249
|
+
|
|
250
|
+
# Show next steps
|
|
251
|
+
console.print("\n[bold]Next steps:[/bold]")
|
|
252
|
+
console.print(f" • Monitor status: [cyan]xenfra status {deployment_id}[/cyan]")
|
|
253
|
+
console.print(f" • View logs: [cyan]xenfra logs {deployment_id}[/cyan]")
|
|
254
|
+
if not no_heal:
|
|
255
|
+
console.print(
|
|
256
|
+
f" • Diagnose issues: [cyan]xenfra diagnose {deployment_id}[/cyan]"
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# Success - break out of retry loop
|
|
260
|
+
break
|
|
261
|
+
|
|
262
|
+
except XenfraAPIError as e:
|
|
263
|
+
# Deployment failed
|
|
264
|
+
console.print(f"[bold red]✗ Deployment failed: {e.detail}[/bold red]")
|
|
265
|
+
|
|
266
|
+
# Check if we should auto-heal
|
|
267
|
+
if no_heal or attempt >= MAX_RETRY_ATTEMPTS:
|
|
268
|
+
# No auto-healing or max retries reached
|
|
269
|
+
if attempt >= MAX_RETRY_ATTEMPTS:
|
|
270
|
+
console.print(
|
|
271
|
+
f"\n[bold red]❌ Maximum retry attempts ({MAX_RETRY_ATTEMPTS}) reached.[/bold red]"
|
|
272
|
+
)
|
|
273
|
+
console.print(
|
|
274
|
+
"[yellow]Unable to auto-fix the issue. Please review the errors above.[/yellow]"
|
|
275
|
+
)
|
|
276
|
+
raise
|
|
277
|
+
else:
|
|
278
|
+
# Try to get logs for diagnosis
|
|
279
|
+
error_logs = str(e.detail)
|
|
280
|
+
try:
|
|
281
|
+
if deployment_id:
|
|
282
|
+
# This should be a method in the SDK that returns a string
|
|
283
|
+
logs_response = client.deployments.get_logs(deployment_id)
|
|
284
|
+
if isinstance(logs_response, dict):
|
|
285
|
+
error_logs = logs_response.get("logs", str(e.detail))
|
|
286
|
+
else:
|
|
287
|
+
error_logs = str(logs_response) # Assuming it can be a string
|
|
288
|
+
except Exception as log_err:
|
|
289
|
+
console.print(
|
|
290
|
+
f"[yellow]Warning: Could not fetch detailed logs for diagnosis: {log_err}[/yellow]"
|
|
291
|
+
)
|
|
292
|
+
# Fallback to the initial error detail
|
|
293
|
+
pass
|
|
294
|
+
|
|
295
|
+
# Run Zen Nod workflow
|
|
296
|
+
should_retry = zen_nod_workflow(error_logs, client, attempt)
|
|
297
|
+
|
|
298
|
+
if not should_retry:
|
|
299
|
+
# User declined patch or no patch available
|
|
300
|
+
console.print("\n[dim]Deployment cancelled.[/dim]")
|
|
301
|
+
raise click.Abort()
|
|
302
|
+
|
|
303
|
+
# Continue to next iteration (retry)
|
|
304
|
+
continue
|
|
305
|
+
|
|
306
|
+
except XenfraAPIError as e:
|
|
307
|
+
console.print(f"[bold red]API Error: {e.detail}[/bold red]")
|
|
308
|
+
except XenfraError as e:
|
|
309
|
+
console.print(f"[bold red]Error: {e}[/bold red]")
|
|
310
|
+
except click.Abort:
|
|
311
|
+
pass
|
|
312
|
+
except Exception as e:
|
|
313
|
+
console.print(f"[bold red]Unexpected error: {e}[/bold red]")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@click.command()
|
|
317
|
+
@click.argument("deployment-id")
|
|
318
|
+
@click.option("--follow", "-f", is_flag=True, help="Follow log output (stream)")
|
|
319
|
+
@click.option("--tail", type=int, help="Show last N lines")
|
|
320
|
+
def logs(deployment_id, follow, tail):
|
|
321
|
+
# Validate deployment ID
|
|
322
|
+
is_valid, error_msg = validate_deployment_id(deployment_id)
|
|
323
|
+
if not is_valid:
|
|
324
|
+
console.print(f"[bold red]Invalid deployment ID: {error_msg}[/bold red]")
|
|
325
|
+
raise click.Abort()
|
|
326
|
+
"""
|
|
327
|
+
Stream deployment logs.
|
|
328
|
+
|
|
329
|
+
Shows logs for a specific deployment. Use --follow to stream logs in real-time.
|
|
330
|
+
"""
|
|
331
|
+
try:
|
|
332
|
+
with get_client() as client:
|
|
333
|
+
console.print(f"[cyan]Fetching logs for deployment {deployment_id}...[/cyan]")
|
|
334
|
+
|
|
335
|
+
log_content = client.deployments.get_logs(deployment_id)
|
|
336
|
+
|
|
337
|
+
if not log_content:
|
|
338
|
+
console.print("[yellow]No logs available yet.[/yellow]")
|
|
339
|
+
console.print("[dim]The deployment may still be starting up.[/dim]")
|
|
340
|
+
return
|
|
341
|
+
|
|
342
|
+
# Process logs
|
|
343
|
+
log_lines = log_content.strip().split("\n")
|
|
344
|
+
|
|
345
|
+
# Apply tail if specified
|
|
346
|
+
if tail:
|
|
347
|
+
log_lines = log_lines[-tail:]
|
|
348
|
+
|
|
349
|
+
# Display logs with syntax highlighting
|
|
350
|
+
console.print(f"\n[bold]Logs for deployment {deployment_id}:[/bold]\n")
|
|
351
|
+
|
|
352
|
+
if follow:
|
|
353
|
+
console.print(
|
|
354
|
+
"[yellow]Note: --follow flag not yet implemented (showing static logs)[/yellow]\n"
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
# Display logs
|
|
358
|
+
for line in log_lines:
|
|
359
|
+
# Color-code based on log level
|
|
360
|
+
if "ERROR" in line or "FAILED" in line:
|
|
361
|
+
console.print(f"[red]{line}[/red]")
|
|
362
|
+
elif "WARN" in line or "WARNING" in line:
|
|
363
|
+
console.print(f"[yellow]{line}[/yellow]")
|
|
364
|
+
elif "SUCCESS" in line or "COMPLETED" in line:
|
|
365
|
+
console.print(f"[green]{line}[/green]")
|
|
366
|
+
elif "INFO" in line:
|
|
367
|
+
console.print(f"[cyan]{line}[/cyan]")
|
|
368
|
+
else:
|
|
369
|
+
console.print(line)
|
|
370
|
+
|
|
371
|
+
except XenfraAPIError as e:
|
|
372
|
+
console.print(f"[bold red]API Error: {e.detail}[/bold red]")
|
|
373
|
+
except XenfraError as e:
|
|
374
|
+
console.print(f"[bold red]Error: {e}[/bold red]")
|
|
375
|
+
except click.Abort:
|
|
376
|
+
pass
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@click.command()
|
|
380
|
+
@click.argument("deployment-id", required=False)
|
|
381
|
+
@click.option("--watch", "-w", is_flag=True, help="Watch status updates")
|
|
382
|
+
def status(deployment_id, watch):
|
|
383
|
+
"""
|
|
384
|
+
Show deployment status.
|
|
385
|
+
|
|
386
|
+
Displays current status, progress, and details for a deployment.
|
|
387
|
+
Use --watch to monitor status in real-time.
|
|
388
|
+
"""
|
|
389
|
+
try:
|
|
390
|
+
if not deployment_id:
|
|
391
|
+
console.print("[yellow]No deployment ID provided.[/yellow]")
|
|
392
|
+
console.print("[dim]Usage: xenfra status <deployment-id>[/dim]")
|
|
393
|
+
return
|
|
394
|
+
|
|
395
|
+
# Validate deployment ID
|
|
396
|
+
is_valid, error_msg = validate_deployment_id(deployment_id)
|
|
397
|
+
if not is_valid:
|
|
398
|
+
console.print(f"[bold red]Invalid deployment ID: {error_msg}[/bold red]")
|
|
399
|
+
raise click.Abort()
|
|
400
|
+
|
|
401
|
+
with get_client() as client:
|
|
402
|
+
console.print(f"[cyan]Fetching status for deployment {deployment_id}...[/cyan]")
|
|
403
|
+
|
|
404
|
+
deployment_status = client.deployments.get_status(deployment_id)
|
|
405
|
+
|
|
406
|
+
if watch:
|
|
407
|
+
console.print(
|
|
408
|
+
"[yellow]Note: --watch flag not yet implemented (showing current status)[/yellow]\n"
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
# Display status
|
|
412
|
+
status_value = deployment_status.get("status", "UNKNOWN")
|
|
413
|
+
state = deployment_status.get("state", "unknown")
|
|
414
|
+
progress = deployment_status.get("progress", 0)
|
|
415
|
+
|
|
416
|
+
# Status panel
|
|
417
|
+
status_color = {
|
|
418
|
+
"PENDING": "yellow",
|
|
419
|
+
"IN_PROGRESS": "cyan",
|
|
420
|
+
"SUCCESS": "green",
|
|
421
|
+
"FAILED": "red",
|
|
422
|
+
"CANCELLED": "dim",
|
|
423
|
+
}.get(status_value, "white")
|
|
424
|
+
|
|
425
|
+
# Create status table
|
|
426
|
+
table = Table(show_header=False, box=None)
|
|
427
|
+
table.add_column("Property", style="cyan")
|
|
428
|
+
table.add_column("Value")
|
|
429
|
+
|
|
430
|
+
table.add_row("Deployment ID", str(deployment_id))
|
|
431
|
+
table.add_row("Status", f"[{status_color}]{status_value}[/{status_color}]")
|
|
432
|
+
table.add_row("State", state)
|
|
433
|
+
|
|
434
|
+
if progress > 0:
|
|
435
|
+
table.add_row("Progress", f"{progress}%")
|
|
436
|
+
|
|
437
|
+
if "project_name" in deployment_status:
|
|
438
|
+
table.add_row("Project", deployment_status["project_name"])
|
|
439
|
+
|
|
440
|
+
if "created_at" in deployment_status:
|
|
441
|
+
table.add_row("Created", deployment_status["created_at"])
|
|
442
|
+
|
|
443
|
+
if "finished_at" in deployment_status:
|
|
444
|
+
table.add_row("Finished", deployment_status["finished_at"])
|
|
445
|
+
|
|
446
|
+
if "url" in deployment_status:
|
|
447
|
+
table.add_row("URL", f"[link]{deployment_status['url']}[/link]")
|
|
448
|
+
|
|
449
|
+
if "ip_address" in deployment_status:
|
|
450
|
+
table.add_row("IP Address", deployment_status["ip_address"])
|
|
451
|
+
|
|
452
|
+
panel = Panel(table, title="[bold]Deployment Status[/bold]", border_style=status_color)
|
|
453
|
+
console.print(panel)
|
|
454
|
+
|
|
455
|
+
# Show error if failed
|
|
456
|
+
if status_value == "FAILED" and "error" in deployment_status:
|
|
457
|
+
error_panel = Panel(
|
|
458
|
+
deployment_status["error"],
|
|
459
|
+
title="[bold red]Error[/bold red]",
|
|
460
|
+
border_style="red",
|
|
461
|
+
)
|
|
462
|
+
console.print("\n", error_panel)
|
|
463
|
+
|
|
464
|
+
console.print("\n[bold]Troubleshooting:[/bold]")
|
|
465
|
+
console.print(f" • View logs: [cyan]xenfra logs {deployment_id}[/cyan]")
|
|
466
|
+
console.print(f" • Diagnose: [cyan]xenfra diagnose {deployment_id}[/cyan]")
|
|
467
|
+
|
|
468
|
+
# Show next steps based on status
|
|
469
|
+
elif status_value == "SUCCESS":
|
|
470
|
+
console.print("\n[bold green]Deployment successful! 🎉[/bold green]")
|
|
471
|
+
if "url" in deployment_status:
|
|
472
|
+
console.print(f" • Visit: [link]{deployment_status['url']}[/link]")
|
|
473
|
+
|
|
474
|
+
elif status_value in ["PENDING", "IN_PROGRESS"]:
|
|
475
|
+
console.print("\n[bold]Deployment in progress...[/bold]")
|
|
476
|
+
console.print(f" • View logs: [cyan]xenfra logs {deployment_id}[/cyan]")
|
|
477
|
+
console.print(f" • Check again: [cyan]xenfra status {deployment_id}[/cyan]")
|
|
478
|
+
|
|
479
|
+
except XenfraAPIError as e:
|
|
480
|
+
console.print(f"[bold red]API Error: {e.detail}[/bold red]")
|
|
481
|
+
except XenfraError as e:
|
|
482
|
+
console.print(f"[bold red]Error: {e}[/bold red]")
|
|
483
|
+
except click.Abort:
|
|
484
|
+
pass
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
@click.command()
|
|
488
|
+
@click.argument("deployment-id")
|
|
489
|
+
@click.option("--format", "output_format", type=click.Choice(["detailed", "summary"], case_sensitive=False), default="detailed", help="Report format (detailed or summary)")
|
|
490
|
+
def report(deployment_id, output_format):
|
|
491
|
+
"""
|
|
492
|
+
Generate deployment report with self-healing events.
|
|
493
|
+
|
|
494
|
+
Shows comprehensive deployment information including:
|
|
495
|
+
- Deployment status and timeline
|
|
496
|
+
- Self-healing attempts and outcomes
|
|
497
|
+
- Patches applied during healing
|
|
498
|
+
- Statistics and metrics
|
|
499
|
+
"""
|
|
500
|
+
try:
|
|
501
|
+
# Validate deployment ID
|
|
502
|
+
is_valid, error_msg = validate_deployment_id(deployment_id)
|
|
503
|
+
if not is_valid:
|
|
504
|
+
console.print(f"[bold red]Invalid deployment ID: {error_msg}[/bold red]")
|
|
505
|
+
raise click.Abort()
|
|
506
|
+
|
|
507
|
+
with get_client() as client:
|
|
508
|
+
console.print(f"[cyan]Generating report for deployment {deployment_id}...[/cyan]\n")
|
|
509
|
+
|
|
510
|
+
# Get deployment status
|
|
511
|
+
try:
|
|
512
|
+
deployment_status = client.deployments.get_status(deployment_id)
|
|
513
|
+
except XenfraAPIError as e:
|
|
514
|
+
console.print(f"[bold red]Error fetching deployment status: {e.detail}[/bold red]")
|
|
515
|
+
raise click.Abort()
|
|
516
|
+
|
|
517
|
+
# Get deployment logs
|
|
518
|
+
try:
|
|
519
|
+
logs = client.deployments.get_logs(deployment_id)
|
|
520
|
+
except XenfraAPIError:
|
|
521
|
+
logs = None
|
|
522
|
+
|
|
523
|
+
# Parse status
|
|
524
|
+
status_value = deployment_status.get("status", "UNKNOWN")
|
|
525
|
+
state = deployment_status.get("state", "unknown")
|
|
526
|
+
progress = deployment_status.get("progress", 0)
|
|
527
|
+
|
|
528
|
+
# Status color mapping
|
|
529
|
+
status_color = {
|
|
530
|
+
"PENDING": "yellow",
|
|
531
|
+
"IN_PROGRESS": "cyan",
|
|
532
|
+
"SUCCESS": "green",
|
|
533
|
+
"FAILED": "red",
|
|
534
|
+
"CANCELLED": "dim",
|
|
535
|
+
}.get(status_value, "white")
|
|
536
|
+
|
|
537
|
+
# Calculate statistics from logs
|
|
538
|
+
heal_attempts = logs.count("🤖 Analyzing failure") if logs else 0
|
|
539
|
+
patches_applied = logs.count("✓ Patch applied") if logs else 0
|
|
540
|
+
diagnoses = logs.count("🔍 Diagnosis") if logs else 0
|
|
541
|
+
|
|
542
|
+
# Create main report table
|
|
543
|
+
report_table = Table(show_header=True, box=None)
|
|
544
|
+
report_table.add_column("Property", style="cyan", width=25)
|
|
545
|
+
report_table.add_column("Value", style="white")
|
|
546
|
+
|
|
547
|
+
report_table.add_row("Deployment ID", str(deployment_id))
|
|
548
|
+
report_table.add_row("Status", f"[{status_color}]{status_value}[/{status_color}]")
|
|
549
|
+
report_table.add_row("State", state)
|
|
550
|
+
|
|
551
|
+
if progress > 0:
|
|
552
|
+
report_table.add_row("Progress", f"{progress}%")
|
|
553
|
+
|
|
554
|
+
if "project_name" in deployment_status:
|
|
555
|
+
report_table.add_row("Project", deployment_status["project_name"])
|
|
556
|
+
|
|
557
|
+
if "created_at" in deployment_status:
|
|
558
|
+
report_table.add_row("Created", deployment_status["created_at"])
|
|
559
|
+
|
|
560
|
+
if "finished_at" in deployment_status:
|
|
561
|
+
report_table.add_row("Finished", deployment_status["finished_at"])
|
|
562
|
+
|
|
563
|
+
if "url" in deployment_status:
|
|
564
|
+
report_table.add_row("URL", f"[link]{deployment_status['url']}[/link]")
|
|
565
|
+
|
|
566
|
+
if "ip_address" in deployment_status:
|
|
567
|
+
report_table.add_row("IP Address", deployment_status["ip_address"])
|
|
568
|
+
|
|
569
|
+
# Self-healing statistics
|
|
570
|
+
report_table.add_row("", "") # Separator
|
|
571
|
+
report_table.add_row("[bold]Self-Healing Stats[/bold]", "")
|
|
572
|
+
report_table.add_row("Healing Attempts", str(heal_attempts))
|
|
573
|
+
report_table.add_row("Patches Applied", str(patches_applied))
|
|
574
|
+
report_table.add_row("Diagnoses Performed", str(diagnoses))
|
|
575
|
+
|
|
576
|
+
if heal_attempts > 0:
|
|
577
|
+
success_rate = (patches_applied / heal_attempts * 100) if heal_attempts > 0 else 0
|
|
578
|
+
report_table.add_row("Healing Success Rate", f"{success_rate:.1f}%")
|
|
579
|
+
|
|
580
|
+
# Display main report
|
|
581
|
+
console.print(Panel(report_table, title="[bold]Deployment Report[/bold]", border_style=status_color))
|
|
582
|
+
|
|
583
|
+
# Detailed format includes timeline and healing events
|
|
584
|
+
if output_format == "detailed" and logs:
|
|
585
|
+
console.print("\n[bold]Self-Healing Timeline[/bold]\n")
|
|
586
|
+
|
|
587
|
+
# Extract healing events from logs
|
|
588
|
+
log_lines = logs.split("\n")
|
|
589
|
+
timeline_entries = []
|
|
590
|
+
|
|
591
|
+
for i, line in enumerate(log_lines):
|
|
592
|
+
if "🤖 Analyzing failure" in line:
|
|
593
|
+
attempt_match = None
|
|
594
|
+
# Try to find attempt number in surrounding lines
|
|
595
|
+
for j in range(max(0, i-5), min(len(log_lines), i+10)):
|
|
596
|
+
if "attempt" in log_lines[j].lower():
|
|
597
|
+
timeline_entries.append(("Healing Attempt", log_lines[j].strip()))
|
|
598
|
+
break
|
|
599
|
+
elif "🔍 Diagnosis" in line or "Diagnosis" in line:
|
|
600
|
+
# Extract diagnosis text from next few lines
|
|
601
|
+
diagnosis_text = line.strip()
|
|
602
|
+
if i+1 < len(log_lines) and log_lines[i+1].strip():
|
|
603
|
+
diagnosis_text += "\n " + log_lines[i+1].strip()[:100]
|
|
604
|
+
timeline_entries.append(("Diagnosis", diagnosis_text))
|
|
605
|
+
elif "✓ Patch applied" in line or "Patch applied" in line:
|
|
606
|
+
timeline_entries.append(("Patch Applied", line.strip()))
|
|
607
|
+
elif "🔄 Retrying deployment" in line:
|
|
608
|
+
timeline_entries.append(("Retry", line.strip()))
|
|
609
|
+
|
|
610
|
+
if timeline_entries:
|
|
611
|
+
timeline_table = Table(show_header=True, box=None)
|
|
612
|
+
timeline_table.add_column("Event", style="cyan", width=20)
|
|
613
|
+
timeline_table.add_column("Details", style="white")
|
|
614
|
+
|
|
615
|
+
for event_type, details in timeline_entries[:20]: # Limit to 20 entries
|
|
616
|
+
timeline_table.add_row(event_type, details)
|
|
617
|
+
|
|
618
|
+
console.print(timeline_table)
|
|
619
|
+
else:
|
|
620
|
+
console.print("[dim]No self-healing events detected in logs.[/dim]")
|
|
621
|
+
|
|
622
|
+
# Show error if failed
|
|
623
|
+
if status_value == "FAILED":
|
|
624
|
+
console.print("\n[bold red]⚠ Deployment Failed[/bold red]")
|
|
625
|
+
if "error" in deployment_status:
|
|
626
|
+
error_panel = Panel(
|
|
627
|
+
deployment_status["error"],
|
|
628
|
+
title="[bold red]Error Details[/bold red]",
|
|
629
|
+
border_style="red",
|
|
630
|
+
)
|
|
631
|
+
console.print("\n", error_panel)
|
|
632
|
+
|
|
633
|
+
console.print("\n[bold]Troubleshooting:[/bold]")
|
|
634
|
+
console.print(f" • View logs: [cyan]xenfra logs {deployment_id}[/cyan]")
|
|
635
|
+
console.print(f" • Diagnose: [cyan]xenfra diagnose {deployment_id}[/cyan]")
|
|
636
|
+
console.print(f" • View status: [cyan]xenfra status {deployment_id}[/cyan]")
|
|
637
|
+
|
|
638
|
+
# Success summary
|
|
639
|
+
elif status_value == "SUCCESS":
|
|
640
|
+
console.print("\n[bold green]✓ Deployment Successful[/bold green]")
|
|
641
|
+
if heal_attempts > 0:
|
|
642
|
+
console.print(f"[dim]Deployment succeeded after {heal_attempts} self-healing attempt(s).[/dim]")
|
|
643
|
+
if "url" in deployment_status:
|
|
644
|
+
console.print(f" • Visit: [link]{deployment_status['url']}[/link]")
|
|
645
|
+
|
|
646
|
+
# Export summary format (JSON-like structure for programmatic use)
|
|
647
|
+
if output_format == "summary":
|
|
648
|
+
console.print("\n[bold]Summary Format:[/bold]")
|
|
649
|
+
import json
|
|
650
|
+
summary = {
|
|
651
|
+
"deployment_id": deployment_id,
|
|
652
|
+
"status": status_value,
|
|
653
|
+
"healing_attempts": heal_attempts,
|
|
654
|
+
"patches_applied": patches_applied,
|
|
655
|
+
"success": status_value == "SUCCESS",
|
|
656
|
+
}
|
|
657
|
+
console.print(f"[dim]{json.dumps(summary, indent=2)}[/dim]")
|
|
658
|
+
|
|
659
|
+
except XenfraAPIError as e:
|
|
660
|
+
console.print(f"[bold red]API Error: {e.detail}[/bold red]")
|
|
661
|
+
except XenfraError as e:
|
|
662
|
+
console.print(f"[bold red]Error: {e}[/bold red]")
|
|
663
|
+
except click.Abort:
|
|
664
|
+
pass
|
|
665
|
+
except Exception as e:
|
|
666
|
+
console.print(f"[bold red]Unexpected error: {e}[/bold red]")
|