xenfra 0.1.7__py3-none-any.whl → 0.1.8__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/cli/main.py ADDED
@@ -0,0 +1,211 @@
1
+ import click
2
+ from rich.console import Console
3
+ from rich.table import Table
4
+
5
+ from xenfra.engine import InfraEngine
6
+ from xenfra import dockerizer
7
+ import yaml
8
+
9
+ console = Console()
10
+
11
+ @click.group()
12
+ @click.pass_context
13
+ def main(ctx):
14
+ """
15
+ Xenfra CLI: A 'Zen Mode' infrastructure engine for Python developers.
16
+ """
17
+ try:
18
+ ctx.obj = {'engine': InfraEngine()}
19
+ user_info = ctx.obj['engine'].get_user_info()
20
+ console.print(f"[bold underline]Xenfra CLI[/bold underline] - Logged in as [green]{user_info.email}[/green]")
21
+ except Exception as e:
22
+ console.print(f"[bold red]CRITICAL ERROR:[/bold red] Failed to initialize engine: {e}")
23
+ exit(1)
24
+
25
+ @main.command()
26
+ @click.pass_context
27
+ def init(ctx):
28
+ """Initializes a project by creating a xenfra.yaml configuration file."""
29
+ console.print("\n[bold blue]🔎 INITIALIZING PROJECT[/bold blue]")
30
+
31
+ framework, _, _ = dockerizer.detect_framework()
32
+ if not framework:
33
+ console.print("[yellow] Warning: No recognizable web framework detected.[/yellow]")
34
+
35
+ console.print(f" - Detected [cyan]{framework or 'unknown'}[/cyan] project.")
36
+
37
+ use_db = click.confirm("\n Would you like to add a PostgreSQL database to your deployment?", default=False)
38
+
39
+ config = {
40
+ 'name': 'xenfra-app',
41
+ 'digitalocean': {
42
+ 'region': 'nyc3',
43
+ 'size': 's-1vcpu-1gb',
44
+ 'image': 'ubuntu-22-04-x64'
45
+ },
46
+ 'app': {
47
+ 'framework': framework
48
+ }
49
+ }
50
+
51
+ if use_db:
52
+ config['database'] = {
53
+ 'type': 'postgres',
54
+ 'user': 'db_user',
55
+ 'password': 'db_password', # In a real scenario, this should be handled more securely
56
+ 'name': 'app_db'
57
+ }
58
+ console.print(" - Added [bold green]PostgreSQL[/bold green] to the configuration.")
59
+
60
+ with open('xenfra.yaml', 'w') as f:
61
+ yaml.dump(config, f, default_flow_style=False, sort_keys=False)
62
+
63
+ console.print("\n[bold green]✅ SUCCESS![/bold green]")
64
+ console.print(" - Created [cyan]xenfra.yaml[/cyan].")
65
+ console.print("\n Next step: Review the configuration and run 'xenfra deploy'!")
66
+
67
+
68
+ @main.command()
69
+ @click.pass_context
70
+ def deploy(ctx):
71
+ """Deploys the project based on the xenfra.yaml configuration."""
72
+ console.print("\n[bold green]🚀 INITIATING DEPLOYMENT FROM CONFIGURATION[/bold green]")
73
+
74
+ try:
75
+ with open('xenfra.yaml', 'r') as f:
76
+ config = yaml.safe_load(f)
77
+ except FileNotFoundError:
78
+ raise click.ClickException("No 'xenfra.yaml' found. Run 'xenfra init' to create a configuration file.")
79
+
80
+ engine = ctx.obj['engine']
81
+
82
+ # Extract config values
83
+ name = config.get('name', 'xenfra-app')
84
+ do_config = config.get('digitalocean', {})
85
+ region = do_config.get('region', 'nyc3')
86
+ size = do_config.get('size', 's-1vcpu-1gb')
87
+ image = do_config.get('image', 'ubuntu-22-04-x64')
88
+
89
+ # Build context for templates
90
+ template_context = {
91
+ 'database': config.get('database', {}).get('type'),
92
+ 'db_user': config.get('database', {}).get('user'),
93
+ 'db_password': config.get('database', {}).get('password'),
94
+ 'db_name': config.get('database', {}).get('name'),
95
+ 'email': ctx.obj['engine'].get_user_info().email
96
+ }
97
+
98
+ console.print(f" - App Name: [cyan]{name}[/cyan]")
99
+ console.print(f" - Region: [cyan]{region}[/cyan], Size: [cyan]{size}[/cyan]")
100
+ if template_context.get('database'):
101
+ console.print(f" - Including Database: [cyan]{template_context['database']}[/cyan]")
102
+
103
+ if not click.confirm(f"\n Ready to deploy '{name}' from 'xenfra.yaml'?"):
104
+ return
105
+
106
+ with console.status("[bold green]Deployment in progress...[/bold green]"):
107
+ result = engine.deploy_server(
108
+ name=name,
109
+ region=region,
110
+ size=size,
111
+ image=image,
112
+ logger=console.log,
113
+ **template_context
114
+ )
115
+
116
+ console.print(f"\n[bold green]✅ DEPLOYMENT COMPLETE![/bold green]")
117
+ console.print(result)
118
+
119
+ @main.command(name="list")
120
+ @click.option('--refresh', is_flag=True, help="Sync with the cloud provider before listing.")
121
+ @click.pass_context
122
+ def list_projects(ctx, refresh):
123
+ """Lists all active Xenfra projects from the local database."""
124
+ engine = ctx.obj['engine']
125
+
126
+ if refresh:
127
+ console.print("\n[bold]📡 SYNCING WITH CLOUD PROVIDER...[/bold]")
128
+ with console.status("Calling DigitalOcean API and reconciling state..."):
129
+ projects = engine.sync_with_provider()
130
+ else:
131
+ console.print("\n[bold]⚡️ LISTING PROJECTS FROM LOCAL DATABASE[/bold]")
132
+ projects = engine.list_projects_from_db()
133
+
134
+ if not projects:
135
+ console.print("[yellow] No active projects found. Run 'xenfra deploy' to create one.[/yellow]")
136
+ else:
137
+ table = Table(show_header=True, header_style="bold magenta")
138
+ table.add_column("Droplet ID", style="dim", width=12)
139
+ table.add_column("Name", style="cyan")
140
+ table.add_column("IP Address", style="green")
141
+ table.add_column("Status")
142
+ table.add_column("Region")
143
+ table.add_column("Size")
144
+ for p in projects:
145
+ table.add_row(str(p.droplet_id), p.name, p.ip_address, p.status, p.region, p.size)
146
+ console.print(table)
147
+
148
+ @main.command(name="logs")
149
+ @click.pass_context
150
+ def logs(ctx):
151
+ """Streams real-time logs from a deployed project."""
152
+ engine = ctx.obj['engine']
153
+
154
+ console.print("\n[bold yellow]📡 SELECT A PROJECT TO STREAM LOGS[/bold yellow]")
155
+ projects = engine.list_projects_from_db()
156
+
157
+ if not projects:
158
+ console.print("[yellow] No active projects to stream logs from.[/yellow]")
159
+ return
160
+
161
+ project_map = {str(i+1): p for i, p in enumerate(projects)}
162
+ for k, p in project_map.items():
163
+ console.print(f" [{k}] {p.name} ({p.ip_address})")
164
+
165
+ choice_key = click.prompt("\n Select Project (0 to cancel)", type=click.Choice(['0'] + list(project_map.keys())), show_choices=False)
166
+ if choice_key == '0':
167
+ return
168
+
169
+ target = project_map[choice_key]
170
+
171
+ try:
172
+ console.print(f"\n[bold green]-- Attaching to logs for {target.name} (Press Ctrl+C to stop) --[/bold green]")
173
+ engine.stream_logs(target.droplet_id)
174
+ except DeploymentError as e:
175
+ console.print(f"[bold red]ERROR:[/bold red] {e.message}")
176
+ except KeyboardInterrupt:
177
+ console.print("\n[bold yellow]-- Log streaming stopped by user. --[/bold yellow]")
178
+ except Exception as e:
179
+ console.print(f"[bold red]An unexpected error occurred:[/bold red] {e}")
180
+
181
+
182
+ @main.command()
183
+ @click.pass_context
184
+ def destroy(ctx):
185
+ """Destroys a deployed project."""
186
+ engine = ctx.obj['engine']
187
+
188
+ console.print("\n[bold red]🧨 SELECT A PROJECT TO DESTROY[/bold red]")
189
+ projects = engine.list_projects_from_db()
190
+
191
+ if not projects:
192
+ console.print("[yellow] No active projects to destroy.[/yellow]")
193
+ return
194
+
195
+ project_map = {str(i+1): p for i, p in enumerate(projects)}
196
+ for k, p in project_map.items():
197
+ console.print(f" [{k}] {p.name} ({p.ip_address})")
198
+
199
+ choice_key = click.prompt("\n Select Project to DESTROY (0 to cancel)", type=click.Choice(['0'] + list(project_map.keys())), show_choices=False)
200
+ if choice_key == '0':
201
+ return
202
+
203
+ target = project_map[choice_key]
204
+
205
+ if click.confirm(f" Are you SURE you want to permanently delete [red]{target.name}[/red] (Droplet ID: {target.droplet_id})? This action cannot be undone."):
206
+ with console.status(f"💥 Destroying {target.name}..."):
207
+ engine.destroy_server(target.droplet_id)
208
+ console.print(f"[green] Project '{target.name}' has been destroyed.[/green]")
209
+
210
+ if __name__ == "__main__":
211
+ main()
xenfra/config.py ADDED
@@ -0,0 +1,24 @@
1
+ # src/xenfra/config.py
2
+
3
+ from pydantic_settings import BaseSettings, SettingsConfigDict
4
+ from typing import Optional
5
+
6
+ class Settings(BaseSettings):
7
+ model_config = SettingsConfigDict(env_file=".env", extra="ignore")
8
+
9
+ SECRET_KEY: str
10
+ ENCRYPTION_KEY: str
11
+
12
+ GITHUB_CLIENT_ID: str
13
+ GITHUB_CLIENT_SECRET: str
14
+ GITHUB_REDIRECT_URI: str
15
+ GITHUB_WEBHOOK_SECRET: str
16
+
17
+ DO_CLIENT_ID: str
18
+ DO_CLIENT_SECRET: str
19
+ DO_REDIRECT_URI: str
20
+
21
+ # Frontend redirect for successful OAuth (e.g., /dashboard/connections)
22
+ FRONTEND_OAUTH_REDIRECT_SUCCESS: str = "/dashboard/connections"
23
+
24
+ settings = Settings()
xenfra/db/models.py ADDED
@@ -0,0 +1,51 @@
1
+ # src/xenfra/db/models.py
2
+
3
+ from typing import List, Optional
4
+ from sqlmodel import Field, Relationship, SQLModel
5
+
6
+ class UserBase(SQLModel):
7
+ email: str = Field(unique=True, index=True)
8
+ is_active: bool = True
9
+
10
+ class User(UserBase, table=True):
11
+ id: Optional[int] = Field(default=None, primary_key=True)
12
+ hashed_password: str
13
+
14
+ credentials: List["Credential"] = Relationship(back_populates="user")
15
+
16
+ class UserCreate(UserBase):
17
+ password: str
18
+
19
+ class UserRead(UserBase):
20
+ id: int
21
+
22
+ class CredentialBase(SQLModel):
23
+ service: str # e.g., "digitalocean", "github"
24
+ encrypted_token: str
25
+ user_id: int = Field(foreign_key="user.id")
26
+ github_installation_id: Optional[int] = Field(default=None, index=True)
27
+
28
+ class Credential(CredentialBase, table=True):
29
+ id: Optional[int] = Field(default=None, primary_key=True)
30
+
31
+ user: User = Relationship(back_populates="credentials")
32
+
33
+ class CredentialCreate(CredentialBase):
34
+ pass
35
+
36
+ class CredentialRead(SQLModel):
37
+ id: int
38
+ service: str
39
+ user_id: int
40
+
41
+ # --- Project Model for CLI state ---
42
+
43
+ class Project(SQLModel, table=True):
44
+ id: Optional[int] = Field(default=None, primary_key=True)
45
+ droplet_id: int = Field(unique=True, index=True)
46
+ name: str
47
+ ip_address: str
48
+ status: str
49
+ region: str
50
+ size: str
51
+
xenfra/db/session.py ADDED
@@ -0,0 +1,17 @@
1
+ # src/xenfra/db/session.py
2
+
3
+ from sqlmodel import create_engine, Session, SQLModel
4
+ import os
5
+
6
+ # For now, we will use a simple SQLite database for ease of setup.
7
+ # In production, this should be a PostgreSQL database URL from environment variables.
8
+ DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./xenfra.db")
9
+
10
+ engine = create_engine(DATABASE_URL, echo=True)
11
+
12
+ def create_db_and_tables():
13
+ SQLModel.metadata.create_all(engine)
14
+
15
+ def get_session():
16
+ with Session(engine) as session:
17
+ yield session
xenfra/dependencies.py ADDED
@@ -0,0 +1,35 @@
1
+ # src/xenfra/dependencies.py
2
+
3
+ from fastapi import Depends, HTTPException, status
4
+ from fastapi.security import OAuth2PasswordBearer
5
+ from sqlmodel import Session, select
6
+
7
+ from xenfra.db.session import get_session
8
+ from xenfra.db.models import User
9
+ from xenfra.security import decode_token
10
+
11
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")
12
+
13
+ def get_current_user(token: str = Depends(oauth2_scheme), session: Session = Depends(get_session)) -> User:
14
+ credentials_exception = HTTPException(
15
+ status_code=status.HTTP_401_UNAUTHORIZED,
16
+ detail="Could not validate credentials",
17
+ headers={"WWW-Authenticate": "Bearer"},
18
+ )
19
+ payload = decode_token(token)
20
+ if payload is None:
21
+ raise credentials_exception
22
+
23
+ email: str = payload.get("sub")
24
+ if email is None:
25
+ raise credentials_exception
26
+
27
+ user = session.exec(select(User).where(User.email == email)).first()
28
+ if user is None:
29
+ raise credentials_exception
30
+ return user
31
+
32
+ def get_current_active_user(current_user: User = Depends(get_current_user)) -> User:
33
+ if not current_user.is_active:
34
+ raise HTTPException(status_code=400, detail="Inactive user")
35
+ return current_user
xenfra/dockerizer.py CHANGED
@@ -1,12 +1,18 @@
1
1
  import os
