xenfra 0.1.7__tar.gz → 0.1.9__tar.gz
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-0.1.7 → xenfra-0.1.9}/PKG-INFO +30 -63
- xenfra-0.1.9/README.md +57 -0
- {xenfra-0.1.7 → xenfra-0.1.9}/pyproject.toml +15 -2
- xenfra-0.1.9/src/xenfra/api/auth.py +51 -0
- xenfra-0.1.9/src/xenfra/api/billing.py +80 -0
- xenfra-0.1.9/src/xenfra/api/connections.py +163 -0
- xenfra-0.1.9/src/xenfra/api/main.py +175 -0
- xenfra-0.1.9/src/xenfra/api/webhooks.py +146 -0
- xenfra-0.1.9/src/xenfra/cli/main.py +211 -0
- xenfra-0.1.9/src/xenfra/config.py +24 -0
- xenfra-0.1.9/src/xenfra/db/models.py +51 -0
- xenfra-0.1.9/src/xenfra/db/session.py +17 -0
- xenfra-0.1.9/src/xenfra/dependencies.py +35 -0
- xenfra-0.1.9/src/xenfra/dockerizer.py +89 -0
- xenfra-0.1.9/src/xenfra/engine.py +292 -0
- xenfra-0.1.9/src/xenfra/mcp_client.py +149 -0
- xenfra-0.1.9/src/xenfra/models.py +54 -0
- xenfra-0.1.9/src/xenfra/recipes.py +23 -0
- xenfra-0.1.9/src/xenfra/security.py +58 -0
- xenfra-0.1.9/src/xenfra/templates/Dockerfile.j2 +25 -0
- xenfra-0.1.9/src/xenfra/templates/cloud-init.sh.j2 +72 -0
- xenfra-0.1.9/src/xenfra/templates/docker-compose.yml.j2 +33 -0
- xenfra-0.1.7/README.md +0 -103
- xenfra-0.1.7/src/xenfra/cli.py +0 -169
- xenfra-0.1.7/src/xenfra/dockerizer.py +0 -153
- xenfra-0.1.7/src/xenfra/engine.py +0 -264
- xenfra-0.1.7/src/xenfra/recipes.py +0 -121
- {xenfra-0.1.7 → xenfra-0.1.9}/src/xenfra/__init__.py +0 -0
- {xenfra-0.1.7 → xenfra-0.1.9}/src/xenfra/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: xenfra
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.9
|
|
4
4
|
Summary: A 'Zen Mode' infrastructure engine for Python developers.
|
|
5
5
|
Author: xenfra-cloud
|
|
6
6
|
Author-email: xenfra-cloud <xenfracloud@gmail.com>
|
|
@@ -15,6 +15,19 @@ Requires-Dist: fabric>=3.2.2
|
|
|
15
15
|
Requires-Dist: python-digitalocean>=1.17.0
|
|
16
16
|
Requires-Dist: python-dotenv>=1.2.1
|
|
17
17
|
Requires-Dist: rich>=14.2.0
|
|
18
|
+
Requires-Dist: fastapi>=0.110.0
|
|
19
|
+
Requires-Dist: uvicorn[standard]>=0.27.1
|
|
20
|
+
Requires-Dist: click>=8.1.7
|
|
21
|
+
Requires-Dist: sqlmodel>=0.0.16
|
|
22
|
+
Requires-Dist: psycopg2-binary>=2.9.9
|
|
23
|
+
Requires-Dist: python-jose[cryptography]>=3.3.0
|
|
24
|
+
Requires-Dist: passlib>=1.7.4
|
|
25
|
+
Requires-Dist: httpx>=0.27.0
|
|
26
|
+
Requires-Dist: pyyaml>=6.0.1
|
|
27
|
+
Requires-Dist: python-multipart>=0.0.21
|
|
28
|
+
Requires-Dist: bcrypt==4.0.1
|
|
29
|
+
Requires-Dist: jinja2>=3.1.3
|
|
30
|
+
Requires-Dist: pytest>=9.0.2
|
|
18
31
|
Requires-Dist: pytest>=8.0.0 ; extra == 'test'
|
|
19
32
|
Requires-Dist: pytest-mock>=3.12.0 ; extra == 'test'
|
|
20
33
|
Requires-Python: >=3.13
|
|
@@ -35,83 +48,37 @@ It handles the complexity of server provisioning, context-aware configuration, D
|
|
|
35
48
|
* **Clients as the Face**: Frontends like the default CLI (`xenfra.cli`) are thin, stateless clients responsible only for user interaction.
|
|
36
49
|
* **Zen Mode**: If a server setup fails due to common issues like a locked package manager, the Engine automatically fixes it without exposing raw errors to the user.
|
|
37
50
|
|
|
38
|
-
## 🚀 Quickstart
|
|
51
|
+
## 🚀 Quickstart
|
|
39
52
|
|
|
40
|
-
|
|
53
|
+
Using the Xenfra CLI involves a simple workflow: **Configure**, **Initialize**, and then **Deploy & Manage**.
|
|
41
54
|
|
|
42
|
-
### 1.
|
|
55
|
+
### 1. Configure
|
|
43
56
|
|
|
44
|
-
|
|
45
|
-
# Install from PyPI (once published)
|
|
46
|
-
pip install xenfra
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
### 2. Prerequisites
|
|
50
|
-
|
|
51
|
-
Ensure your DigitalOcean API token is available as an environment variable:
|
|
57
|
+
Xenfra needs your DigitalOcean API token to manage infrastructure on your behalf. Export it as an environment variable:
|
|
52
58
|
|
|
53
59
|
```bash
|
|
54
|
-
export DIGITAL_OCEAN_TOKEN="
|
|
60
|
+
export DIGITAL_OCEAN_TOKEN="dop_v1_your_secret_token_here"
|
|
55
61
|
```
|
|
56
62
|
|
|
57
|
-
###
|
|
58
|
-
|
|
59
|
-
```python
|
|
60
|
-
from xenfra.engine import InfraEngine
|
|
63
|
+
### 2. Initialize
|
|
61
64
|
|
|
62
|
-
|
|
63
|
-
def my_logger(message):
|
|
64
|
-
print(f"[My App] {message}")
|
|
65
|
-
|
|
66
|
-
try:
|
|
67
|
-
# The engine automatically finds and validates the API token
|
|
68
|
-
engine = InfraEngine()
|
|
69
|
-
|
|
70
|
-
# Define the server and deploy
|
|
71
|
-
result = engine.deploy_server(
|
|
72
|
-
name="my-app-server",
|
|
73
|
-
region="nyc3",
|
|
74
|
-
size="s-1vcpu-1gb",
|
|
75
|
-
image="ubuntu-24-04-x64",
|
|
76
|
-
logger=my_logger
|
|
77
|
-
)
|
|
78
|
-
|
|
79
|
-
print(f"🎉 Deployment successful! IP Address: {result.get('ip')}")
|
|
80
|
-
|
|
81
|
-
except Exception as e:
|
|
82
|
-
print(f"❌ Deployment failed: {e}")
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
## 💻 CLI Usage
|
|
86
|
-
|
|
87
|
-
Xenfra also provides a beautiful, interactive CLI for manual control.
|
|
88
|
-
|
|
89
|
-
### 1. Installation
|
|
65
|
+
Navigate to your project's root directory and run the `init` command. This command scans your project, asks a few questions, and creates a `xenfra.yaml` configuration file.
|
|
90
66
|
|
|
91
67
|
```bash
|
|
92
|
-
|
|
68
|
+
xenfra init
|
|
93
69
|
```
|
|
70
|
+
You should review the generated `xenfra.yaml` and commit it to your repository.
|
|
94
71
|
|
|
95
|
-
###
|
|
72
|
+
### 3. Deploy & Manage
|
|
96
73
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
```env
|
|
100
|
-
DIGITAL_OCEAN_TOKEN=dop_v1_your_token_here
|
|
101
|
-
```
|
|
74
|
+
Once your project is initialized, you can use the following commands to manage your application:
|
|
102
75
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
xenfra
|
|
109
|
-
```
|
|
76
|
+
* **`xenfra deploy`**: Deploys your application based on the settings in `xenfra.yaml`.
|
|
77
|
+
* **`xenfra list`**: Instantly lists all your deployed projects from a local cache.
|
|
78
|
+
* Use `xenfra list --refresh` to force a sync with your cloud provider.
|
|
79
|
+
* **`xenfra logs`**: Streams real-time logs from a selected project.
|
|
80
|
+
* **`xenfra destroy`**: Decommissions and deletes a deployed project.
|
|
110
81
|
|
|
111
|
-
This will launch the interactive menu where you can:
|
|
112
|
-
- **🚀 Deploy New Server**: A guided workflow to provision and deploy your application.
|
|
113
|
-
- **📋 List Active Servers**: View your current DigitalOcean droplets.
|
|
114
|
-
- **🧨 Destroy a Server**: Decommission servers you no longer need.
|
|
115
82
|
|
|
116
83
|
## 📦 Supported Frameworks & Features
|
|
117
84
|
|
xenfra-0.1.9/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# 🧘 Xenfra: Infrastructure in Zen Mode
|
|
2
|
+
|
|
3
|
+
**Xenfra** is a modular infrastructure engine for Python developers that automates the deployment of applications to DigitalOcean. It is designed as a library first, with a beautiful and interactive CLI as the default frontend.
|
|
4
|
+
|
|
5
|
+
It handles the complexity of server provisioning, context-aware configuration, Dockerization, and automatic HTTPS, allowing you to focus on your code.
|
|
6
|
+
|
|
7
|
+
## ✨ Core Philosophy
|
|
8
|
+
|
|
9
|
+
* **Engine as the Brain**: `xenfra.engine` is the core library. It owns the DigitalOcean API, the SSH "Auto-Heal" retry loops, and the Dockerizer services. It is stateful, robust, and can be imported into any Python project.
|
|
10
|
+
* **Clients as the Face**: Frontends like the default CLI (`xenfra.cli`) are thin, stateless clients responsible only for user interaction.
|
|
11
|
+
* **Zen Mode**: If a server setup fails due to common issues like a locked package manager, the Engine automatically fixes it without exposing raw errors to the user.
|
|
12
|
+
|
|
13
|
+
## 🚀 Quickstart
|
|
14
|
+
|
|
15
|
+
Using the Xenfra CLI involves a simple workflow: **Configure**, **Initialize**, and then **Deploy & Manage**.
|
|
16
|
+
|
|
17
|
+
### 1. Configure
|
|
18
|
+
|
|
19
|
+
Xenfra needs your DigitalOcean API token to manage infrastructure on your behalf. Export it as an environment variable:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
export DIGITAL_OCEAN_TOKEN="dop_v1_your_secret_token_here"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### 2. Initialize
|
|
26
|
+
|
|
27
|
+
Navigate to your project's root directory and run the `init` command. This command scans your project, asks a few questions, and creates a `xenfra.yaml` configuration file.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
xenfra init
|
|
31
|
+
```
|
|
32
|
+
You should review the generated `xenfra.yaml` and commit it to your repository.
|
|
33
|
+
|
|
34
|
+
### 3. Deploy & Manage
|
|
35
|
+
|
|
36
|
+
Once your project is initialized, you can use the following commands to manage your application:
|
|
37
|
+
|
|
38
|
+
* **`xenfra deploy`**: Deploys your application based on the settings in `xenfra.yaml`.
|
|
39
|
+
* **`xenfra list`**: Instantly lists all your deployed projects from a local cache.
|
|
40
|
+
* Use `xenfra list --refresh` to force a sync with your cloud provider.
|
|
41
|
+
* **`xenfra logs`**: Streams real-time logs from a selected project.
|
|
42
|
+
* **`xenfra destroy`**: Decommissions and deletes a deployed project.
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
## 📦 Supported Frameworks & Features
|
|
46
|
+
|
|
47
|
+
* **Smart Context Detection**: Automatically detects your package manager (`uv` or `pip`).
|
|
48
|
+
* **Automatic Dockerization**: If a web framework is detected (`FastAPI`, `Flask`), Xenfra will:
|
|
49
|
+
* Generate a `Dockerfile`, `docker-compose.yml`, and `Caddyfile`.
|
|
50
|
+
* Deploy your application as a container.
|
|
51
|
+
* Configure **Caddy** as a reverse proxy with **automatic HTTPS**.
|
|
52
|
+
|
|
53
|
+
## 🤝 Contributing
|
|
54
|
+
|
|
55
|
+
Contributions are welcome! Please check our `CONTRIBUTING.md` for more details.
|
|
56
|
+
|
|
57
|
+
## 📄 Created by DevHusnainAi
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "xenfra"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.9"
|
|
4
4
|
description = "A 'Zen Mode' infrastructure engine for Python developers."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -21,6 +21,19 @@ dependencies = [
|
|
|
21
21
|
"python-digitalocean>=1.17.0",
|
|
22
22
|
"python-dotenv>=1.2.1",
|
|
23
23
|
"rich>=14.2.0",
|
|
24
|
+
"fastapi>=0.110.0",
|
|
25
|
+
"uvicorn[standard]>=0.27.1",
|
|
26
|
+
"click>=8.1.7",
|
|
27
|
+
"sqlmodel>=0.0.16",
|
|
28
|
+
"psycopg2-binary>=2.9.9",
|
|
29
|
+
"python-jose[cryptography]>=3.3.0",
|
|
30
|
+
"passlib>=1.7.4", # No longer need [bcrypt] extra
|
|
31
|
+
"httpx>=0.27.0",
|
|
32
|
+
"PyYAML>=6.0.1", # Explicitly add bcrypt backend
|
|
33
|
+
"python-multipart>=0.0.21",
|
|
34
|
+
"bcrypt==4.0.1",
|
|
35
|
+
"Jinja2>=3.1.3",
|
|
36
|
+
"pytest>=9.0.2",
|
|
24
37
|
]
|
|
25
38
|
|
|
26
39
|
[project.urls]
|
|
@@ -28,7 +41,7 @@ Homepage = "https://github.com/xenfra-cloud/xenfra"
|
|
|
28
41
|
Issues = "https://github.com/xenfra-cloud/xenfra/issues"
|
|
29
42
|
|
|
30
43
|
[project.scripts]
|
|
31
|
-
xenfra = "xenfra.cli:main"
|
|
44
|
+
xenfra = "xenfra.cli.main:main"
|
|
32
45
|
|
|
33
46
|
[project.optional-dependencies]
|
|
34
47
|
test = [
|
|
@@ -0,0 +1,51 @@
|
|
|
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"}
|
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
)
|
|
@@ -0,0 +1,163 @@
|
|
|
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)
|