xenfra 0.1.7__py3-none-any.whl → 0.1.9__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/api/auth.py ADDED
@@ -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"}
xenfra/api/billing.py ADDED
@@ -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)
xenfra/api/main.py ADDED
@@ -0,0 +1,175 @@
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
+ )
xenfra/api/webhooks.py ADDED
@@ -0,0 +1,146 @@
1
+ # src/xenfra/api/webhooks.py
2
+
3
+ import hmac
4
+ import hashlib
5
+ from fastapi import APIRouter, Depends, HTTPException, status, Request, BackgroundTasks
6
+ from sqlmodel import Session, select
7
+
8
+ from xenfra.db.session import get_session
9
+ from xenfra.db.models import User, Credential
10
+ from xenfra.dependencies import get_current_active_user # Corrected import
11
+ from xenfra.engine import InfraEngine
12
+ from xenfra.security import decrypt_token
13
+ from xenfra.config import settings
14
+
15
+ router = APIRouter()
16
+
17
+ # This secret should be configured in your GitHub App's webhook settings
18
+ GITHUB_WEBHOOK_SECRET = settings.GITHUB_WEBHOOK_SECRET
19
+
20
+ async def verify_github_signature(request: Request):
21
+ """
22
+ Verify that the incoming webhook request is genuinely from GitHub.
23
+ """
24
+ if not GITHUB_WEBHOOK_SECRET:
25
+ raise HTTPException(status_code=500, detail="GitHub webhook secret not configured.")
26
+
27
+ signature_header = request.headers.get("X-Hub-Signature-256")
28
+ if not signature_header:
29
+ raise HTTPException(status_code=400, detail="X-Hub-Signature-256 header is missing.")
30
+
31
+ signature_parts = signature_header.split("=", 1)
32
+ if len(signature_parts) != 2 or signature_parts[0] != "sha256":
33
+ raise HTTPException(status_code=400, detail="Invalid signature format.")
34
+
35
+ signature = signature_parts[1]
36
+ body = await request.body()
37
+
38
+ expected_signature = hmac.new(
39
+ key=GITHUB_WEBHOOK_SECRET.encode(),
40
+ msg=body,
41
+ digestmod=hashlib.sha256
42
+ ).hexdigest()
43
+
44
+ if not hmac.compare_digest(expected_signature, signature):
45
+ raise HTTPException(status_code=400, detail="Invalid signature.")
46
+
47
+
48
+ @router.post("/github", dependencies=[Depends(verify_github_signature)])
49
+ async def github_webhook(request: Request, background_tasks: BackgroundTasks, session: Session = Depends(get_session)):
50
+ """
51
+ Handles incoming webhooks from GitHub to manage Preview Environments.
52
+ """
53
+ payload = await request.json()
54
+ event_type = request.headers.get("X-GitHub-Event")
55
+
56
+ if event_type == "pull_request":
57
+ action = payload.get("action")
58
+ pr_info = payload.get("pull_request", {})
59
+ repo_info = payload.get("repository", {})
60
+
61
+ repo_full_name = repo_info.get("full_name")
62
+ pr_number = pr_info.get("number")
63
+ commit_sha = pr_info.get("head", {}).get("sha")
64
+ clone_url = repo_info.get("clone_url")
65
+ installation_id = payload.get("installation", {}).get("id")
66
+
67
+ if not all([repo_full_name, pr_number, commit_sha, clone_url, installation_id]):
68
+ raise HTTPException(status_code=400, detail="Incomplete pull request payload or missing installation ID.")
69
+
70
+ # Find the user associated with this GitHub App installation
71
+ github_credential = session.exec(
72
+ select(Credential).where(
73
+ Credential.service == "github",
74
+ Credential.github_installation_id == installation_id
75
+ )
76
+ ).first()
77
+
78
+ if not github_credential:
79
+ raise HTTPException(status_code=404, detail=f"No GitHub credential found for installation ID {installation_id}.")
80
+
81
+ user = session.exec(select(User).where(User.id == github_credential.user_id)).first()
82
+ if not user:
83
+ raise HTTPException(status_code=404, detail=f"User not found for credential with installation ID {installation_id}.")
84
+
85
+ # Find the user's DigitalOcean credential
86
+ do_credential = session.exec(
87
+ select(Credential).where(Credential.user_id == user.id, Credential.service == "digitalocean")
88
+ ).first()
89
+
90
+ if not do_credential:
91
+ raise HTTPException(status_code=400, detail=f"No DigitalOcean credential found for user {user.email}.")
92
+
93
+ # Decrypt the token and instantiate the engine
94
+ try:
95
+ do_token = decrypt_token(do_credential.encrypted_token)
96
+ engine = InfraEngine(token=do_token) # InfraEngine needs to be adapted to accept a token
97
+
98
+ print(f"DEBUG(WEBHOOKS): engine object id: {id(engine)}, type: {type(engine)}")
99
+ print(f"DEBUG(WEBHOOKS): engine.list_servers method id: {id(engine.list_servers)}, type: {type(engine.list_servers)}")
100
+
101
+ except Exception as e:
102
+ return {"status": "error", "detail": f"Failed to initialize engine: {e}"}
103
+
104
+ server_name = f"xenfra-pr-{repo_full_name.replace('/', '-')}-{pr_number}"
105
+
106
+ if action in ["opened", "synchronize"]:
107
+ print(f"🚀 Deploying preview for PR #{pr_number} from {repo_full_name} at {commit_sha[:7]}")
108
+ # This should be run in a background task in a real app
109
+ background_tasks.add_task(
110
+ engine.deploy_server,
111
+ name=server_name,
112
+ region="nyc3", # Using a default for now
113
+ size="s-1vcpu-1gb", # Using a default for now
114
+ image="ubuntu-22-04-x64", # Using a default for now
115
+ email=user.email,
116
+ repo_url=clone_url,
117
+ commit_sha=commit_sha
118
+ )
119
+ # TODO: Post comment back to GitHub with the preview URL
120
+ # TODO: Store the droplet_id and PR number association in the DB
121
+
122
+ return {"status": "success", "action": "deploying in background"}
123
+
124
+
125
+ elif action == "closed":
126
+ print(f"🔥 Destroying preview for closed PR #{pr_number} from {repo_full_name}")
127
+ try:
128
+ # Find the droplet associated with this PR
129
+ servers = engine.list_servers()
130
+ droplet_to_destroy = None
131
+ for s in servers:
132
+ if s.name == server_name:
133
+ droplet_to_destroy = s
134
+ break
135
+
136
+ if droplet_to_destroy:
137
+ background_tasks.add_task(engine.destroy_server, droplet_to_destroy.id)
138
+ print(f"✅ Droplet {droplet_to_destroy.name} ({droplet_to_destroy.id}) scheduled for destruction.")
139
+ else:
140
+ print(f"⚠️ No droplet found with name {server_name} to destroy.")
141
+ except Exception as e:
142
+ print(f"❌ Failed to destroy preview environment: {e}")
143
+
144
+ return {"status": "success", "action": "destroying in background"}
145
+
146
+ return {"status": "success", "detail": "Webhook received"}