2
2
  from pathlib import Path
3
+ from jinja2 import Environment, FileSystemLoader
3
4
 
4
- def detect_framework():
5
+ def detect_framework(path="."):
5
6
  """
6
7
  Scans common Python project structures to guess the framework and entrypoint.
7
8
  Returns: (framework_name, default_port, start_command) or (None, None, None)
8
9
  """
9
- project_root = Path(os.getcwd())
10
+ project_root = Path(path).resolve()
11
+
12
+ # Check for Django first (common pattern: manage.py in root)
13
+ if (project_root / "manage.py").is_file():
14
+ project_name = project_root.name
15
+ return "django", 8000, f"gunicorn {project_name}.wsgi:application --bind 0.0.0.0:8000"
10
16
 
11
17
  candidate_files = []
12
18
 
@@ -27,10 +33,7 @@ def detect_framework():
27
33
  content = f.read()
28
34
 
29
35
  module_name = str(file_path.relative_to(project_root)).replace(os.sep, '.')[:-3]
30
- # If path is like src/testdeploy/main.py, module_name becomes src.testdeploy.main
31
- # For uvicorn/gunicorn, we usually want package.module (e.g., testdeploy.main)
32
36
  if module_name.startswith("src."):
33
- # Strip the "src." prefix
34
37
  module_name = module_name[4:]
