xenfra-sdk 0.1.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_sdk/__init__.py +21 -0
- xenfra_sdk/cli/__init__.py +0 -0
- xenfra_sdk/cli/main.py +226 -0
- xenfra_sdk/client.py +69 -0
- xenfra_sdk/client_with_hooks.py +275 -0
- xenfra_sdk/config.py +26 -0
- xenfra_sdk/db/__init__.py +0 -0
- xenfra_sdk/db/models.py +27 -0
- xenfra_sdk/db/session.py +30 -0
- xenfra_sdk/dependencies.py +38 -0
- xenfra_sdk/dockerizer.py +87 -0
- xenfra_sdk/engine.py +388 -0
- xenfra_sdk/exceptions.py +19 -0
- xenfra_sdk/mcp_client.py +154 -0
- xenfra_sdk/models.py +170 -0
- xenfra_sdk/patterns.json +14 -0
- xenfra_sdk/privacy.py +118 -0
- xenfra_sdk/recipes.py +25 -0
- xenfra_sdk/resources/__init__.py +0 -0
- xenfra_sdk/resources/base.py +3 -0
- xenfra_sdk/resources/deployments.py +83 -0
- xenfra_sdk/resources/intelligence.py +102 -0
- xenfra_sdk/resources/projects.py +95 -0
- xenfra_sdk/security.py +41 -0
- xenfra_sdk/templates/Dockerfile.j2 +25 -0
- xenfra_sdk/templates/cloud-init.sh.j2 +68 -0
- xenfra_sdk/templates/docker-compose.yml.j2 +33 -0
- xenfra_sdk/utils.py +70 -0
- xenfra_sdk-0.1.0.dist-info/METADATA +118 -0
- xenfra_sdk-0.1.0.dist-info/RECORD +31 -0
- xenfra_sdk-0.1.0.dist-info/WHEEL +4 -0
xenfra_sdk/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# This file makes src/xenfra_sdk a Python package.
|
|
2
|
+
|
|
3
|
+
from .client import XenfraClient
|
|
4
|
+
from .exceptions import AuthenticationError, XenfraAPIError, XenfraError
|
|
5
|
+
from .models import (
|
|
6
|
+
CodebaseAnalysisResponse,
|
|
7
|
+
DiagnosisResponse,
|
|
8
|
+
PatchObject,
|
|
9
|
+
ProjectRead,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"XenfraClient",
|
|
14
|
+
"XenfraError",
|
|
15
|
+
"AuthenticationError",
|
|
16
|
+
"XenfraAPIError",
|
|
17
|
+
"DiagnosisResponse",
|
|
18
|
+
"CodebaseAnalysisResponse",
|
|
19
|
+
"PatchObject",
|
|
20
|
+
"ProjectRead",
|
|
21
|
+
]
|
|
File without changes
|
xenfra_sdk/cli/main.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import click
|
|
2
|
+
import yaml
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
from xenfra_sdk import dockerizer
|
|
6
|
+
from xenfra_sdk.db.session import create_db_and_tables
|
|
7
|
+
from xenfra_sdk.engine import DeploymentError, InfraEngine
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.group()
|
|
13
|
+
@click.pass_context
|
|
14
|
+
def main(ctx):
|
|
15
|
+
"""
|
|
16
|
+
Xenfra CLI: A 'Zen Mode' infrastructure engine for Python developers.
|
|
17
|
+
"""
|
|
18
|
+
try:
|
|
19
|
+
create_db_and_tables()
|
|
20
|
+
ctx.obj = {"engine": InfraEngine()}
|
|
21
|
+
user_info = ctx.obj["engine"].get_user_info()
|
|
22
|
+
console.print(
|
|
23
|
+
f"[bold underline]Xenfra CLI[/bold underline] - Logged in as [green]{user_info.email}[/green]"
|
|
24
|
+
)
|
|
25
|
+
except Exception as e:
|
|
26
|
+
console.print(f"[bold red]CRITICAL ERROR:[/bold red] Failed to initialize engine: {e}")
|
|
27
|
+
exit(1)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@main.command()
|
|
31
|
+
@click.pass_context
|
|
32
|
+
def init(ctx):
|
|
33
|
+
"""Initializes a project by creating a xenfra.yaml configuration file."""
|
|
34
|
+
console.print("\n[bold blue]🔎 INITIALIZING PROJECT[/bold blue]")
|
|
35
|
+
|
|
36
|
+
framework, _, _ = dockerizer.detect_framework()
|
|
37
|
+
if not framework:
|
|
38
|
+
console.print("[yellow] Warning: No recognizable web framework detected.[/yellow]")
|
|
39
|
+
|
|
40
|
+
console.print(f" - Detected [cyan]{framework or 'unknown'}[/cyan] project.")
|
|
41
|
+
|
|
42
|
+
use_db = click.confirm(
|
|
43
|
+
"\n Would you like to add a PostgreSQL database to your deployment?", default=False
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
config = {
|
|
47
|
+
"name": "xenfra-app",
|
|
48
|
+
"digitalocean": {"region": "nyc3", "size": "s-1vcpu-1gb", "image": "ubuntu-22-04-x64"},
|
|
49
|
+
"app": {"framework": framework},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if use_db:
|
|
53
|
+
config["database"] = {
|
|
54
|
+
"type": "postgres",
|
|
55
|
+
"user": "db_user",
|
|
56
|
+
"password": "db_password", # In a real scenario, this should be handled more securely
|
|
57
|
+
"name": "app_db",
|
|
58
|
+
}
|
|
59
|
+
console.print(" - Added [bold green]PostgreSQL[/bold green] to the configuration.")
|
|
60
|
+
|
|
61
|
+
with open("xenfra.yaml", "w") as f:
|
|
62
|
+
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
|
63
|
+
|
|
64
|
+
console.print("\n[bold green]✅ SUCCESS![/bold green]")
|
|
65
|
+
console.print(" - Created [cyan]xenfra.yaml[/cyan].")
|
|
66
|
+
console.print("\n Next step: Review the configuration and run 'xenfra deploy'!")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@main.command()
|
|
70
|
+
@click.pass_context
|
|
71
|
+
def deploy(ctx):
|
|
72
|
+
"""Deploys the project based on the xenfra.yaml configuration."""
|
|
73
|
+
console.print("\n[bold green]🚀 INITIATING DEPLOYMENT FROM CONFIGURATION[/bold green]")
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
with open("xenfra.yaml", "r") as f:
|
|
77
|
+
config = yaml.safe_load(f)
|
|
78
|
+
except FileNotFoundError:
|
|
79
|
+
raise click.ClickException(
|
|
80
|
+
"No 'xenfra.yaml' found. Run 'xenfra init' to create a configuration file."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
engine = ctx.obj["engine"]
|
|
84
|
+
|
|
85
|
+
# Extract config values
|
|
86
|
+
name = config.get("name", "xenfra-app")
|
|
87
|
+
do_config = config.get("digitalocean", {})
|
|
88
|
+
region = do_config.get("region", "nyc3")
|
|
89
|
+
size = do_config.get("size", "s-1vcpu-1gb")
|
|
90
|
+
image = do_config.get("image", "ubuntu-22-04-x64")
|
|
91
|
+
|
|
92
|
+
# Build context for templates
|
|
93
|
+
template_context = {
|
|
94
|
+
"database": config.get("database", {}).get("type"),
|
|
95
|
+
"db_user": config.get("database", {}).get("user"),
|
|
96
|
+
"db_password": config.get("database", {}).get("password"),
|
|
97
|
+
"db_name": config.get("database", {}).get("name"),
|
|
98
|
+
"email": ctx.obj["engine"].get_user_info().email,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
console.print(f" - App Name: [cyan]{name}[/cyan]")
|
|
102
|
+
console.print(f" - Region: [cyan]{region}[/cyan], Size: [cyan]{size}[/cyan]")
|
|
103
|
+
if template_context.get("database"):
|
|
104
|
+
console.print(f" - Including Database: [cyan]{template_context['database']}[/cyan]")
|
|
105
|
+
|
|
106
|
+
if not click.confirm(f"\n Ready to deploy '{name}' from 'xenfra.yaml'?"):
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
with console.status("[bold green]Deployment in progress...[/bold green]"):
|
|
110
|
+
result = engine.deploy_server(
|
|
111
|
+
name=name, region=region, size=size, image=image, logger=console.log, **template_context
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
console.print("\n[bold green]✅ DEPLOYMENT COMPLETE![/bold green]")
|
|
115
|
+
console.print(result)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@main.command(name="list")
|
|
119
|
+
@click.option("--refresh", is_flag=True, help="Sync with the cloud provider before listing.")
|
|
120
|
+
@click.pass_context
|
|
121
|
+
def list_projects(ctx, refresh):
|
|
122
|
+
"""Lists all active Xenfra projects from the local database."""
|
|
123
|
+
engine = ctx.obj["engine"]
|
|
124
|
+
|
|
125
|
+
if refresh:
|
|
126
|
+
console.print("\n[bold]📡 SYNCING WITH CLOUD PROVIDER...[/bold]")
|
|
127
|
+
with console.status("Calling DigitalOcean API and reconciling state..."):
|
|
128
|
+
projects = engine.sync_with_provider()
|
|
129
|
+
else:
|
|
130
|
+
console.print("\n[bold]⚡️ LISTING PROJECTS FROM LOCAL DATABASE[/bold]")
|
|
131
|
+
projects = engine.list_projects_from_db()
|
|
132
|
+
|
|
133
|
+
if not projects:
|
|
134
|
+
console.print(
|
|
135
|
+
"[yellow] No active projects found. Run 'xenfra deploy' to create one.[/yellow]"
|
|
136
|
+
)
|
|
137
|
+
else:
|
|
138
|
+
table = Table(show_header=True, header_style="bold magenta")
|
|
139
|
+
table.add_column("Droplet ID", style="dim", width=12)
|
|
140
|
+
table.add_column("Name", style="cyan")
|
|
141
|
+
table.add_column("IP Address", style="green")
|
|
142
|
+
table.add_column("Status")
|
|
143
|
+
table.add_column("Region")
|
|
144
|
+
table.add_column("Size")
|
|
145
|
+
for p in projects:
|
|
146
|
+
table.add_row(str(p.droplet_id), p.name, p.ip_address, p.status, p.region, p.size)
|
|
147
|
+
console.print(table)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@main.command(name="logs")
|
|
151
|
+
@click.pass_context
|
|
152
|
+
def logs(ctx):
|
|
153
|
+
"""Streams real-time logs from a deployed project."""
|
|
154
|
+
engine = ctx.obj["engine"]
|
|
155
|
+
|
|
156
|
+
console.print("\n[bold yellow]📡 SELECT A PROJECT TO STREAM LOGS[/bold yellow]")
|
|
157
|
+
projects = engine.list_projects_from_db()
|
|
158
|
+
|
|
159
|
+
if not projects:
|
|
160
|
+
console.print("[yellow] No active projects to stream logs from.[/yellow]")
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
project_map = {str(i + 1): p for i, p in enumerate(projects)}
|
|
164
|
+
for k, p in project_map.items():
|
|
165
|
+
console.print(f" [{k}] {p.name} ({p.ip_address})")
|
|
166
|
+
|
|
167
|
+
choice_key = click.prompt(
|
|
168
|
+
"\n Select Project (0 to cancel)",
|
|
169
|
+
type=click.Choice(["0"] + list(project_map.keys())),
|
|
170
|
+
show_choices=False,
|
|
171
|
+
)
|
|
172
|
+
if choice_key == "0":
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
target = project_map[choice_key]
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
console.print(
|
|
179
|
+
f"\n[bold green]-- Attaching to logs for {target.name} (Press Ctrl+C to stop) --[/bold green]"
|
|
180
|
+
)
|
|
181
|
+
engine.stream_logs(target.droplet_id)
|
|
182
|
+
except DeploymentError as e:
|
|
183
|
+
console.print(f"[bold red]ERROR:[/bold red] {e.message}")
|
|
184
|
+
except KeyboardInterrupt:
|
|
185
|
+
console.print("\n[bold yellow]-- Log streaming stopped by user. --[/bold yellow]")
|
|
186
|
+
except Exception as e:
|
|
187
|
+
console.print(f"[bold red]An unexpected error occurred:[/bold red] {e}")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@main.command()
|
|
191
|
+
@click.pass_context
|
|
192
|
+
def destroy(ctx):
|
|
193
|
+
"""Destroys a deployed project."""
|
|
194
|
+
engine = ctx.obj["engine"]
|
|
195
|
+
|
|
196
|
+
console.print("\n[bold red]🧨 SELECT A PROJECT TO DESTROY[/bold red]")
|
|
197
|
+
projects = engine.list_projects_from_db()
|
|
198
|
+
|
|
199
|
+
if not projects:
|
|
200
|
+
console.print("[yellow] No active projects to destroy.[/yellow]")
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
project_map = {str(i + 1): p for i, p in enumerate(projects)}
|
|
204
|
+
for k, p in project_map.items():
|
|
205
|
+
console.print(f" [{k}] {p.name} ({p.ip_address})")
|
|
206
|
+
|
|
207
|
+
choice_key = click.prompt(
|
|
208
|
+
"\n Select Project to DESTROY (0 to cancel)",
|
|
209
|
+
type=click.Choice(["0"] + list(project_map.keys())),
|
|
210
|
+
show_choices=False,
|
|
211
|
+
)
|
|
212
|
+
if choice_key == "0":
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
target = project_map[choice_key]
|
|
216
|
+
|
|
217
|
+
if click.confirm(
|
|
218
|
+
f" Are you SURE you want to permanently delete [red]{target.name}[/red] (Droplet ID: {target.droplet_id})? This action cannot be undone."
|
|
219
|
+
):
|
|
220
|
+
with console.status(f"💥 Destroying {target.name}..."):
|
|
221
|
+
engine.destroy_server(target.droplet_id)
|
|
222
|
+
console.print(f"[green] Project '{target.name}' has been destroyed.[/green]")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
if __name__ == "__main__":
|
|
226
|
+
main()
|
xenfra_sdk/client.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from .exceptions import AuthenticationError, XenfraAPIError, XenfraError
|
|
6
|
+
from .resources.deployments import DeploymentsManager
|
|
7
|
+
from .resources.intelligence import IntelligenceManager
|
|
8
|
+
from .resources.projects import ProjectsManager
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class XenfraClient:
|
|
12
|
+
def __init__(self, token: str = None, api_url: str = "http://localhost:8000"):
|
|
13
|
+
self.api_url = api_url
|
|
14
|
+
self._token = token or os.getenv("XENFRA_TOKEN")
|
|
15
|
+
if not self._token:
|
|
16
|
+
raise AuthenticationError(
|
|
17
|
+
"No API token provided. Pass it to the client or set XENFRA_TOKEN."
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
self._http_client = httpx.Client(
|
|
21
|
+
base_url=self.api_url,
|
|
22
|
+
headers={"Authorization": f"Bearer {self._token}", "Content-Type": "application/json"},
|
|
23
|
+
timeout=30.0, # Add a reasonable timeout
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Track if client is closed
|
|
27
|
+
self._closed = False
|
|
28
|
+
|
|
29
|
+
# Initialize resource managers
|
|
30
|
+
self.projects = ProjectsManager(self)
|
|
31
|
+
self.deployments = DeploymentsManager(self)
|
|
32
|
+
self.intelligence = IntelligenceManager(self)
|
|
33
|
+
|
|
34
|
+
def _request(self, method: str, path: str, json: dict = None) -> httpx.Response:
|
|
35
|
+
"""Internal method to handle all HTTP requests."""
|
|
36
|
+
if self._closed:
|
|
37
|
+
raise XenfraError("Client is closed. Create a new client or use context manager.")
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
response = self._http_client.request(method, path, json=json)
|
|
41
|
+
response.raise_for_status() # Raise HTTPStatusError for 4xx/5xx
|
|
42
|
+
return response
|
|
43
|
+
except httpx.HTTPStatusError as e:
|
|
44
|
+
# Convert httpx error to our custom SDK error
|
|
45
|
+
detail = e.response.json().get("detail", e.response.text)
|
|
46
|
+
raise XenfraAPIError(status_code=e.response.status_code, detail=detail) from e
|
|
47
|
+
except httpx.RequestError as e:
|
|
48
|
+
# Handle connection errors, timeouts, etc.
|
|
49
|
+
raise XenfraError(f"HTTP request failed: {e}")
|
|
50
|
+
|
|
51
|
+
def close(self):
|
|
52
|
+
"""Close the HTTP client and cleanup resources."""
|
|
53
|
+
if not self._closed:
|
|
54
|
+
self._http_client.close()
|
|
55
|
+
self._closed = True
|
|
56
|
+
|
|
57
|
+
def __enter__(self):
|
|
58
|
+
"""Context manager entry - allows 'with XenfraClient() as client:' usage."""
|
|
59
|
+
return self
|
|
60
|
+
|
|
61
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
62
|
+
"""Context manager exit - ensures cleanup."""
|
|
63
|
+
self.close()
|
|
64
|
+
return False # Don't suppress exceptions
|
|
65
|
+
|
|
66
|
+
def __del__(self):
|
|
67
|
+
"""Destructor - cleanup if not already closed."""
|
|
68
|
+
if hasattr(self, '_closed') and not self._closed:
|
|
69
|
+
self.close()
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Enhanced XenfraClient with context management and lifecycle hooks.
|
|
3
|
+
"""
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any, Callable, Optional
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .exceptions import AuthenticationError, XenfraAPIError, XenfraError
|
|
11
|
+
from .resources.deployments import DeploymentsManager
|
|
12
|
+
from .resources.intelligence import IntelligenceManager
|
|
13
|
+
from .resources.projects import ProjectsManager
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RequestHooks:
|
|
19
|
+
"""Container for request lifecycle hooks."""
|
|
20
|
+
|
|
21
|
+
def __init__(self):
|
|
22
|
+
self.before_request: list[Callable] = []
|
|
23
|
+
self.after_request: list[Callable] = []
|
|
24
|
+
self.on_error: list[Callable] = []
|
|
25
|
+
self.on_retry: list[Callable] = []
|
|
26
|
+
|
|
27
|
+
def register_before_request(self, callback: Callable):
|
|
28
|
+
"""Register a callback to run before each request."""
|
|
29
|
+
self.before_request.append(callback)
|
|
30
|
+
|
|
31
|
+
def register_after_request(self, callback: Callable):
|
|
32
|
+
"""Register a callback to run after each request."""
|
|
33
|
+
self.after_request.append(callback)
|
|
34
|
+
|
|
35
|
+
def register_on_error(self, callback: Callable):
|
|
36
|
+
"""Register a callback to run on request errors."""
|
|
37
|
+
self.on_error.append(callback)
|
|
38
|
+
|
|
39
|
+
def register_on_retry(self, callback: Callable):
|
|
40
|
+
"""Register a callback to run on request retries."""
|
|
41
|
+
self.on_retry.append(callback)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class XenfraClient:
|
|
45
|
+
"""
|
|
46
|
+
Xenfra SDK client with context manager support and lifecycle hooks.
|
|
47
|
+
|
|
48
|
+
Usage:
|
|
49
|
+
# With context manager (recommended):
|
|
50
|
+
with XenfraClient(token=token) as client:
|
|
51
|
+
projects = client.projects.list()
|
|
52
|
+
|
|
53
|
+
# Without context manager (manual cleanup):
|
|
54
|
+
client = XenfraClient(token=token)
|
|
55
|
+
try:
|
|
56
|
+
projects = client.projects.list()
|
|
57
|
+
finally:
|
|
58
|
+
client.close()
|
|
59
|
+
|
|
60
|
+
# With hooks:
|
|
61
|
+
client = XenfraClient(token=token)
|
|
62
|
+
client.hooks.register_before_request(lambda req: print(f"Calling {req.url}"))
|
|
63
|
+
client.hooks.register_after_request(lambda req, resp: print(f"Status: {resp.status_code}"))
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
token: str = None,
|
|
69
|
+
api_url: str = "http://localhost:8000",
|
|
70
|
+
timeout: float = 30.0,
|
|
71
|
+
max_retries: int = 3,
|
|
72
|
+
enable_logging: bool = False,
|
|
73
|
+
):
|
|
74
|
+
"""
|
|
75
|
+
Initialize Xenfra client.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
token: API authentication token
|
|
79
|
+
api_url: Base URL for Xenfra API
|
|
80
|
+
timeout: Request timeout in seconds
|
|
81
|
+
max_retries: Maximum number of retries for failed requests
|
|
82
|
+
enable_logging: Enable request/response logging
|
|
83
|
+
"""
|
|
84
|
+
self.api_url = api_url
|
|
85
|
+
self._token = token or os.getenv("XENFRA_TOKEN")
|
|
86
|
+
self.timeout = timeout
|
|
87
|
+
self.max_retries = max_retries
|
|
88
|
+
self.enable_logging = enable_logging
|
|
89
|
+
|
|
90
|
+
if not self._token:
|
|
91
|
+
raise AuthenticationError(
|
|
92
|
+
"No API token provided. Pass it to the client or set XENFRA_TOKEN."
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Initialize hooks
|
|
96
|
+
self.hooks = RequestHooks()
|
|
97
|
+
|
|
98
|
+
# Create HTTP client with retry logic
|
|
99
|
+
transport = httpx.HTTPTransport(retries=max_retries)
|
|
100
|
+
self._http_client = httpx.Client(
|
|
101
|
+
base_url=self.api_url,
|
|
102
|
+
headers={
|
|
103
|
+
"Authorization": f"Bearer {self._token}",
|
|
104
|
+
"Content-Type": "application/json",
|
|
105
|
+
"User-Agent": "Xenfra-SDK/0.2.0",
|
|
106
|
+
},
|
|
107
|
+
timeout=timeout,
|
|
108
|
+
transport=transport,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Track if client is closed
|
|
112
|
+
self._closed = False
|
|
113
|
+
|
|
114
|
+
# Initialize resource managers
|
|
115
|
+
self.projects = ProjectsManager(self)
|
|
116
|
+
self.deployments = DeploymentsManager(self)
|
|
117
|
+
self.intelligence = IntelligenceManager(self)
|
|
118
|
+
|
|
119
|
+
logger.debug(f"XenfraClient initialized for {api_url}")
|
|
120
|
+
|
|
121
|
+
def _request(self, method: str, path: str, json: dict = None) -> httpx.Response:
|
|
122
|
+
"""
|
|
123
|
+
Internal method to handle all HTTP requests with hooks.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
method: HTTP method (GET, POST, etc.)
|
|
127
|
+
path: API endpoint path
|
|
128
|
+
json: Optional JSON body
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
HTTP response
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
XenfraAPIError: For API errors (4xx, 5xx)
|
|
135
|
+
XenfraError: For connection/network errors
|
|
136
|
+
"""
|
|
137
|
+
if self._closed:
|
|
138
|
+
raise XenfraError("Client is closed. Create a new client or use context manager.")
|
|
139
|
+
|
|
140
|
+
# Build request context
|
|
141
|
+
request_context = {
|
|
142
|
+
"method": method,
|
|
143
|
+
"path": path,
|
|
144
|
+
"json": json,
|
|
145
|
+
"url": f"{self.api_url}{path}",
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
# Run before_request hooks
|
|
149
|
+
for hook in self.hooks.before_request:
|
|
150
|
+
try:
|
|
151
|
+
hook(request_context)
|
|
152
|
+
except Exception as e:
|
|
153
|
+
logger.warning(f"before_request hook failed: {e}")
|
|
154
|
+
|
|
155
|
+
# Log request if enabled
|
|
156
|
+
if self.enable_logging or logger.isEnabledFor(logging.DEBUG):
|
|
157
|
+
logger.debug(f"{method} {path}")
|
|
158
|
+
if json:
|
|
159
|
+
logger.debug(f"Request body: {json}")
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
# Make the request
|
|
163
|
+
response = self._http_client.request(method, path, json=json)
|
|
164
|
+
response.raise_for_status()
|
|
165
|
+
|
|
166
|
+
# Log response if enabled
|
|
167
|
+
if self.enable_logging or logger.isEnabledFor(logging.DEBUG):
|
|
168
|
+
logger.debug(f"Response: {response.status_code}")
|
|
169
|
+
|
|
170
|
+
# Run after_request hooks
|
|
171
|
+
for hook in self.hooks.after_request:
|
|
172
|
+
try:
|
|
173
|
+
hook(request_context, response)
|
|
174
|
+
except Exception as e:
|
|
175
|
+
logger.warning(f"after_request hook failed: {e}")
|
|
176
|
+
|
|
177
|
+
return response
|
|
178
|
+
|
|
179
|
+
except httpx.HTTPStatusError as e:
|
|
180
|
+
# API error (4xx, 5xx)
|
|
181
|
+
detail = e.response.json().get("detail", e.response.text) if e.response else str(e)
|
|
182
|
+
|
|
183
|
+
# Run error hooks
|
|
184
|
+
error_context = {**request_context, "error": e, "response": e.response}
|
|
185
|
+
for hook in self.hooks.on_error:
|
|
186
|
+
try:
|
|
187
|
+
hook(error_context)
|
|
188
|
+
except Exception as hook_error:
|
|
189
|
+
logger.warning(f"on_error hook failed: {hook_error}")
|
|
190
|
+
|
|
191
|
+
# Log error
|
|
192
|
+
logger.error(f"{method} {path} failed: {e.response.status_code if e.response else 'unknown'}")
|
|
193
|
+
|
|
194
|
+
raise XenfraAPIError(
|
|
195
|
+
status_code=e.response.status_code if e.response else 500,
|
|
196
|
+
detail=detail
|
|
197
|
+
) from e
|
|
198
|
+
|
|
199
|
+
except httpx.RequestError as e:
|
|
200
|
+
# Connection/network error
|
|
201
|
+
error_context = {**request_context, "error": e}
|
|
202
|
+
for hook in self.hooks.on_error:
|
|
203
|
+
try:
|
|
204
|
+
hook(error_context)
|
|
205
|
+
except Exception as hook_error:
|
|
206
|
+
logger.warning(f"on_error hook failed: {hook_error}")
|
|
207
|
+
|
|
208
|
+
logger.error(f"{method} {path} failed: {e}")
|
|
209
|
+
raise XenfraError(f"HTTP request failed: {e}") from e
|
|
210
|
+
|
|
211
|
+
def close(self):
|
|
212
|
+
"""Close the HTTP client and cleanup resources."""
|
|
213
|
+
if not self._closed:
|
|
214
|
+
logger.debug("Closing XenfraClient")
|
|
215
|
+
self._http_client.close()
|
|
216
|
+
self._closed = True
|
|
217
|
+
|
|
218
|
+
def __enter__(self):
|
|
219
|
+
"""Context manager entry."""
|
|
220
|
+
return self
|
|
221
|
+
|
|
222
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
223
|
+
"""Context manager exit - ensures cleanup."""
|
|
224
|
+
self.close()
|
|
225
|
+
return False # Don't suppress exceptions
|
|
226
|
+
|
|
227
|
+
def __del__(self):
|
|
228
|
+
"""Destructor - cleanup if not already closed."""
|
|
229
|
+
if not self._closed:
|
|
230
|
+
logger.warning("XenfraClient was not properly closed. Use 'with' statement or call close().")
|
|
231
|
+
self.close()
|
|
232
|
+
|
|
233
|
+
def __repr__(self):
|
|
234
|
+
"""String representation."""
|
|
235
|
+
status = "closed" if self._closed else "open"
|
|
236
|
+
return f"<XenfraClient(api_url='{self.api_url}', status='{status}')>"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# Example hooks for common use cases
|
|
240
|
+
|
|
241
|
+
def logging_hook_before(request_context):
|
|
242
|
+
"""Example: Log all requests."""
|
|
243
|
+
print(f"→ {request_context['method']} {request_context['url']}")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def logging_hook_after(request_context, response):
|
|
247
|
+
"""Example: Log all responses."""
|
|
248
|
+
print(f"← {response.status_code} {request_context['url']}")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def error_notification_hook(error_context):
|
|
252
|
+
"""Example: Send notifications on errors."""
|
|
253
|
+
# Could send to Sentry, DataDog, etc.
|
|
254
|
+
print(f"⚠️ API Error: {error_context['url']} - {error_context['error']}")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def rate_limit_tracker_hook(request_context, response):
|
|
258
|
+
"""Example: Track rate limits."""
|
|
259
|
+
remaining = response.headers.get("X-RateLimit-Remaining")
|
|
260
|
+
if remaining:
|
|
261
|
+
print(f"Rate limit remaining: {remaining}")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def request_timing_hook(request_context):
|
|
265
|
+
"""Example: Track request timing."""
|
|
266
|
+
import time
|
|
267
|
+
request_context["start_time"] = time.time()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def response_timing_hook(request_context, response):
|
|
271
|
+
"""Example: Calculate request duration."""
|
|
272
|
+
import time
|
|
273
|
+
if "start_time" in request_context:
|
|
274
|
+
duration = time.time() - request_context["start_time"]
|
|
275
|
+
print(f"Request took {duration:.3f}s")
|
xenfra_sdk/config.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# src/xenfra/config.py
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Settings(BaseSettings):
|
|
8
|
+
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
|
9
|
+
|
|
10
|
+
SECRET_KEY: str
|
|
11
|
+
ENCRYPTION_KEY: str
|
|
12
|
+
|
|
13
|
+
GITHUB_CLIENT_ID: str
|
|
14
|
+
GITHUB_CLIENT_SECRET: str
|
|
15
|
+
GITHUB_REDIRECT_URI: str
|
|
16
|
+
GITHUB_WEBHOOK_SECRET: str
|
|
17
|
+
|
|
18
|
+
DO_CLIENT_ID: str
|
|
19
|
+
DO_CLIENT_SECRET: str
|
|
20
|
+
DO_REDIRECT_URI: str
|
|
21
|
+
|
|
22
|
+
# Frontend redirect for successful OAuth (e.g., /dashboard/connections)
|
|
23
|
+
FRONTEND_OAUTH_REDIRECT_SUCCESS: str = "/dashboard/connections"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
settings = Settings()
|
|
File without changes
|
xenfra_sdk/db/models.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# src/xenfra/db/models.py
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from sqlmodel import Field, SQLModel
|
|
6
|
+
|
|
7
|
+
# --- Project Model for CLI state ---
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Project(SQLModel, table=True):
|
|
11
|
+
"""
|
|
12
|
+
Project model storing deployment state in the SDK's local database.
|
|
13
|
+
|
|
14
|
+
Note: user_id references a User in the SSO service database.
|
|
15
|
+
In a microservices architecture, we store the ID but don't enforce
|
|
16
|
+
a foreign key constraint across service boundaries.
|
|
17
|
+
"""
|
|
18
|
+
id: Optional[int] = Field(default=None, primary_key=True)
|
|
19
|
+
droplet_id: int = Field(unique=True, index=True)
|
|
20
|
+
name: str
|
|
21
|
+
ip_address: str
|
|
22
|
+
status: str
|
|
23
|
+
region: str
|
|
24
|
+
size: str
|
|
25
|
+
# user_id is a reference to User.id in the SSO service database
|
|
26
|
+
# No foreign key constraint since databases are separate in microservices architecture
|
|
27
|
+
user_id: int = Field(index=True) # Index for query performance
|
xenfra_sdk/db/session.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# src/xenfra/db/session.py
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from sqlmodel import Session, SQLModel, create_engine
|
|
8
|
+
|
|
9
|
+
# Get the app directory for the current user
|
|
10
|
+
app_dir = Path(click.get_app_dir("xenfra"))
|
|
11
|
+
app_dir.mkdir(parents=True, exist_ok=True)
|
|
12
|
+
db_path = app_dir / "xenfra.db"
|
|
13
|
+
|
|
14
|
+
# For now, we will use a simple SQLite database for ease of setup.
|
|
15
|
+
# In production, this should be a PostgreSQL database URL from environment variables.
|
|
16
|
+
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{db_path}")
|
|
17
|
+
|
|
18
|
+
# Only echo SQL in development (set SQL_ECHO=1 to enable)
|
|
19
|
+
SQL_ECHO = os.getenv("SQL_ECHO", "0") == "1"
|
|
20
|
+
|
|
21
|
+
engine = create_engine(DATABASE_URL, echo=SQL_ECHO)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def create_db_and_tables():
|
|
25
|
+
SQLModel.metadata.create_all(engine)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_session():
|
|
29
|
+
with Session(engine) as session:
|
|
30
|
+
yield session
|