|
|
|
import os |
|
import uuid |
|
import time |
|
import zipfile |
|
|
|
|
|
import docker |
|
import git |
|
import hmac |
|
import hashlib |
|
from pyngrok import ngrok |
|
from fastapi import APIRouter, HTTPException, UploadFile, Form, Request, BackgroundTasks |
|
from fastapi.responses import JSONResponse |
|
|
|
|
|
router = APIRouter() |
|
|
|
deployed_projects = {} |
|
|
|
|
|
GITHUB_WEBHOOK_SECRET = os.getenv("GITHUB_WEBHOOK_SECRET", "your_github_webhook_secret_here_CHANGE_THIS") |
|
if GITHUB_WEBHOOK_SECRET == "your_github_webhook_secret_here_CHANGE_THIS": |
|
print("WARNING: GITHUB_WEBHOOK_SECRET is not set. Webhook security is compromised.") |
|
|
|
|
|
|
|
|
|
def _find_file_in_project(filename: str, root_dir: str) -> str | None: |
|
""" |
|
Searches for a file (case-insensitive) within the given root directory and its subdirectories. |
|
Returns the absolute path to the file if found, otherwise None. |
|
""" |
|
filename_lower = filename.lower() |
|
for dirpath, _, files in os.walk(root_dir): |
|
for file in files: |
|
if file.lower() == filename_lower: |
|
return os.path.join(dirpath, file) |
|
return None |
|
|
|
|
|
async def _build_and_deploy(project_id: str, project_path: str, app_name: str, existing_container_name: str = None): |
|
""" |
|
Handles the Docker build and deployment process for a given project. |
|
If an existing_container_name is provided, it attempts to stop and remove it first. |
|
Manages ngrok tunnels for the deployed application. |
|
""" |
|
docker_client = docker.from_env() |
|
|
|
|
|
image_name = f"{app_name.lower()}_{project_id[:8]}" |
|
container_name = f"{image_name}_container" |
|
|
|
try: |
|
|
|
|
|
if existing_container_name: |
|
print(f"Attempting to stop and remove existing container: {existing_container_name}") |
|
try: |
|
old_container = docker_client.containers.get(existing_container_name) |
|
old_container.stop(timeout=5) |
|
old_container.remove(force=True) |
|
print(f"Successfully stopped and removed old container: {existing_container_name}") |
|
except docker.errors.NotFound: |
|
print(f"Existing container {existing_container_name} not found, proceeding with new deployment.") |
|
except Exception as e: |
|
print(f"Error stopping/removing old container {existing_container_name}: {e}") |
|
|
|
|
|
|
|
for c in docker_client.containers.list(all=True): |
|
if c.status in ["created", "exited"]: |
|
|
|
|
|
if c.name.startswith(f"{app_name.lower()}_{project_id[:8]}") or c.name.startswith(f"ngrok-"): |
|
print(f"Removing leftover container {c.name} ({c.id}) with status {c.status}") |
|
try: |
|
c.remove(force=True) |
|
except Exception as e: |
|
print(f"Error removing leftover container {c.name}: {e}") |
|
|
|
|
|
print(f"Building Docker image from {project_path} with tag: {image_name}") |
|
image, build_logs_generator = docker_client.images.build(path=project_path, tag=image_name, rm=True) |
|
|
|
for log_line in build_logs_generator: |
|
if 'stream' in log_line: |
|
print(f"[BUILD LOG] {log_line['stream'].strip()}") |
|
elif 'error' in log_line: |
|
print(f"[BUILD ERROR] {log_line['error'].strip()}") |
|
|
|
print(f"Docker image built successfully: {image.id}") |
|
|
|
|
|
print(f"Running new container {container_name} from image {image_name}") |
|
container = docker_client.containers.run( |
|
image=image_name, |
|
ports={"8080/tcp": None}, |
|
name=container_name, |
|
detach=True, |
|
mem_limit="512m", |
|
nano_cpus=1_000_000_000, |
|
read_only=True, |
|
tmpfs={"/tmp": ""}, |
|
user="1001:1001" |
|
) |
|
print(f"Container started with ID: {container.id}") |
|
|
|
|
|
time.sleep(5) |
|
|
|
|
|
port_info = docker_client.api.port(container.id, 8080) |
|
if not port_info: |
|
|
|
print(f"Error: Port 8080 not exposed by container {container.id}. Inspecting container logs...") |
|
try: |
|
container_logs = container.logs().decode('utf-8') |
|
print(f"Container logs:\n{container_logs}") |
|
except Exception as log_e: |
|
print(f"Could not retrieve container logs: {log_e}") |
|
container.stop() |
|
container.remove(force=True) |
|
raise Exception("Port 8080 not exposed by container or container failed to start correctly. Check container logs.") |
|
|
|
host_port = port_info[0]['HostPort'] |
|
print(f"Container {container.id} is accessible on host port: {host_port}") |
|
|
|
|
|
|
|
if project_id in deployed_projects and deployed_projects[project_id].get('ngrok_tunnel'): |
|
existing_tunnel = deployed_projects[project_id]['ngrok_tunnel'] |
|
print(f"Closing existing ngrok tunnel: {existing_tunnel.public_url}") |
|
try: |
|
existing_tunnel.disconnect() |
|
except Exception as ngrok_disconnect_e: |
|
print(f"Error disconnecting existing ngrok tunnel: {ngrok_disconnect_e}") |
|
deployed_projects[project_id]['ngrok_tunnel'] = None |
|
|
|
|
|
print(f"Connecting new ngrok tunnel to host port {host_port}") |
|
tunnel = ngrok.connect(host_port, bind_tls=True) |
|
public_url = tunnel.public_url |
|
print(f"Ngrok public URL for {app_name}: {public_url}") |
|
|
|
|
|
|
|
if project_id not in deployed_projects: |
|
deployed_projects[project_id] = {} |
|
|
|
deployed_projects[project_id].update({ |
|
"container_id": container.id, |
|
"container_name": container_name, |
|
"ngrok_tunnel": tunnel, |
|
"public_url": public_url, |
|
"status": "deployed" |
|
}) |
|
|
|
return public_url, container_name |
|
|
|
except docker.errors.BuildError as e: |
|
print(f"Docker build error: {e}") |
|
|
|
build_logs_str = "\n".join([str(log_line.get('stream', '')).strip() for log_line in e.build_log if 'stream' in log_line]) |
|
if project_id in deployed_projects: |
|
deployed_projects[project_id]["status"] = "failed" |
|
raise HTTPException(status_code=500, detail=f"Docker build failed: {e.msg}\nLogs:\n{build_logs_str}") |
|
except docker.errors.ContainerError as e: |
|
print(f"Docker container runtime error: {e}") |
|
if project_id in deployed_projects: |
|
deployed_projects[project_id]["status"] = "failed" |
|
raise HTTPException(status_code=500, detail=f"Container failed during runtime: {e.stderr.decode()}") |
|
except docker.errors.APIError as e: |
|
print(f"Docker API error: {e}") |
|
if project_id in deployed_projects: |
|
deployed_projects[project_id]["status"] = "failed" |
|
raise HTTPException(status_code=500, detail=f"Docker daemon or API error: {e.explanation}") |
|
except Exception as e: |
|
print(f"General deployment error: {e}") |
|
if project_id in deployed_projects: |
|
deployed_projects[project_id]["status"] = "failed" |
|
raise HTTPException(status_code=500, detail=f"Deployment process failed unexpectedly: {str(e)}") |
|
|
|
|
|
|
|
@router.post("/project") |
|
async def deploy_from_git(repo_url: str = Form(...), app_name: str = Form(...)): |
|
""" |
|
Deploys a FastAPI/Flask application from a specified Git repository. |
|
The repository must contain a main.py, requirements.txt, and Dockerfile. |
|
""" |
|
|
|
if not repo_url.startswith(("http://", "https://", "git@", "ssh://")): |
|
raise HTTPException(status_code=400, detail="Invalid Git repository URL format. Must be HTTP(S) or SSH.") |
|
|
|
|
|
project_id = str(uuid.uuid4()) |
|
|
|
|
|
base_dir = os.path.dirname(os.path.abspath(__file__)) |
|
projects_dir = os.path.abspath(os.path.join(base_dir, "..", "projects")) |
|
os.makedirs(projects_dir, exist_ok=True) |
|
|
|
project_path = os.path.join(projects_dir, project_id) |
|
os.makedirs(project_path, exist_ok=True) |
|
|
|
try: |
|
|
|
print(f"Cloning repository {repo_url} into {project_path}") |
|
git.Repo.clone_from(repo_url, project_path) |
|
print("Repository cloned successfully.") |
|
|
|
except git.exc.GitCommandError as e: |
|
print(f"Git clone failed: {e.stderr.decode()}") |
|
|
|
if os.path.exists(project_path): |
|
import shutil |
|
shutil.rmtree(project_path) |
|
raise HTTPException(status_code=400, detail=f"Failed to clone repository: {e.stderr.decode()}") |
|
except Exception as e: |
|
print(f"Unexpected error during git clone: {e}") |
|
if os.path.exists(project_path): |
|
import shutil |
|
shutil.rmtree(project_path) |
|
raise HTTPException(status_code=500, detail=f"An unexpected error occurred during repository cloning: {str(e)}") |
|
|
|
|
|
main_py_path = _find_file_in_project("main.py", project_path) |
|
requirements_txt_path = _find_file_in_project("requirements.txt", project_path) |
|
dockerfile_path = _find_file_in_project("Dockerfile", project_path) |
|
|
|
missing_files = [] |
|
if not main_py_path: |
|
missing_files.append("main.py") |
|
if not requirements_txt_path: |
|
missing_files.append("requirements.txt") |
|
if not dockerfile_path: |
|
missing_files.append("Dockerfile") |
|
|
|
if missing_files: |
|
|
|
if os.path.exists(project_path): |
|
import shutil |
|
shutil.rmtree(project_path) |
|
raise HTTPException( |
|
status_code=400, |
|
detail=f"The cloned repository is missing required file(s): {', '.join(missing_files)} (case-insensitive search)." |
|
) |
|
|
|
|
|
if os.path.dirname(dockerfile_path) != project_path: |
|
print(f"[DEBUG] Moving Dockerfile from {dockerfile_path} to project root: {project_path}") |
|
target_dockerfile_path = os.path.join(project_path, "Dockerfile") |
|
os.replace(dockerfile_path, target_dockerfile_path) |
|
dockerfile_path = target_dockerfile_path |
|
|
|
|
|
deployed_projects[project_id] = { |
|
"app_name": app_name, |
|
"repo_url": repo_url, |
|
"project_path": project_path, |
|
"status": "building", |
|
"container_name": None, |
|
"public_url": None, |
|
"ngrok_tunnel": None |
|
} |
|
print(f"Project {project_id} initialized for deployment.") |
|
|
|
|
|
try: |
|
public_url, container_name = await _build_and_deploy(project_id, project_path, app_name) |
|
return JSONResponse({ |
|
"project_id": project_id, |
|
"container_name": container_name, |
|
"preview_url": public_url, |
|
"message": "Deployment initiated from Git repository. Check logs for status." |
|
}, status_code=202) |
|
except HTTPException as e: |
|
|
|
if project_id in deployed_projects: |
|
deployed_projects[project_id]["status"] = "failed" |
|
raise e |
|
except Exception as e: |
|
|
|
if project_id in deployed_projects: |
|
deployed_projects[project_id]["status"] = "failed" |
|
print(f"Error during initial _build_and_deploy for project {project_id}: {e}") |
|
raise HTTPException(status_code=500, detail=f"Initial deployment failed unexpectedly: {str(e)}") |
|
|
|
@router.post("/webhook/github") |
|
async def github_webhook(request: Request, background_tasks: BackgroundTasks): |
|
""" |
|
Endpoint to receive GitHub webhook events (e.g., push events) and trigger redeployments. |
|
""" |
|
|
|
|
|
|
|
signature_header = request.headers.get("X-Hub-Signature-256") |
|
if not signature_header: |
|
raise HTTPException(status_code=403, detail="X-Hub-Signature-256 header missing.") |
|
|
|
|
|
body = await request.body() |
|
|
|
try: |
|
|
|
sha_name, signature = signature_header.split("=", 1) |
|
if sha_name != "sha256": |
|
raise HTTPException(status_code=400, detail="Invalid X-Hub-Signature-256 algorithm. Only sha256 supported.") |
|
|
|
|
|
|
|
mac = hmac.new(GITHUB_WEBHOOK_SECRET.encode("utf-8"), body, hashlib.sha256) |
|
|
|
|
|
if not hmac.compare_digest(mac.hexdigest(), signature): |
|
raise HTTPException(status_code=403, detail="Invalid GitHub signature.") |
|
except Exception as e: |
|
print(f"Webhook signature verification failed: {e}") |
|
raise HTTPException(status_code=403, detail="Signature verification failed.") |
|
|
|
|
|
payload = await request.json() |
|
github_event = request.headers.get("X-GitHub-Event") |
|
|
|
print(f"Received GitHub '{github_event}' webhook for repository: {payload.get('repository', {}).get('full_name')}") |
|
|
|
|
|
if github_event != "push": |
|
return JSONResponse({"message": f"Received '{github_event}' event, but only 'push' events are processed."}, status_code=200) |
|
|
|
|
|
repo_url_from_webhook = payload.get("repository", {}).get("html_url") |
|
if not repo_url_from_webhook: |
|
raise HTTPException(status_code=400, detail="Repository URL not found in webhook payload.") |
|
|
|
|
|
project_to_redeploy = None |
|
project_id_to_redeploy = None |
|
for project_id, project_data in deployed_projects.items(): |
|
|
|
if project_data.get("repo_url") == repo_url_from_webhook: |
|
project_to_redeploy = project_data |
|
project_id_to_redeploy = project_id |
|
break |
|
|
|
if not project_to_redeploy: |
|
print(f"No active project found for repository: {repo_url_from_webhook}. Webhook ignored.") |
|
return JSONResponse({"message": "No associated project found for this repository, ignoring webhook."}, status_code=200) |
|
|
|
print(f"Received push for {repo_url_from_webhook}. Triggering redeployment for project {project_id_to_redeploy} ({project_to_redeploy['app_name']}).") |
|
|
|
|
|
project_path = project_to_redeploy["project_path"] |
|
try: |
|
repo = git.Repo(project_path) |
|
origin = repo.remotes.origin |
|
print(f"Pulling latest changes for {repo_url_from_webhook} into {project_path}") |
|
origin.pull() |
|
print("Latest changes pulled successfully.") |
|
except git.exc.GitCommandError as e: |
|
print(f"Failed to pull latest changes for {repo_url_from_webhook}: {e.stderr.decode()}") |
|
|
|
deployed_projects[project_id_to_redeploy]["status"] = "failed" |
|
return JSONResponse({"error": f"Failed to pull latest changes: {e.stderr.decode()}"}, status_code=500) |
|
except Exception as e: |
|
print(f"Unexpected error during git pull: {e}") |
|
deployed_projects[project_id_to_redeploy]["status"] = "failed" |
|
return JSONResponse({"error": f"An unexpected error occurred during git pull: {str(e)}"}, status_code=500) |
|
|
|
|
|
|
|
|
|
|
|
|
|
current_container_name = project_to_redeploy.get("container_name") |
|
|
|
|
|
background_tasks.add_task( |
|
_build_and_deploy, |
|
project_id_to_redeploy, |
|
project_path, |
|
project_to_redeploy["app_name"], |
|
current_container_name |
|
) |
|
|
|
|
|
deployed_projects[project_id_to_redeploy]["status"] = "redeploying" |
|
|
|
return JSONResponse( |
|
{"message": f"Redeployment for project {project_id_to_redeploy} initiated from GitHub webhook."}, |
|
background=background_tasks, |
|
status_code=202 |
|
) |
|
|
|
|
|
@router.post("/project/delete/{project_id}") |
|
async def delete_project(project_id: str): |
|
""" |
|
Deletes a deployed project, its Docker container, ngrok tunnel, and local files. |
|
""" |
|
if project_id not in deployed_projects: |
|
raise HTTPException(status_code=404, detail=f"Project with ID {project_id} not found.") |
|
|
|
project_data = deployed_projects[project_id] |
|
|
|
|
|
docker_client = docker.from_env() |
|
container_name = project_data.get("container_name") |
|
if container_name: |
|
try: |
|
container = docker_client.containers.get(container_name) |
|
container.stop(timeout=5) |
|
container.remove(force=True) |
|
print(f"Container {container_name} for project {project_id} removed.") |
|
except docker.errors.NotFound: |
|
print(f"Container {container_name} not found, already removed?") |
|
except Exception as e: |
|
print(f"Error removing container {container_name}: {e}") |
|
|
|
|
|
|
|
ngrok_tunnel = project_data.get("ngrok_tunnel") |
|
if ngrok_tunnel: |
|
try: |
|
ngrok_tunnel.disconnect() |
|
print(f"Ngrok tunnel for project {project_id} disconnected.") |
|
except Exception as e: |
|
print(f"Error disconnecting ngrok tunnel for project {project_id}: {e}") |
|
|
|
|
|
project_path = project_data.get("project_path") |
|
if project_path and os.path.exists(project_path): |
|
try: |
|
import shutil |
|
shutil.rmtree(project_path) |
|
print(f"Project directory {project_path} removed.") |
|
except Exception as e: |
|
print(f"Error removing project directory {project_path}: {e}") |
|
|
|
|
|
del deployed_projects[project_id] |
|
print(f"Project {project_id} removed from deployed_projects.") |
|
|
|
return JSONResponse({"message": f"Project {project_id} and associated resources deleted."}) |
|
|
|
|