xenfra 0.2.1__py3-none-any.whl → 0.2.3__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/__init__.py +0 -1
- xenfra/commands/__init__.py +3 -0
- xenfra/commands/auth.py +186 -0
- xenfra/commands/deployments.py +443 -0
- xenfra/commands/intelligence.py +312 -0
- xenfra/commands/projects.py +163 -0
- xenfra/commands/security_cmd.py +235 -0
- xenfra/main.py +70 -0
- xenfra/utils/__init__.py +3 -0
- xenfra/utils/auth.py +148 -0
- xenfra/utils/codebase.py +86 -0
- xenfra/utils/config.py +278 -0
- xenfra/utils/security.py +350 -0
- xenfra-0.2.3.dist-info/METADATA +115 -0
- xenfra-0.2.3.dist-info/RECORD +17 -0
- xenfra-0.2.3.dist-info/entry_points.txt +3 -0
- xenfra/api/auth.py +0 -51
- xenfra/api/billing.py +0 -80
- xenfra/api/connections.py +0 -163
- xenfra/api/main.py +0 -175
- xenfra/api/webhooks.py +0 -146
- xenfra/cli/main.py +0 -211
- xenfra/config.py +0 -24
- xenfra/db/models.py +0 -51
- xenfra/db/session.py +0 -17
- xenfra/dependencies.py +0 -35
- xenfra/dockerizer.py +0 -89
- xenfra/engine.py +0 -292
- xenfra/mcp_client.py +0 -149
- xenfra/models.py +0 -54
- xenfra/recipes.py +0 -23
- xenfra/security.py +0 -58
- xenfra/templates/Dockerfile.j2 +0 -25
- xenfra/templates/cloud-init.sh.j2 +0 -68
- xenfra/templates/docker-compose.yml.j2 +0 -33
- xenfra/utils.py +0 -69
- xenfra-0.2.1.dist-info/METADATA +0 -95
- xenfra-0.2.1.dist-info/RECORD +0 -25
- xenfra-0.2.1.dist-info/entry_points.txt +0 -3
- {xenfra-0.2.1.dist-info → xenfra-0.2.3.dist-info}/WHEEL +0 -0
xenfra/__init__.py
CHANGED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
# This file makes src/xenfra a Python package.
|
xenfra/commands/auth.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication commands for Xenfra CLI.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import secrets
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import webbrowser
|
|
10
|
+
from http.server import HTTPServer
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
import httpx
|
|
14
|
+
import keyring
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
|
|
17
|
+
from ..utils.auth import (
|
|
18
|
+
API_BASE_URL,
|
|
19
|
+
CLI_CLIENT_ID,
|
|
20
|
+
CLI_LOCAL_SERVER_END_PORT,
|
|
21
|
+
CLI_LOCAL_SERVER_START_PORT,
|
|
22
|
+
CLI_REDIRECT_PATH,
|
|
23
|
+
SERVICE_ID,
|
|
24
|
+
AuthCallbackHandler,
|
|
25
|
+
clear_tokens,
|
|
26
|
+
get_auth_token,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
console = Console()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@click.group()
|
|
33
|
+
def auth():
|
|
34
|
+
"""Authentication commands."""
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@auth.command()
|
|
39
|
+
def login():
|
|
40
|
+
"""Login to Xenfra using OAuth2 PKCE flow."""
|
|
41
|
+
global oauth_data
|
|
42
|
+
oauth_data = {"code": None, "state": None, "error": None}
|
|
43
|
+
|
|
44
|
+
# 1. Generate PKCE parameters
|
|
45
|
+
code_verifier = secrets.token_urlsafe(96)
|
|
46
|
+
code_challenge = (
|
|
47
|
+
base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest())
|
|
48
|
+
.decode()
|
|
49
|
+
.rstrip("=")
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# 2. Generate state for CSRF protection
|
|
53
|
+
state = secrets.token_urlsafe(32)
|
|
54
|
+
|
|
55
|
+
# 3. Start local HTTP server
|
|
56
|
+
server_port = None
|
|
57
|
+
httpd_instance = None
|
|
58
|
+
for port in range(CLI_LOCAL_SERVER_START_PORT, CLI_LOCAL_SERVER_END_PORT + 1):
|
|
59
|
+
try:
|
|
60
|
+
server_address = ("127.0.0.1", port)
|
|
61
|
+
httpd_instance = HTTPServer(server_address, AuthCallbackHandler)
|
|
62
|
+
server_port = port
|
|
63
|
+
break
|
|
64
|
+
except OSError:
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
if not server_port:
|
|
68
|
+
console.print(
|
|
69
|
+
f"[bold red]Error: No available ports in range {CLI_LOCAL_SERVER_START_PORT}-{CLI_LOCAL_SERVER_END_PORT}[/bold red]"
|
|
70
|
+
)
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
redirect_uri = f"http://localhost:{server_port}{CLI_REDIRECT_PATH}"
|
|
74
|
+
|
|
75
|
+
# 4. Construct Authorization URL
|
|
76
|
+
auth_url = (
|
|
77
|
+
f"{API_BASE_URL}/auth/authorize?"
|
|
78
|
+
f"client_id={CLI_CLIENT_ID}&"
|
|
79
|
+
f"redirect_uri={urllib.parse.quote(redirect_uri)}&"
|
|
80
|
+
f"response_type=code&"
|
|
81
|
+
f"scope={urllib.parse.quote('openid profile')}&"
|
|
82
|
+
f"state={state}&"
|
|
83
|
+
f"code_challenge={code_challenge}&"
|
|
84
|
+
f"code_challenge_method=S256"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
console.print("[bold blue]Opening browser for login...[/bold blue]")
|
|
88
|
+
console.print(
|
|
89
|
+
f"[dim]If browser doesn't open, navigate to:[/dim]\n[link={auth_url}]{auth_url}[/link]"
|
|
90
|
+
)
|
|
91
|
+
webbrowser.open(auth_url)
|
|
92
|
+
|
|
93
|
+
# 5. Run local server to capture redirect
|
|
94
|
+
try:
|
|
95
|
+
AuthCallbackHandler.server = httpd_instance # type: ignore
|
|
96
|
+
httpd_instance.handle_request() # type: ignore
|
|
97
|
+
console.print("[dim]Local OAuth server shut down.[/dim]")
|
|
98
|
+
except Exception as e:
|
|
99
|
+
console.print(f"[bold red]Error running OAuth server: {e}[/bold red]")
|
|
100
|
+
if httpd_instance:
|
|
101
|
+
httpd_instance.server_close()
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
if oauth_data["error"]:
|
|
105
|
+
console.print(f"[bold red]Login failed: {oauth_data['error']}[/bold red]")
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
if not oauth_data["code"]:
|
|
109
|
+
console.print("[bold red]Login failed: No authorization code received.[/bold red]")
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
# 6. Verify state
|
|
113
|
+
if oauth_data["state"] != state:
|
|
114
|
+
console.print("[bold red]Login failed: State mismatch (possible CSRF attack)[/bold red]")
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
# 7. Exchange code for tokens
|
|
118
|
+
console.print("[bold cyan]Exchanging authorization code for tokens...[/bold cyan]")
|
|
119
|
+
try:
|
|
120
|
+
with httpx.Client() as client:
|
|
121
|
+
response = client.post(
|
|
122
|
+
f"{API_BASE_URL}/auth/token",
|
|
123
|
+
data={
|
|
124
|
+
"grant_type": "authorization_code",
|
|
125
|
+
"client_id": CLI_CLIENT_ID,
|
|
126
|
+
"code": oauth_data["code"],
|
|
127
|
+
"code_verifier": code_verifier,
|
|
128
|
+
"redirect_uri": redirect_uri,
|
|
129
|
+
},
|
|
130
|
+
)
|
|
131
|
+
response.raise_for_status()
|
|
132
|
+
token_data = response.json()
|
|
133
|
+
access_token = token_data.get("access_token")
|
|
134
|
+
refresh_token = token_data.get("refresh_token")
|
|
135
|
+
|
|
136
|
+
if access_token and refresh_token:
|
|
137
|
+
keyring.set_password(SERVICE_ID, "access_token", access_token)
|
|
138
|
+
keyring.set_password(SERVICE_ID, "refresh_token", refresh_token)
|
|
139
|
+
console.print("[bold green]Login successful! Tokens saved securely.[/bold green]")
|
|
140
|
+
else:
|
|
141
|
+
console.print("[bold red]Login failed: No tokens received.[/bold red]")
|
|
142
|
+
except httpx.RequestError as exc:
|
|
143
|
+
console.print(f"[bold red]Token exchange failed: {exc}[/bold red]")
|
|
144
|
+
except httpx.HTTPStatusError as exc:
|
|
145
|
+
console.print(f"[bold red]Token exchange failed: {exc.response.status_code}[/bold red]")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@auth.command()
|
|
149
|
+
def logout():
|
|
150
|
+
"""Logout and clear stored tokens."""
|
|
151
|
+
clear_tokens()
|
|
152
|
+
console.print("[bold green]Logged out successfully.[/bold green]")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@auth.command()
|
|
156
|
+
@click.option("--token", is_flag=True, help="Show access token")
|
|
157
|
+
def whoami(token):
|
|
158
|
+
"""Show current authenticated user."""
|
|
159
|
+
access_token = get_auth_token()
|
|
160
|
+
|
|
161
|
+
if not access_token:
|
|
162
|
+
console.print("[bold red]Not logged in. Run 'xenfra login' first.[/bold red]")
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
from jose import jwt
|
|
167
|
+
|
|
168
|
+
# For display purposes only, in a CLI context where the token has just
|
|
169
|
+
# been retrieved from a secure source (keyring), we can disable
|
|
170
|
+
# signature verification.
|
|
171
|
+
#
|
|
172
|
+
# SECURITY BEST PRACTICE: In a real application, especially a server,
|
|
173
|
+
# you would fetch the public key from the SSO's JWKS endpoint and
|
|
174
|
+
# fully verify the token's signature to ensure its integrity.
|
|
175
|
+
claims = jwt.decode(
|
|
176
|
+
access_token, options={"verify_signature": False} # OK for local display
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
console.print("[bold green]Logged in as:[/bold green]")
|
|
180
|
+
console.print(f" User ID: {claims.get('sub')}")
|
|
181
|
+
console.print(f" Email: {claims.get('email', 'N/A')}")
|
|
182
|
+
|
|
183
|
+
if token:
|
|
184
|
+
console.print(f"\n[dim]Access Token:[/dim]\n{access_token}")
|
|
185
|
+
except Exception as e:
|
|
186
|
+
console.print(f"[bold red]Failed to decode token: {e}[/bold red]")
|
|
@@ -0,0 +1,443 @@
|
|
|
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
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
# Maximum number of retry attempts for auto-healing
|
|
22
|
+
MAX_RETRY_ATTEMPTS = 3
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_client() -> XenfraClient:
|
|
26
|
+
"""Get authenticated SDK client."""
|
|
27
|
+
token = get_auth_token()
|
|
28
|
+
if not token:
|
|
29
|
+
console.print("[bold red]Not logged in. Run 'xenfra auth login' first.[/bold red]")
|
|
30
|
+
raise click.Abort()
|
|
31
|
+
|
|
32
|
+
return XenfraClient(token=token, api_url=API_BASE_URL)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def show_diagnosis_panel(diagnosis: str, suggestion: str):
|
|
36
|
+
"""Display diagnosis and suggestion in formatted panels."""
|
|
37
|
+
console.print()
|
|
38
|
+
console.print(Panel(diagnosis, title="[bold red]🔍 Diagnosis[/bold red]", border_style="red"))
|
|
39
|
+
console.print()
|
|
40
|
+
console.print(
|
|
41
|
+
Panel(suggestion, title="[bold yellow]💡 Suggestion[/bold yellow]", border_style="yellow")
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def show_patch_preview(patch_data: dict):
|
|
46
|
+
"""Show a preview of the patch that will be applied."""
|
|
47
|
+
console.print()
|
|
48
|
+
console.print("[bold green]🔧 Automatic Fix Available[/bold green]")
|
|
49
|
+
console.print(f" [cyan]File:[/cyan] {patch_data.get('file')}")
|
|
50
|
+
console.print(f" [cyan]Operation:[/cyan] {patch_data.get('operation')}")
|
|
51
|
+
console.print(f" [cyan]Value:[/cyan] {patch_data.get('value')}")
|
|
52
|
+
console.print()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def zen_nod_workflow(logs: str, client: XenfraClient, attempt: int) -> bool:
|
|
56
|
+
"""
|
|
57
|
+
Execute the Zen Nod auto-healing workflow.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
logs: Deployment error logs
|
|
61
|
+
client: Authenticated SDK client
|
|
62
|
+
attempt: Current attempt number
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
True if patch was applied and user wants to retry, False otherwise
|
|
66
|
+
"""
|
|
67
|
+
console.print()
|
|
68
|
+
console.print(f"[cyan]🤖 Analyzing failure (attempt {attempt}/{MAX_RETRY_ATTEMPTS})...[/cyan]")
|
|
69
|
+
|
|
70
|
+
# Scrub sensitive data from logs
|
|
71
|
+
scrubbed_logs = scrub_logs(logs)
|
|
72
|
+
|
|
73
|
+
# Diagnose with AI
|
|
74
|
+
try:
|
|
75
|
+
diagnosis_result = client.intelligence.diagnose(scrubbed_logs)
|
|
76
|
+
except Exception as e:
|
|
77
|
+
console.print(f"[yellow]Could not diagnose failure: {e}[/yellow]")
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
# Show diagnosis
|
|
81
|
+
show_diagnosis_panel(diagnosis_result.diagnosis, diagnosis_result.suggestion)
|
|
82
|
+
|
|
83
|
+
# Check if there's an automatic patch
|
|
84
|
+
if diagnosis_result.patch and diagnosis_result.patch.file:
|
|
85
|
+
show_patch_preview(diagnosis_result.patch.model_dump())
|
|
86
|
+
|
|
87
|
+
# Zen Nod confirmation
|
|
88
|
+
if click.confirm("Apply this fix and retry deployment?", default=True):
|
|
89
|
+
try:
|
|
90
|
+
# Apply patch (with automatic backup)
|
|
91
|
+
backup_path = apply_patch(diagnosis_result.patch.model_dump())
|
|
92
|
+
console.print("[bold green]✓ Patch applied[/bold green]")
|
|
93
|
+
if backup_path:
|
|
94
|
+
console.print(f"[dim]Backup saved: {backup_path}[/dim]")
|
|
95
|
+
return True # Signal to retry
|
|
96
|
+
except Exception as e:
|
|
97
|
+
console.print(f"[bold red]Failed to apply patch: {e}[/bold red]")
|
|
98
|
+
return False
|
|
99
|
+
else:
|
|
100
|
+
console.print()
|
|
101
|
+
console.print("[yellow]❌ Patch declined. Follow the manual steps above.[/yellow]")
|
|
102
|
+
return False
|
|
103
|
+
else:
|
|
104
|
+
console.print()
|
|
105
|
+
console.print(
|
|
106
|
+
"[yellow]No automatic fix available. Please follow the manual steps above.[/yellow]"
|
|
107
|
+
)
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@click.command()
|
|
112
|
+
@click.option("--project-name", help="Project name (defaults to current directory name)")
|
|
113
|
+
@click.option("--git-repo", help="Git repository URL (if deploying from git)")
|
|
114
|
+
@click.option("--branch", default="main", help="Git branch (default: main)")
|
|
115
|
+
@click.option("--framework", help="Framework override (fastapi, flask, django)")
|
|
116
|
+
@click.option("--no-heal", is_flag=True, help="Disable auto-healing on failure")
|
|
117
|
+
def deploy(project_name, git_repo, branch, framework, no_heal):
|
|
118
|
+
"""
|
|
119
|
+
Deploy current project to DigitalOcean with auto-healing.
|
|
120
|
+
|
|
121
|
+
Deploys your application with zero configuration. The CLI will:
|
|
122
|
+
1. Check for xenfra.yaml (or run init if missing)
|
|
123
|
+
2. Create a deployment
|
|
124
|
+
3. Auto-diagnose and fix failures (unless --no-heal is set or XENFRA_NO_AI=1)
|
|
125
|
+
|
|
126
|
+
Set XENFRA_NO_AI=1 environment variable to disable all AI features.
|
|
127
|
+
"""
|
|
128
|
+
# Check XENFRA_NO_AI environment variable
|
|
129
|
+
no_ai = os.environ.get("XENFRA_NO_AI", "0") == "1"
|
|
130
|
+
if no_ai:
|
|
131
|
+
console.print("[yellow]XENFRA_NO_AI is set. Auto-healing disabled.[/yellow]")
|
|
132
|
+
no_heal = True
|
|
133
|
+
|
|
134
|
+
# Check for xenfra.yaml
|
|
135
|
+
if not has_xenfra_config():
|
|
136
|
+
console.print("[yellow]No xenfra.yaml found.[/yellow]")
|
|
137
|
+
if click.confirm("Run 'xenfra init' to create configuration?", default=True):
|
|
138
|
+
from .intelligence import init
|
|
139
|
+
|
|
140
|
+
ctx = click.get_current_context()
|
|
141
|
+
ctx.invoke(init, manual=no_ai, accept_all=False)
|
|
142
|
+
else:
|
|
143
|
+
console.print("[dim]Deployment cancelled.[/dim]")
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
# Default project name to current directory
|
|
147
|
+
if not project_name:
|
|
148
|
+
project_name = os.path.basename(os.getcwd())
|
|
149
|
+
|
|
150
|
+
# Determine deployment source
|
|
151
|
+
if git_repo:
|
|
152
|
+
console.print(f"[cyan]Deploying {project_name} from git repository...[/cyan]")
|
|
153
|
+
else:
|
|
154
|
+
console.print(f"[cyan]Deploying {project_name} from local directory...[/cyan]")
|
|
155
|
+
console.print(
|
|
156
|
+
"[yellow]Local deployment requires code upload (not yet fully implemented).[/yellow]"
|
|
157
|
+
)
|
|
158
|
+
console.print("[dim]Please use --git-repo for now.[/dim]")
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
# Retry loop for auto-healing
|
|
162
|
+
attempt = 0
|
|
163
|
+
deployment_id = None
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
with get_client() as client:
|
|
167
|
+
while attempt < MAX_RETRY_ATTEMPTS:
|
|
168
|
+
# Safety check to prevent infinite loops
|
|
169
|
+
if attempt > MAX_RETRY_ATTEMPTS:
|
|
170
|
+
raise RuntimeError("Safety break: Retry loop exceeded MAX_RETRY_ATTEMPTS.")
|
|
171
|
+
|
|
172
|
+
attempt += 1
|
|
173
|
+
|
|
174
|
+
if attempt > 1:
|
|
175
|
+
console.print(
|
|
176
|
+
f"\n[cyan]🔄 Retrying deployment (attempt {attempt}/{MAX_RETRY_ATTEMPTS})...[/cyan]"
|
|
177
|
+
)
|
|
178
|
+
else:
|
|
179
|
+
console.print("[cyan]Creating deployment...[/cyan]")
|
|
180
|
+
|
|
181
|
+
# Detect framework if not provided
|
|
182
|
+
if not framework:
|
|
183
|
+
console.print("[dim]Auto-detecting framework...[/dim]")
|
|
184
|
+
framework = "fastapi" # Default for now
|
|
185
|
+
|
|
186
|
+
# Create deployment
|
|
187
|
+
try:
|
|
188
|
+
deployment = client.deployments.create(
|
|
189
|
+
project_name=project_name,
|
|
190
|
+
git_repo=git_repo,
|
|
191
|
+
branch=branch,
|
|
192
|
+
framework=framework,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
deployment_id = deployment["deployment_id"]
|
|
196
|
+
console.print(
|
|
197
|
+
f"[bold green]✓[/bold green] Deployment created: [cyan]{deployment_id}[/cyan]"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Show deployment details
|
|
201
|
+
details_table = Table(show_header=False, box=None)
|
|
202
|
+
details_table.add_column("Property", style="cyan")
|
|
203
|
+
details_table.add_column("Value", style="white")
|
|
204
|
+
|
|
205
|
+
details_table.add_row("Deployment ID", str(deployment_id))
|
|
206
|
+
details_table.add_row("Project", project_name)
|
|
207
|
+
if git_repo:
|
|
208
|
+
details_table.add_row("Repository", git_repo)
|
|
209
|
+
details_table.add_row("Branch", branch)
|
|
210
|
+
details_table.add_row("Framework", framework)
|
|
211
|
+
details_table.add_row("Status", deployment.get("status", "PENDING"))
|
|
212
|
+
|
|
213
|
+
panel = Panel(
|
|
214
|
+
details_table,
|
|
215
|
+
title="[bold green]Deployment Started[/bold green]",
|
|
216
|
+
border_style="green",
|
|
217
|
+
)
|
|
218
|
+
console.print(panel)
|
|
219
|
+
|
|
220
|
+
# Show next steps
|
|
221
|
+
console.print("\n[bold]Next steps:[/bold]")
|
|
222
|
+
console.print(f" • Monitor status: [cyan]xenfra status {deployment_id}[/cyan]")
|
|
223
|
+
console.print(f" • View logs: [cyan]xenfra logs {deployment_id}[/cyan]")
|
|
224
|
+
if not no_heal:
|
|
225
|
+
console.print(
|
|
226
|
+
f" • Diagnose issues: [cyan]xenfra diagnose {deployment_id}[/cyan]"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# Success - break out of retry loop
|
|
230
|
+
break
|
|
231
|
+
|
|
232
|
+
except XenfraAPIError as e:
|
|
233
|
+
# Deployment failed
|
|
234
|
+
console.print(f"[bold red]✗ Deployment failed: {e.detail}[/bold red]")
|
|
235
|
+
|
|
236
|
+
# Check if we should auto-heal
|
|
237
|
+
if no_heal or attempt >= MAX_RETRY_ATTEMPTS:
|
|
238
|
+
# No auto-healing or max retries reached
|
|
239
|
+
if attempt >= MAX_RETRY_ATTEMPTS:
|
|
240
|
+
console.print(
|
|
241
|
+
f"\n[bold red]❌ Maximum retry attempts ({MAX_RETRY_ATTEMPTS}) reached.[/bold red]"
|
|
242
|
+
)
|
|
243
|
+
console.print(
|
|
244
|
+
"[yellow]Unable to auto-fix the issue. Please review the errors above.[/yellow]"
|
|
245
|
+
)
|
|
246
|
+
raise
|
|
247
|
+
else:
|
|
248
|
+
# Try to get logs for diagnosis
|
|
249
|
+
error_logs = str(e.detail)
|
|
250
|
+
try:
|
|
251
|
+
if deployment_id:
|
|
252
|
+
# This should be a method in the SDK that returns a string
|
|
253
|
+
logs_response = client.deployments.get_logs(deployment_id)
|
|
254
|
+
if isinstance(logs_response, dict):
|
|
255
|
+
error_logs = logs_response.get("logs", str(e.detail))
|
|
256
|
+
else:
|
|
257
|
+
error_logs = str(logs_response) # Assuming it can be a string
|
|
258
|
+
except Exception as log_err:
|
|
259
|
+
console.print(
|
|
260
|
+
f"[yellow]Warning: Could not fetch detailed logs for diagnosis: {log_err}[/yellow]"
|
|
261
|
+
)
|
|
262
|
+
# Fallback to the initial error detail
|
|
263
|
+
pass
|
|
264
|
+
|
|
265
|
+
# Run Zen Nod workflow
|
|
266
|
+
should_retry = zen_nod_workflow(error_logs, client, attempt)
|
|
267
|
+
|
|
268
|
+
if not should_retry:
|
|
269
|
+
# User declined patch or no patch available
|
|
270
|
+
console.print("\n[dim]Deployment cancelled.[/dim]")
|
|
271
|
+
raise click.Abort()
|
|
272
|
+
|
|
273
|
+
# Continue to next iteration (retry)
|
|
274
|
+
continue
|
|
275
|
+
|
|
276
|
+
except XenfraAPIError as e:
|
|
277
|
+
console.print(f"[bold red]API Error: {e.detail}[/bold red]")
|
|
278
|
+
except XenfraError as e:
|
|
279
|
+
console.print(f"[bold red]Error: {e}[/bold red]")
|
|
280
|
+
except click.Abort:
|
|
281
|
+
pass
|
|
282
|
+
except Exception as e:
|
|
283
|
+
console.print(f"[bold red]Unexpected error: {e}[/bold red]")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@click.command()
|
|
287
|
+
@click.argument("deployment-id")
|
|
288
|
+
@click.option("--follow", "-f", is_flag=True, help="Follow log output (stream)")
|
|
289
|
+
@click.option("--tail", type=int, help="Show last N lines")
|
|
290
|
+
def logs(deployment_id, follow, tail):
|
|
291
|
+
"""
|
|
292
|
+
Stream deployment logs.
|
|
293
|
+
|
|
294
|
+
Shows logs for a specific deployment. Use --follow to stream logs in real-time.
|
|
295
|
+
"""
|
|
296
|
+
try:
|
|
297
|
+
with get_client() as client:
|
|
298
|
+
console.print(f"[cyan]Fetching logs for deployment {deployment_id}...[/cyan]")
|
|
299
|
+
|
|
300
|
+
log_content = client.deployments.get_logs(deployment_id)
|
|
301
|
+
|
|
302
|
+
if not log_content:
|
|
303
|
+
console.print("[yellow]No logs available yet.[/yellow]")
|
|
304
|
+
console.print("[dim]The deployment may still be starting up.[/dim]")
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
# Process logs
|
|
308
|
+
log_lines = log_content.strip().split("\n")
|
|
309
|
+
|
|
310
|
+
# Apply tail if specified
|
|
311
|
+
if tail:
|
|
312
|
+
log_lines = log_lines[-tail:]
|
|
313
|
+
|
|
314
|
+
# Display logs with syntax highlighting
|
|
315
|
+
console.print(f"\n[bold]Logs for deployment {deployment_id}:[/bold]\n")
|
|
316
|
+
|
|
317
|
+
if follow:
|
|
318
|
+
console.print(
|
|
319
|
+
"[yellow]Note: --follow flag not yet implemented (showing static logs)[/yellow]\n"
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
# Display logs
|
|
323
|
+
for line in log_lines:
|
|
324
|
+
# Color-code based on log level
|
|
325
|
+
if "ERROR" in line or "FAILED" in line:
|
|
326
|
+
console.print(f"[red]{line}[/red]")
|
|
327
|
+
elif "WARN" in line or "WARNING" in line:
|
|
328
|
+
console.print(f"[yellow]{line}[/yellow]")
|
|
329
|
+
elif "SUCCESS" in line or "COMPLETED" in line:
|
|
330
|
+
console.print(f"[green]{line}[/green]")
|
|
331
|
+
elif "INFO" in line:
|
|
332
|
+
console.print(f"[cyan]{line}[/cyan]")
|
|
333
|
+
else:
|
|
334
|
+
console.print(line)
|
|
335
|
+
|
|
336
|
+
except XenfraAPIError as e:
|
|
337
|
+
console.print(f"[bold red]API Error: {e.detail}[/bold red]")
|
|
338
|
+
except XenfraError as e:
|
|
339
|
+
console.print(f"[bold red]Error: {e}[/bold red]")
|
|
340
|
+
except click.Abort:
|
|
341
|
+
pass
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@click.command()
|
|
345
|
+
@click.argument("deployment-id", required=False)
|
|
346
|
+
@click.option("--watch", "-w", is_flag=True, help="Watch status updates")
|
|
347
|
+
def status(deployment_id, watch):
|
|
348
|
+
"""
|
|
349
|
+
Show deployment status.
|
|
350
|
+
|
|
351
|
+
Displays current status, progress, and details for a deployment.
|
|
352
|
+
Use --watch to monitor status in real-time.
|
|
353
|
+
"""
|
|
354
|
+
try:
|
|
355
|
+
if not deployment_id:
|
|
356
|
+
console.print("[yellow]No deployment ID provided.[/yellow]")
|
|
357
|
+
console.print("[dim]Usage: xenfra status <deployment-id>[/dim]")
|
|
358
|
+
return
|
|
359
|
+
|
|
360
|
+
with get_client() as client:
|
|
361
|
+
console.print(f"[cyan]Fetching status for deployment {deployment_id}...[/cyan]")
|
|
362
|
+
|
|
363
|
+
deployment_status = client.deployments.get_status(deployment_id)
|
|
364
|
+
|
|
365
|
+
if watch:
|
|
366
|
+
console.print(
|
|
367
|
+
"[yellow]Note: --watch flag not yet implemented (showing current status)[/yellow]\n"
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
# Display status
|
|
371
|
+
status_value = deployment_status.get("status", "UNKNOWN")
|
|
372
|
+
state = deployment_status.get("state", "unknown")
|
|
373
|
+
progress = deployment_status.get("progress", 0)
|
|
374
|
+
|
|
375
|
+
# Status panel
|
|
376
|
+
status_color = {
|
|
377
|
+
"PENDING": "yellow",
|
|
378
|
+
"IN_PROGRESS": "cyan",
|
|
379
|
+
"SUCCESS": "green",
|
|
380
|
+
"FAILED": "red",
|
|
381
|
+
"CANCELLED": "dim",
|
|
382
|
+
}.get(status_value, "white")
|
|
383
|
+
|
|
384
|
+
# Create status table
|
|
385
|
+
table = Table(show_header=False, box=None)
|
|
386
|
+
table.add_column("Property", style="cyan")
|
|
387
|
+
table.add_column("Value")
|
|
388
|
+
|
|
389
|
+
table.add_row("Deployment ID", str(deployment_id))
|
|
390
|
+
table.add_row("Status", f"[{status_color}]{status_value}[/{status_color}]")
|
|
391
|
+
table.add_row("State", state)
|
|
392
|
+
|
|
393
|
+
if progress > 0:
|
|
394
|
+
table.add_row("Progress", f"{progress}%")
|
|
395
|
+
|
|
396
|
+
if "project_name" in deployment_status:
|
|
397
|
+
table.add_row("Project", deployment_status["project_name"])
|
|
398
|
+
|
|
399
|
+
if "created_at" in deployment_status:
|
|
400
|
+
table.add_row("Created", deployment_status["created_at"])
|
|
401
|
+
|
|
402
|
+
if "finished_at" in deployment_status:
|
|
403
|
+
table.add_row("Finished", deployment_status["finished_at"])
|
|
404
|
+
|
|
405
|
+
if "url" in deployment_status:
|
|
406
|
+
table.add_row("URL", f"[link]{deployment_status['url']}[/link]")
|
|
407
|
+
|
|
408
|
+
if "ip_address" in deployment_status:
|
|
409
|
+
table.add_row("IP Address", deployment_status["ip_address"])
|
|
410
|
+
|
|
411
|
+
panel = Panel(table, title="[bold]Deployment Status[/bold]", border_style=status_color)
|
|
412
|
+
console.print(panel)
|
|
413
|
+
|
|
414
|
+
# Show error if failed
|
|
415
|
+
if status_value == "FAILED" and "error" in deployment_status:
|
|
416
|
+
error_panel = Panel(
|
|
417
|
+
deployment_status["error"],
|
|
418
|
+
title="[bold red]Error[/bold red]",
|
|
419
|
+
border_style="red",
|
|
420
|
+
)
|
|
421
|
+
console.print("\n", error_panel)
|
|
422
|
+
|
|
423
|
+
console.print("\n[bold]Troubleshooting:[/bold]")
|
|
424
|
+
console.print(f" • View logs: [cyan]xenfra logs {deployment_id}[/cyan]")
|
|
425
|
+
console.print(f" • Diagnose: [cyan]xenfra diagnose {deployment_id}[/cyan]")
|
|
426
|
+
|
|
427
|
+
# Show next steps based on status
|
|
428
|
+
elif status_value == "SUCCESS":
|
|
429
|
+
console.print("\n[bold green]Deployment successful! 🎉[/bold green]")
|
|
430
|
+
if "url" in deployment_status:
|
|
431
|
+
console.print(f" • Visit: [link]{deployment_status['url']}[/link]")
|
|
432
|
+
|
|
433
|
+
elif status_value in ["PENDING", "IN_PROGRESS"]:
|
|
434
|
+
console.print("\n[bold]Deployment in progress...[/bold]")
|
|
435
|
+
console.print(f" • View logs: [cyan]xenfra logs {deployment_id}[/cyan]")
|
|
436
|
+
console.print(f" • Check again: [cyan]xenfra status {deployment_id}[/cyan]")
|
|
437
|
+
|
|
438
|
+
except XenfraAPIError as e:
|
|
439
|
+
console.print(f"[bold red]API Error: {e.detail}[/bold red]")
|
|
440
|
+
except XenfraError as e:
|
|
441
|
+
console.print(f"[bold red]Error: {e}[/bold red]")
|
|
442
|
+
except click.Abort:
|
|
443
|
+
pass
|