xenfra 0.2.2__py3-none-any.whl → 0.2.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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.4.dist-info/METADATA +115 -0
- xenfra-0.2.4.dist-info/RECORD +17 -0
- xenfra-0.2.4.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 -293
- 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.2.dist-info/METADATA +0 -95
- xenfra-0.2.2.dist-info/RECORD +0 -25
- xenfra-0.2.2.dist-info/entry_points.txt +0 -3
- {xenfra-0.2.2.dist-info → xenfra-0.2.4.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: xenfra
|
|
3
|
+
Version: 0.2.4
|
|
4
|
+
Summary: A 'Zen Mode' infrastructure engine for Python developers.
|
|
5
|
+
Author: xenfra-cloud
|
|
6
|
+
Author-email: xenfra-cloud <xenfracloud@gmail.com>
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
13
|
+
Classifier: Topic :: System :: Systems Administration
|
|
14
|
+
Requires-Dist: click>=8.1.7
|
|
15
|
+
Requires-Dist: rich>=14.2.0
|
|
16
|
+
Requires-Dist: sqlmodel>=0.0.16
|
|
17
|
+
Requires-Dist: python-digitalocean>=1.17.0
|
|
18
|
+
Requires-Dist: python-dotenv>=1.2.1
|
|
19
|
+
Requires-Dist: pyyaml>=6.0.1
|
|
20
|
+
Requires-Dist: fabric>=3.2.2
|
|
21
|
+
Requires-Dist: xenfra-sdk
|
|
22
|
+
Requires-Dist: httpx>=0.27.0
|
|
23
|
+
Requires-Dist: keyring>=25.7.0
|
|
24
|
+
Requires-Dist: keyrings-alt>=5.0.2
|
|
25
|
+
Requires-Dist: pytest>=8.0.0 ; extra == 'test'
|
|
26
|
+
Requires-Dist: pytest-mock>=3.12.0 ; extra == 'test'
|
|
27
|
+
Requires-Python: >=3.13
|
|
28
|
+
Project-URL: Homepage, https://github.com/xenfra-cloud/xenfra
|
|
29
|
+
Project-URL: Issues, https://github.com/xenfra-cloud/xenfra/issues
|
|
30
|
+
Provides-Extra: test
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# Xenfra CLI
|
|
34
|
+
|
|
35
|
+
## Xenfra CLI: Deploy Python Apps with Zen Mode
|
|
36
|
+
|
|
37
|
+
The Xenfra CLI is a powerful and intuitive command-line interface designed to streamline the deployment of Python applications to DigitalOcean. Built with a "Zen Mode" philosophy, it automates complex infrastructure tasks, allowing developers to focus on writing code.
|
|
38
|
+
|
|
39
|
+
### ✨ Key Features
|
|
40
|
+
|
|
41
|
+
* **Zero-Configuration Deployment:** Automatically detects your project's framework and dependencies.
|
|
42
|
+
* **AI-Powered Auto-Healing:** Diagnoses common deployment failures and suggests, or even applies, fixes automatically.
|
|
43
|
+
* **Real-time Monitoring:** View deployment status and stream live application logs directly from your terminal.
|
|
44
|
+
* **Integrated Project Management:** Easily list, view, and destroy your deployed projects.
|
|
45
|
+
* **Secure Authentication:** Uses OAuth2 PKCE flow for secure, token-based authentication.
|
|
46
|
+
|
|
47
|
+
### 🚀 Quickstart
|
|
48
|
+
|
|
49
|
+
#### 1. Installation
|
|
50
|
+
|
|
51
|
+
Install the Xenfra CLI using `uv` (recommended) or `pip`:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
uv pip install xenfra-cli
|
|
55
|
+
# or
|
|
56
|
+
pip install xenfra-cli
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
#### 2. Authentication
|
|
60
|
+
|
|
61
|
+
Log in to your Xenfra account. This will open your web browser to complete the OAuth2 flow.
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
xenfra auth login
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
#### 3. Initialize Your Project
|
|
68
|
+
|
|
69
|
+
Navigate to your Python project's root directory and run `init`. The CLI will scan your codebase, detect its characteristics, and generate a `xenfra.yaml` configuration file.
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
cd your-python-project/
|
|
73
|
+
xenfra init
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
#### 4. Deploy Your Application
|
|
77
|
+
|
|
78
|
+
Once `xenfra.yaml` is configured, deploy your application. The CLI will handle provisioning a DigitalOcean Droplet, setting up Docker, and deploying your code.
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
xenfra deploy
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### 📋 Usage Examples
|
|
85
|
+
|
|
86
|
+
* **Monitor Deployment Status:**
|
|
87
|
+
```bash
|
|
88
|
+
xenfra status <deployment-id>
|
|
89
|
+
```
|
|
90
|
+
* **Stream Application Logs:**
|
|
91
|
+
```bash
|
|
92
|
+
xenfra logs <deployment-id>
|
|
93
|
+
```
|
|
94
|
+
* **List Deployed Projects:**
|
|
95
|
+
```bash
|
|
96
|
+
xenfra projects list
|
|
97
|
+
```
|
|
98
|
+
* **Diagnose a Failed Deployment (AI-Powered):**
|
|
99
|
+
```bash
|
|
100
|
+
xenfra diagnose <deployment-id>
|
|
101
|
+
# Or to diagnose from a log file:
|
|
102
|
+
xenfra diagnose --logs error.log
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### 📚 Documentation
|
|
106
|
+
|
|
107
|
+
For more detailed information, advanced configurations, and API references, please refer to the [official Xenfra Documentation](https://docs.xenfra.com/cli) (Link will be updated upon final deployment).
|
|
108
|
+
|
|
109
|
+
### 🤝 Contributing
|
|
110
|
+
|
|
111
|
+
We welcome contributions! Please see our [Contributing Guidelines](CONTRIBUTING.md) for more details.
|
|
112
|
+
|
|
113
|
+
### 📄 License
|
|
114
|
+
|
|
115
|
+
This project is licensed under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
xenfra/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
xenfra/commands/__init__.py,sha256=bdugTOErbWUhDvnwFl17KkGPPV7gtmDkSOzhF_NEHX0,40
|
|
3
|
+
xenfra/commands/auth.py,sha256=oiEt7RJqAa-_b-FCTgS_I2VyaQqoiGENhXM88tLCFhg,6080
|
|
4
|
+
xenfra/commands/deployments.py,sha256=38JcmHnDfS04feYheMd9IwBIZmDafsjognJ26MCQIU4,17812
|
|
5
|
+
xenfra/commands/intelligence.py,sha256=Pf9fbR1EY8IROoXJZYIASUtnSVvZCUW35zN2YeaCyZM,12933
|
|
6
|
+
xenfra/commands/projects.py,sha256=nkE-T43M1gqiKar597H33tWFg5hwFx-oxfn-t9afxa0,5259
|
|
7
|
+
xenfra/commands/security_cmd.py,sha256=j_VDpis0LEGS-ug9cYZ_MG33ToHrDFzoLG3SOoYS1Uk,7117
|
|
8
|
+
xenfra/main.py,sha256=muanV6chEYzLi0CW9aF2a5Ibok2CosuyVGY9HSq6Uso,1789
|
|
9
|
+
xenfra/utils/__init__.py,sha256=57o8j7Tibrhyid84zTFLHjFmRP5sCnNbtLEfpRqIpMk,42
|
|
10
|
+
xenfra/utils/auth.py,sha256=qM6g8CW1ddSlfEa7Bw5OZEqqUxmgq4zdQFpqS5CVzXw,5440
|
|
11
|
+
xenfra/utils/codebase.py,sha256=lA10QL6uitmO79y8YvoY4t22e_ff8AG7iBizygOUY4c,2773
|
|
12
|
+
xenfra/utils/config.py,sha256=aC4d4t4yDXRW5x-fgjOND6VeiYue8VnKkKNtvxp_j_c,8377
|
|
13
|
+
xenfra/utils/security.py,sha256=jI62NtIiHkgX6D11B-MysbiI9pbO_VpUtjb5nBufHpo,11911
|
|
14
|
+
xenfra-0.2.4.dist-info/WHEEL,sha256=ZyFSCYkV2BrxH6-HRVRg3R9Fo7MALzer9KiPYqNxSbo,79
|
|
15
|
+
xenfra-0.2.4.dist-info/entry_points.txt,sha256=a_2cGhYK__X6eW05Ba8uB6RIM_61c2sHtXsPY8N0mic,45
|
|
16
|
+
xenfra-0.2.4.dist-info/METADATA,sha256=bXoC8y7qZZjVTJeJPrdXZWMiePFoy8VjWgGULvyx1sE,3765
|
|
17
|
+
xenfra-0.2.4.dist-info/RECORD,,
|
xenfra/api/auth.py
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
# src/xenfra/api/auth.py
|
|
2
|
-
|
|
3
|
-
from fastapi import APIRouter, Depends, HTTPException, status
|
|
4
|
-
from fastapi.security import OAuth2PasswordRequestForm
|
|
5
|
-
from sqlmodel import Session, select
|
|
6
|
-
|
|
7
|
-
from xenfra.db.session import get_session
|
|
8
|
-
from xenfra.db.models import User, UserCreate, UserRead
|
|
9
|
-
from xenfra.security import get_password_hash, verify_password, create_access_token
|
|
10
|
-
|
|
11
|
-
router = APIRouter()
|
|
12
|
-
|
|
13
|
-
@router.post("/register", response_model=UserRead)
|
|
14
|
-
def register_user(user: UserCreate, session: Session = Depends(get_session)):
|
|
15
|
-
"""
|
|
16
|
-
Create a new user.
|
|
17
|
-
"""
|
|
18
|
-
db_user = session.exec(select(User).where(User.email == user.email)).first()
|
|
19
|
-
if db_user:
|
|
20
|
-
raise HTTPException(
|
|
21
|
-
status_code=status.HTTP_400_BAD_REQUEST,
|
|
22
|
-
detail="Email already registered",
|
|
23
|
-
)
|
|
24
|
-
|
|
25
|
-
hashed_password = get_password_hash(user.password)
|
|
26
|
-
new_user = User(email=user.email, hashed_password=hashed_password, is_active=True)
|
|
27
|
-
|
|
28
|
-
session.add(new_user)
|
|
29
|
-
session.commit()
|
|
30
|
-
session.refresh(new_user)
|
|
31
|
-
|
|
32
|
-
return new_user
|
|
33
|
-
|
|
34
|
-
@router.post("/token")
|
|
35
|
-
def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(), session: Session = Depends(get_session)):
|
|
36
|
-
"""
|
|
37
|
-
Login user and return a JWT access token.
|
|
38
|
-
"""
|
|
39
|
-
user = session.exec(select(User).where(User.email == form_data.username)).first()
|
|
40
|
-
if not user or not verify_password(form_data.password, user.hashed_password):
|
|
41
|
-
raise HTTPException(
|
|
42
|
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
43
|
-
detail="Incorrect username or password",
|
|
44
|
-
headers={"WWW-Authenticate": "Bearer"},
|
|
45
|
-
)
|
|
46
|
-
|
|
47
|
-
if not user.is_active:
|
|
48
|
-
raise HTTPException(status_code=400, detail="Inactive user")
|
|
49
|
-
|
|
50
|
-
access_token = create_access_token(data={"sub": user.email})
|
|
51
|
-
return {"access_token": access_token, "token_type": "bearer"}
|
xenfra/api/billing.py
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
# src/xenfra/api/billing.py
|
|
2
|
-
|
|
3
|
-
from fastapi import APIRouter, Depends, HTTPException, status
|
|
4
|
-
from sqlmodel import Session, select
|
|
5
|
-
from pydantic import BaseModel
|
|
6
|
-
|
|
7
|
-
from xenfra.db.session import get_session
|
|
8
|
-
from xenfra.db.models import User, Credential
|
|
9
|
-
from xenfra.dependencies import get_current_active_user # Corrected import
|
|
10
|
-
from xenfra.engine import InfraEngine
|
|
11
|
-
from xenfra.security import decrypt_token
|
|
12
|
-
from xenfra.models import DropletCostRead # Import new model
|
|
13
|
-
|
|
14
|
-
router = APIRouter()
|
|
15
|
-
|
|
16
|
-
class BalanceRead(BaseModel):
|
|
17
|
-
month_to_date_balance: str
|
|
18
|
-
account_balance: str
|
|
19
|
-
month_to_date_usage: str
|
|
20
|
-
generated_at: str
|
|
21
|
-
error: str | None = None
|
|
22
|
-
|
|
23
|
-
@router.get("/balance", response_model=BalanceRead)
|
|
24
|
-
def get_billing_balance(
|
|
25
|
-
session: Session = Depends(get_session),
|
|
26
|
-
user: User = Depends(get_current_active_user)
|
|
27
|
-
):
|
|
28
|
-
"""
|
|
29
|
-
Get the current DigitalOcean account balance and month-to-date usage.
|
|
30
|
-
"""
|
|
31
|
-
# Find the user's DigitalOcean credential
|
|
32
|
-
do_credential = session.exec(
|
|
33
|
-
select(Credential).where(Credential.user_id == user.id, Credential.service == "digitalocean")
|
|
34
|
-
).first()
|
|
35
|
-
|
|
36
|
-
if not do_credential:
|
|
37
|
-
raise HTTPException(
|
|
38
|
-
status_code=status.HTTP_404_NOT_FOUND,
|
|
39
|
-
detail="DigitalOcean credential not found for this user. Please connect your account.",
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
try:
|
|
43
|
-
do_token = decrypt_token(do_credential.encrypted_token)
|
|
44
|
-
engine = InfraEngine(token=do_token)
|
|
45
|
-
balance_data = engine.get_account_balance()
|
|
46
|
-
return BalanceRead(**balance_data)
|
|
47
|
-
except Exception as e:
|
|
48
|
-
raise HTTPException(
|
|
49
|
-
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
50
|
-
detail=f"Failed to fetch balance from DigitalOcean: {e}",
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
@router.get("/droplets", response_model=list[DropletCostRead])
|
|
54
|
-
def get_billing_droplets(
|
|
55
|
-
session: Session = Depends(get_session),
|
|
56
|
-
user: User = Depends(get_current_active_user)
|
|
57
|
-
):
|
|
58
|
-
"""
|
|
59
|
-
Get a list of Xenfra-managed DigitalOcean droplets with their estimated monthly costs.
|
|
60
|
-
"""
|
|
61
|
-
do_credential = session.exec(
|
|
62
|
-
select(Credential).where(Credential.user_id == user.id, Credential.service == "digitalocean")
|
|
63
|
-
).first()
|
|
64
|
-
|
|
65
|
-
if not do_credential:
|
|
66
|
-
raise HTTPException(
|
|
67
|
-
status_code=status.HTTP_404_NOT_FOUND,
|
|
68
|
-
detail="DigitalOcean credential not found for this user. Please connect your account.",
|
|
69
|
-
)
|
|
70
|
-
|
|
71
|
-
try:
|
|
72
|
-
do_token = decrypt_token(do_credential.encrypted_token)
|
|
73
|
-
engine = InfraEngine(token=do_token)
|
|
74
|
-
droplets_data = engine.get_droplet_cost_estimates()
|
|
75
|
-
return [DropletCostRead(**d) for d in droplets_data]
|
|
76
|
-
except Exception as e:
|
|
77
|
-
raise HTTPException(
|
|
78
|
-
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
79
|
-
detail=f"Failed to fetch droplet cost estimates from DigitalOcean: {e}",
|
|
80
|
-
)
|
xenfra/api/connections.py
DELETED
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
# src/xenfra/api/connections.py
|
|
2
|
-
|
|
3
|
-
import httpx
|
|
4
|
-
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
|
5
|
-
from fastapi.responses import RedirectResponse
|
|
6
|
-
from sqlmodel import Session, select
|
|
7
|
-
import secrets
|
|
8
|
-
|
|
9
|
-
from xenfra.db.session import get_session
|
|
10
|
-
from xenfra.db.models import User, Credential, CredentialCreate
|
|
11
|
-
from xenfra.dependencies import get_current_active_user # Corrected import
|
|
12
|
-
from xenfra.security import encrypt_token
|
|
13
|
-
from xenfra.config import settings
|
|
14
|
-
|
|
15
|
-
router = APIRouter()
|
|
16
|
-
|
|
17
|
-
# --- GitHub OAuth ---
|
|
18
|
-
# GITHUB_CLIENT_ID = os.getenv("GITHUB_CLIENT_ID") # Moved inside function
|
|
19
|
-
# GITHUB_CLIENT_SECRET = os.getenv("GITHUB_CLIENT_SECRET") # Moved inside function
|
|
20
|
-
# GITHUB_REDIRECT_URI = os.getenv("GITHUB_REDIRECT_URI", "http://localhost:8000/connections/github/callback") # Moved inside function
|
|
21
|
-
|
|
22
|
-
@router.get("/github/login")
|
|
23
|
-
def github_login(request: Request): # Add request: Request
|
|
24
|
-
"""Redirects the user to GitHub for authorization."""
|
|
25
|
-
GITHUB_CLIENT_ID = settings.GITHUB_CLIENT_ID
|
|
26
|
-
GITHUB_REDIRECT_URI = settings.GITHUB_REDIRECT_URI
|
|
27
|
-
|
|
28
|
-
state = secrets.token_urlsafe(32)
|
|
29
|
-
request.session["github_oauth_state"] = state
|
|
30
|
-
|
|
31
|
-
return RedirectResponse(
|
|
32
|
-
f"https://github.com/login/oauth/authorize?client_id={GITHUB_CLIENT_ID}&scope=repo%20user:email&redirect_uri={GITHUB_REDIRECT_URI}&state={state}",
|
|
33
|
-
status_code=302
|
|
34
|
-
)
|
|
35
|
-
|
|
36
|
-
@router.get("/github/callback")
|
|
37
|
-
async def github_callback(code: str, request: Request, session: Session = Depends(get_session), user: User = Depends(get_current_active_user)):
|
|
38
|
-
"""
|
|
39
|
-
Handles the callback from GitHub, exchanges the code for a token,
|
|
40
|
-
and stores the encrypted token.
|
|
41
|
-
"""
|
|
42
|
-
received_state = request.query_params.get("state")
|
|
43
|
-
stored_state = request.session.pop("github_oauth_state", None)
|
|
44
|
-
|
|
45
|
-
if not received_state or not stored_state or received_state != stored_state:
|
|
46
|
-
raise HTTPException(status_code=400, detail="Invalid OAuth state. Possible CSRF attack.")
|
|
47
|
-
|
|
48
|
-
GITHUB_CLIENT_ID = settings.GITHUB_CLIENT_ID
|
|
49
|
-
GITHUB_CLIENT_SECRET = settings.GITHUB_CLIENT_SECRET
|
|
50
|
-
if not GITHUB_CLIENT_SECRET: # Explicit check for client secret
|
|
51
|
-
raise HTTPException(status_code=500, detail="GitHub client secret not configured.")
|
|
52
|
-
# GITHUB_REDIRECT_URI is not needed here
|
|
53
|
-
|
|
54
|
-
async with httpx.AsyncClient() as client:
|
|
55
|
-
token_response = await client.post(
|
|
56
|
-
"https://github.com/login/oauth/access_token",
|
|
57
|
-
json={"client_id": GITHUB_CLIENT_ID, "client_secret": GITHUB_CLIENT_SECRET, "code": code},
|
|
58
|
-
headers={"Accept": "application/json"}
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
token_data = await token_response.json() # Await the json() coroutine
|
|
62
|
-
access_token = token_data.get("access_token")
|
|
63
|
-
|
|
64
|
-
if not access_token:
|
|
65
|
-
# GitHub might return an error in token_data, e.g., {'error': 'bad_verification_code'}
|
|
66
|
-
error_detail = token_data.get("error_description", token_data.get("error", "Unknown error during token exchange"))
|
|
67
|
-
raise HTTPException(status_code=400, detail=f"Could not fetch GitHub access token: {error_detail}")
|
|
68
|
-
|
|
69
|
-
# Encrypt and store the token
|
|
70
|
-
encrypted_token = encrypt_token(access_token)
|
|
71
|
-
|
|
72
|
-
# Fetch GitHub App installation ID
|
|
73
|
-
async with httpx.AsyncClient() as client:
|
|
74
|
-
installations_response = await client.get(
|
|
75
|
-
"https://api.github.com/user/installations",
|
|
76
|
-
headers={
|
|
77
|
-
"Authorization": f"token {access_token}",
|
|
78
|
-
"Accept": "application/vnd.github.v3+json"
|
|
79
|
-
}
|
|
80
|
-
)
|
|
81
|
-
installations_data = await installations_response.json()
|
|
82
|
-
installation_id = None
|
|
83
|
-
if installations_data and "installations" in installations_data and len(installations_data["installations"]) > 0:
|
|
84
|
-
installation_id = installations_data["installations"][0]["id"]
|
|
85
|
-
|
|
86
|
-
new_credential = CredentialCreate(
|
|
87
|
-
service="github",
|
|
88
|
-
encrypted_token=encrypted_token,
|
|
89
|
-
user_id=user.id,
|
|
90
|
-
github_installation_id=installation_id
|
|
91
|
-
)
|
|
92
|
-
db_credential = Credential.model_validate(new_credential)
|
|
93
|
-
session.add(db_credential)
|
|
94
|
-
session.commit()
|
|
95
|
-
|
|
96
|
-
# Redirect user back to the frontend (URL should be configurable)
|
|
97
|
-
return RedirectResponse(url=f"{settings.FRONTEND_OAUTH_REDIRECT_SUCCESS}?success=github", status_code=302)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
# --- DigitalOcean OAuth ---
|
|
101
|
-
# DO_CLIENT_ID = os.getenv("DO_CLIENT_ID") # Moved inside function
|
|
102
|
-
# DO_CLIENT_SECRET = os.getenv("DO_CLIENT_SECRET") # Moved inside function
|
|
103
|
-
# DO_REDIRECT_URI = os.getenv("DO_REDIRECT_URI", "http://localhost:8000/connections/digitalocean/callback") # Moved inside function
|
|
104
|
-
|
|
105
|
-
@router.get("/digitalocean/login")
|
|
106
|
-
def digitalocean_login(request: Request): # Add request: Request
|
|
107
|
-
"""Redirects the user to DigitalOcean for authorization."""
|
|
108
|
-
DO_CLIENT_ID = settings.DO_CLIENT_ID
|
|
109
|
-
DO_REDIRECT_URI = settings.DO_REDIRECT_URI
|
|
110
|
-
|
|
111
|
-
state = secrets.token_urlsafe(32)
|
|
112
|
-
request.session["digitalocean_oauth_state"] = state
|
|
113
|
-
|
|
114
|
-
return RedirectResponse(
|
|
115
|
-
f"https://cloud.digitalocean.com/v1/oauth/authorize?client_id={DO_CLIENT_ID}&response_type=code&scope=read%20write&redirect_uri={DO_REDIRECT_URI}&state={state}",
|
|
116
|
-
status_code=302
|
|
117
|
-
)
|
|
118
|
-
|
|
119
|
-
@router.get("/digitalocean/callback")
|
|
120
|
-
async def digitalocean_callback(code: str, request: Request, session: Session = Depends(get_session), user: User = Depends(get_current_active_user)):
|
|
121
|
-
"""
|
|
122
|
-
Handles the callback from DigitalOcean, exchanges the code for a token,
|
|
123
|
-
and stores the encrypted token.
|
|
124
|
-
"""
|
|
125
|
-
received_state = request.query_params.get("state")
|
|
126
|
-
stored_state = request.session.pop("digitalocean_oauth_state", None)
|
|
127
|
-
|
|
128
|
-
if not received_state or not stored_state or received_state != stored_state:
|
|
129
|
-
raise HTTPException(status_code=400, detail="Invalid OAuth state. Possible CSRF attack.")
|
|
130
|
-
|
|
131
|
-
DO_CLIENT_ID = settings.DO_CLIENT_ID
|
|
132
|
-
DO_CLIENT_SECRET = settings.DO_CLIENT_SECRET
|
|
133
|
-
DO_REDIRECT_URI = settings.DO_REDIRECT_URI
|
|
134
|
-
if not DO_CLIENT_SECRET: # Explicit check for client secret
|
|
135
|
-
raise HTTPException(status_code=500, detail="DigitalOcean client secret not configured.")
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
async with httpx.AsyncClient() as client:
|
|
139
|
-
token_response = await client.post(
|
|
140
|
-
"https://cloud.digitalocean.com/v1/oauth/token",
|
|
141
|
-
params={
|
|
142
|
-
"grant_type": "authorization_code",
|
|
143
|
-
"client_id": DO_CLIENT_ID,
|
|
144
|
-
"client_secret": DO_CLIENT_SECRET,
|
|
145
|
-
"code": code,
|
|
146
|
-
"redirect_uri": DO_REDIRECT_URI
|
|
147
|
-
}
|
|
148
|
-
)
|
|
149
|
-
|
|
150
|
-
token_data = await token_response.json() # Await the json() coroutine
|
|
151
|
-
access_token = token_data.get("access_token")
|
|
152
|
-
|
|
153
|
-
if not access_token:
|
|
154
|
-
error_detail = token_data.get("error_description", token_data.get("error", "Unknown error during token exchange"))
|
|
155
|
-
raise HTTPException(status_code=400, detail=f"Could not fetch DigitalOcean access token: {error_detail}")
|
|
156
|
-
|
|
157
|
-
encrypted_token = encrypt_token(access_token)
|
|
158
|
-
new_credential = CredentialCreate(service="digitalocean", encrypted_token=encrypted_token, user_id=user.id)
|
|
159
|
-
db_credential = Credential.model_validate(new_credential)
|
|
160
|
-
session.add(db_credential)
|
|
161
|
-
session.commit()
|
|
162
|
-
|
|
163
|
-
return RedirectResponse(url=f"{settings.FRONTEND_OAUTH_REDIRECT_SUCCESS}?success=digitalocean", status_code=302)
|
xenfra/api/main.py
DELETED
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
from fastapi import FastAPI, Depends, HTTPException, status, Request, BackgroundTasks
|
|
2
|
-
from fastapi.middleware.cors import CORSMiddleware
|
|
3
|
-
from starlette.middleware.sessions import SessionMiddleware # Import SessionMiddleware
|
|
4
|
-
from fastapi.security import OAuth2PasswordBearer # This will be removed, as oauth2_scheme is in dependencies
|
|
5
|
-
from sqlmodel import Session, select
|
|
6
|
-
from jose import JWTError
|
|
7
|
-
from typing import List
|
|
8
|
-
from datetime import datetime
|
|
9
|
-
|
|
10
|
-
import os # Keep os import for now as it is used in other places.
|
|
11
|
-
|
|
12
|
-
from xenfra.engine import InfraEngine
|
|
13
|
-
from xenfra.models import Deployment, ProjectRead # Import ProjectRead
|
|
14
|
-
from xenfra.db.session import create_db_and_tables, get_session
|
|
15
|
-
from xenfra.db.models import User, UserRead, Credential, CredentialRead
|
|
16
|
-
from xenfra.security import decode_token, decrypt_token
|
|
17
|
-
from xenfra.dependencies import get_current_user, get_current_active_user, oauth2_scheme # Import oauth2_scheme from dependencies
|
|
18
|
-
from pydantic import BaseModel
|
|
19
|
-
from xenfra.config import settings
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
# --- Lifespan ---
|
|
23
|
-
from contextlib import asynccontextmanager
|
|
24
|
-
|
|
25
|
-
@asynccontextmanager
|
|
26
|
-
async def lifespan(app: FastAPI):
|
|
27
|
-
create_db_and_tables()
|
|
28
|
-
yield
|
|
29
|
-
|
|
30
|
-
# --- App Initialization ---
|
|
31
|
-
app = FastAPI(
|
|
32
|
-
title="Xenfra API",
|
|
33
|
-
description="API for the Xenfra deployment engine.",
|
|
34
|
-
version="0.1.0",
|
|
35
|
-
lifespan=lifespan
|
|
36
|
-
)
|
|
37
|
-
|
|
38
|
-
# --- Middleware ---
|
|
39
|
-
app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY) # Use settings.SECRET_KEY here
|
|
40
|
-
app.add_middleware(
|
|
41
|
-
CORSMiddleware,
|
|
42
|
-
allow_origins=["*"], # For dev, allow all. In prod, strict listing.
|
|
43
|
-
allow_credentials=True,
|
|
44
|
-
allow_methods=["*"],
|
|
45
|
-
allow_headers=["*"],
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
# --- Router Imports (Moved after app initialization) ---
|
|
49
|
-
from xenfra.api import auth, connections, webhooks, billing
|
|
50
|
-
|
|
51
|
-
# --- Routers ---
|
|
52
|
-
app.include_router(auth.router, prefix="/auth", tags=["Authentication"])
|
|
53
|
-
app.include_router(connections.router, prefix="/connections", tags=["Connections"])
|
|
54
|
-
app.include_router(webhooks.router, prefix="/webhooks", tags=["Webhooks"])
|
|
55
|
-
app.include_router(billing.router, prefix="/billing", tags=["Billing"])
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
# --- Models for Frontend Requests ---
|
|
59
|
-
class DeploymentCreateRequest(BaseModel):
|
|
60
|
-
name: str
|
|
61
|
-
region: str
|
|
62
|
-
size: str
|
|
63
|
-
image: str
|
|
64
|
-
email: str
|
|
65
|
-
domain: str | None = None
|
|
66
|
-
repo_url: str | None = None # For future Git deployments via web UI
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
# --- Endpoints ---
|
|
70
|
-
@app.get("/")
|
|
71
|
-
def read_root():
|
|
72
|
-
return {"message": "Welcome to the Xenfra API"}
|
|
73
|
-
|
|
74
|
-
@app.post("/deployments/new")
|
|
75
|
-
def create_new_deployment(
|
|
76
|
-
request: DeploymentCreateRequest,
|
|
77
|
-
background_tasks: BackgroundTasks,
|
|
78
|
-
session: Session = Depends(get_session),
|
|
79
|
-
user: User = Depends(get_current_active_user)
|
|
80
|
-
):
|
|
81
|
-
"""
|
|
82
|
-
Triggers a new deployment based on provided configuration.
|
|
83
|
-
"""
|
|
84
|
-
do_credential = session.exec(
|
|
85
|
-
select(Credential).where(Credential.user_id == user.id, Credential.service == "digitalocean")
|
|
86
|
-
).first()
|
|
87
|
-
|
|
88
|
-
if not do_credential:
|
|
89
|
-
raise HTTPException(
|
|
90
|
-
status_code=status.HTTP_404_NOT_FOUND,
|
|
91
|
-
detail="DigitalOcean credential not found for this user. Please connect your account.",
|
|
92
|
-
)
|
|
93
|
-
|
|
94
|
-
try:
|
|
95
|
-
do_token = decrypt_token(do_credential.encrypted_token)
|
|
96
|
-
engine = InfraEngine(token=do_token)
|
|
97
|
-
|
|
98
|
-
# Offload the deployment to a background task
|
|
99
|
-
background_tasks.add_task(
|
|
100
|
-
engine.deploy_server,
|
|
101
|
-
name=request.name,
|
|
102
|
-
region=request.region,
|
|
103
|
-
size=request.size,
|
|
104
|
-
image=request.image,
|
|
105
|
-
email=request.email,
|
|
106
|
-
domain=request.domain,
|
|
107
|
-
repo_url=request.repo_url
|
|
108
|
-
)
|
|
109
|
-
return {"status": "success", "message": "Deployment initiated in background."}
|
|
110
|
-
except Exception as e:
|
|
111
|
-
raise HTTPException(
|
|
112
|
-
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
113
|
-
detail=f"Deployment failed: {e}",
|
|
114
|
-
)
|
|
115
|
-
|
|
116
|
-
@app.get("/users/me", response_model=UserRead)
|
|
117
|
-
def read_users_me(current_user: User = Depends(get_current_active_user)):
|
|
118
|
-
"""
|
|
119
|
-
Fetch the current logged in user.
|
|
120
|
-
"""
|
|
121
|
-
return current_user
|
|
122
|
-
|
|
123
|
-
@app.get("/users/me/connections", response_model=List[CredentialRead])
|
|
124
|
-
def read_user_connections(current_user: User = Depends(get_current_active_user), session: Session = Depends(get_session)):
|
|
125
|
-
"""
|
|
126
|
-
Fetch the current logged in user's connected credentials (GitHub, DigitalOcean).
|
|
127
|
-
"""
|
|
128
|
-
credentials = session.exec(select(Credential).where(Credential.user_id == current_user.id)).all()
|
|
129
|
-
return credentials
|
|
130
|
-
|
|
131
|
-
@app.get("/projects", response_model=List[ProjectRead])
|
|
132
|
-
def list_projects(
|
|
133
|
-
session: Session = Depends(get_session),
|
|
134
|
-
user: User = Depends(get_current_active_user)
|
|
135
|
-
):
|
|
136
|
-
"""
|
|
137
|
-
Lists Xenfra-managed Droplets as projects.
|
|
138
|
-
"""
|
|
139
|
-
do_credential = session.exec(
|
|
140
|
-
select(Credential).where(Credential.user_id == user.id, Credential.service == "digitalocean")
|
|
141
|
-
).first()
|
|
142
|
-
|
|
143
|
-
if not do_credential:
|
|
144
|
-
raise HTTPException(
|
|
145
|
-
status_code=status.HTTP_404_NOT_FOUND,
|
|
146
|
-
detail="DigitalOcean credential not found for this user. Please connect your account.",
|
|
147
|
-
)
|
|
148
|
-
|
|
149
|
-
try:
|
|
150
|
-
do_token = decrypt_token(do_credential.encrypted_token)
|
|
151
|
-
engine = InfraEngine(token=do_token)
|
|
152
|
-
|
|
153
|
-
droplets = engine.list_servers()
|
|
154
|
-
projects = []
|
|
155
|
-
for droplet in droplets:
|
|
156
|
-
# For V0, a "project" is simply a Xenfra-managed Droplet
|
|
157
|
-
# We filter by name convention 'xenfra-'
|
|
158
|
-
if droplet.name.startswith("xenfra-"):
|
|
159
|
-
estimated_monthly_cost = droplet.size['price_monthly'] if droplet.size else None
|
|
160
|
-
projects.append(ProjectRead(
|
|
161
|
-
id=droplet.id,
|
|
162
|
-
name=droplet.name,
|
|
163
|
-
ip_address=droplet.ip_address,
|
|
164
|
-
status=droplet.status,
|
|
165
|
-
region=droplet.region['slug'],
|
|
166
|
-
size_slug=droplet.size['slug'],
|
|
167
|
-
estimated_monthly_cost=estimated_monthly_cost,
|
|
168
|
-
created_at=datetime.fromisoformat(droplet.created_at.replace('Z', '+00:00')) # Ensure datetime is timezone aware
|
|
169
|
-
))
|
|
170
|
-
return projects
|
|
171
|
-
except Exception as e:
|
|
172
|
-
raise HTTPException(
|
|
173
|
-
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
174
|
-
detail=f"Failed to list projects from DigitalOcean: {e}",
|
|
175
|
-
)
|