35
38
 
36
39
 
@@ -42,112 +45,45 @@ def detect_framework():
42
45
 
43
46
  return None, None, None
44
47
 
45
- def generate_deployment_assets(context):
48
+ def generate_templated_assets(context: dict):
46
49
  """
47
- Creates Dockerfile, docker-compose.yml, and Caddyfile
48
- if a web framework is detected.
50
+ Generates deployment assets (Dockerfile, docker-compose.yml) using Jinja2 templates.
51
+
52
+ Args:
53
+ context: A dictionary containing information for rendering templates,
54
+ e.g., {'database': 'postgres', 'python_version': 'python:3.11-slim'}
49
55
  """
50
- framework, port, cmd = detect_framework()
56
+ # Path to the templates directory
57
+ template_dir = Path(__file__).parent / "templates"
58
+ env = Environment(loader=FileSystemLoader(template_dir))
59
+
60
+ # Detect framework specifics
61
+ framework, port, command = detect_framework()
51
62
  if not framework:
63
+ print("Warning: No recognizable web framework detected.")
52
64
  return []
53
65
 
54
- generated = []
55
- print(f" ✨ Web framework detected: {framework.upper()}. Generating deployment assets...")
56
-
57
- # --- 1. DOCKERFILE ---
58
- if not os.path.exists("Dockerfile"):
59
- # Use UV if detected, otherwise Pip
60
- if context.get("type") == "uv":
61
- install_cmd = "RUN pip install uv && uv pip install --system ."
62
- else:
63
- # Modern pip can install from pyproject.toml directly
64
- install_cmd = "RUN pip install ."
65
-
66
- dockerfile_content = f"""# Generated by Xenfra (Robust v2)
67
- FROM python:3.13-slim
68
-
69
- WORKDIR /app
70
-
71
- # Install uv via pip
72
- RUN pip install uv
73
-
74
- # Copy only the dependency file first to leverage Docker layer caching
75
- COPY pyproject.toml .
76
-
77
- # Install dependencies from pyproject.toml
78
- # This is more robust than `pip install .` as it doesn't build the local package
79
- RUN uv pip install --system -r pyproject.toml
80
-
81
- # Copy the rest of the application code
82
- COPY . .
83
-
84
- # Add the 'src' directory to Python's path
85
- # This allows imports to work correctly for projects with a src/ layout
86
- ENV PYTHONPATH="${{PYTHONPATH}}:/app/src"
87
-
88
- # Expose Port
89
- EXPOSE {port}
90
-
91
- # Start Command
92
- CMD ["/bin/sh", "-c", "{cmd}"]
93
- """
94
- with open("Dockerfile", "w") as f:
95
- f.write(dockerfile_content)
96
- generated.append("Dockerfile")
97
-
98
- # --- 2. CADDYFILE ---
99
- if not os.path.exists("Caddyfile"):
100
- caddy_content = f"""{{
101
- # Enable automatic HTTPS for all sites
102
- email your_email@example.com
103
- }}
104
-
105
- :443 {{
106
- reverse_proxy app:{port}
107
- }}
108
-
109
- :80 {{
110
- redir https://{{host}}{{uri}}
111
- }}
112
- """
113
- with open("Caddyfile", "w") as f:
114
- f.write(caddy_content)
115
- generated.append("Caddyfile")
116
-
117
- # --- 3. DOCKER COMPOSE ---
118
- if not os.path.exists("docker-compose.yml"):
119
- compose_content = f"""
120
- version: '3.8'
121
-
122
- services:
123
- app:
124
- build: .
125
- container_name: xenfra_app
126
- restart: always
127
- # The internal port is exposed to other services in this network
128
- expose:
129
- - "{port}"
130
-
131
- caddy:
132
- image: caddy:latest
133
- container_name: xenfra_caddy
134
- restart: always
135
- ports:
136
- - "80:80"
137
- - "443:443"
138
- volumes:
139
- - ./Caddyfile:/etc/caddy/Caddyfile
140
- - caddy_data:/data
141
- - caddy_config:/config
142
- depends_on:
143
- - app
144
-
145
- volumes:
146
- caddy_data:
147
- caddy_config:
148
- """
149
- with open("docker-compose.yml", "w") as f:
150
- f.write(compose_content)
151
- generated.append("docker-compose.yml")
152
-
153
- return generated
66
+ # Merge detected context with provided context
67
+ render_context = {
68
+ 'port': port,
69
+ 'command': command,
70
+ **context
71
+ }
72
+
73
+ generated_files = []
74
+
75
+ # --- 1. Dockerfile ---
76
+ dockerfile_template = env.get_template("Dockerfile.j2")
77
+ dockerfile_content = dockerfile_template.render(render_context)
78
+ with open("Dockerfile", "w") as f:
79
+ f.write(dockerfile_content)
80
+ generated_files.append("Dockerfile")
81
+
82
+ # --- 2. docker-compose.yml ---
83
+ compose_template = env.get_template("docker-compose.yml.j2")
84
+ compose_content = compose_template.render(render_context)
85
+ with open("docker-compose.yml", "w") as f:
86
+ f.write(compose_content)
87
+ generated_files.append("docker-compose.yml")
88
+
89
+ return generated_files