chipling commited on
Commit
a1d8f81
·
verified ·
1 Parent(s): f0a627b

Update routers/deploy.py

Browse files
Files changed (1) hide show
  1. routers/deploy.py +92 -165
routers/deploy.py CHANGED
@@ -1,194 +1,121 @@
1
- # Standard library imports
2
- import os
 
3
  import uuid
 
 
4
  import time
5
- import zipfile # Still imported, but primarily for handling potential old zip logic or error messages
6
-
7
- # Third-party library imports
8
- import docker # For interacting with Docker daemon
9
- import hmac # For validating GitHub webhook signatures (important for security)
10
- import hashlib # For hashing in webhook signature validation
11
- from pyngrok import ngrok # For creating public URLs (ensure ngrok is configured)
12
- from fastapi import APIRouter, HTTPException, UploadFile, Form, Request, BackgroundTasks # Added Request and BackgroundTasks
13
- from fastapi.responses import JSONResponse
14
 
15
- # Initialize FastAPI router
16
  router = APIRouter()
17
 
18
- deployed_projects = {}
19
-
20
- # --- Helper Functions ---
21
-
22
- # Function to recursively find a file (case-insensitive) within a directory
23
- def _find_file_in_project(filename: str, root_dir: str) -> str | None:
24
- """
25
- Searches for a file (case-insensitive) within the given root directory and its subdirectories.
26
- Returns the absolute path to the file if found, otherwise None.
27
- """
28
- filename_lower = filename.lower()
29
- for dirpath, _, files in os.walk(root_dir):
30
- for file in files:
31
- if file.lower() == filename_lower:
32
- return os.path.join(dirpath, file)
33
- return None
34
-
35
- # Function to build and deploy a Docker container from a project path
36
-
37
- # --- API Endpoints ---
38
-
39
  @router.post("/project")
40
- async def deploy_from_git(repo_url: str = Form(...), app_name: str = Form(...)):
41
- """
42
- Deploys a FastAPI/Flask application from a specified Git repository.
43
- The repository must contain a main.py, requirements.txt, and Dockerfile.
44
- """
45
- # Basic validation for the Git repository URL format
46
- if not repo_url.startswith(("http://", "https://", "git@", "ssh://")):
47
- raise HTTPException(status_code=400, detail="Invalid Git repository URL format. Must be HTTP(S) or SSH.")
48
-
49
- # Generate a unique ID for this project
50
- project_id = str(uuid.uuid4())
51
 
52
- # Define project directories
53
- base_dir = os.path.dirname(os.path.abspath(__file__)) # This is where 'router.py' is
54
- projects_dir = os.path.abspath(os.path.join(base_dir, "..", "projects")) # Parent directory's 'projects' folder
55
- os.makedirs(projects_dir, exist_ok=True) # Ensure the base projects directory exists
 
56
 
57
  project_path = os.path.join(projects_dir, project_id)
58
- os.makedirs(project_path, exist_ok=True) # Create a unique directory for this project
 
 
 
 
 
59
 
 
60
  try:
61
- # Step 1: Clone the Git repository
62
- print(f"Cloning repository {repo_url} into {project_path}")
63
- git.Repo.clone_from(repo_url, project_path)
64
- print("Repository cloned successfully.")
65
-
66
- except git.exc.GitCommandError as e:
67
- print(f"Git clone failed: {e.stderr.decode()}")
68
- # Clean up the partially created project directory if cloning fails
69
- if os.path.exists(project_path):
70
- import shutil
71
- shutil.rmtree(project_path)
72
- raise HTTPException(status_code=400, detail=f"Failed to clone repository: {e.stderr.decode()}")
73
  except Exception as e:
74
- print(f"Unexpected error during git clone: {e}")
75
- if os.path.exists(project_path):
76
- import shutil
77
- shutil.rmtree(project_path)
78
- raise HTTPException(status_code=500, detail=f"An unexpected error occurred during repository cloning: {str(e)}")
 
 
79
 
80
- # Step 2: Validate required project files (main.py, requirements.txt, Dockerfile)
81
- main_py_path = _find_file_in_project("main.py", project_path)
82
- requirements_txt_path = _find_file_in_project("requirements.txt", project_path)
83
- dockerfile_path = _find_file_in_project("Dockerfile", project_path)
 
 
 
 
 
 
 
 
 
84
 
85
  missing_files = []
86
- if not main_py_path:
87
  missing_files.append("main.py")
88
- if not requirements_txt_path:
89
  missing_files.append("requirements.txt")
90
- if not dockerfile_path:
91
  missing_files.append("Dockerfile")
92
 
93
  if missing_files:
94
- # Clean up the project directory if essential files are missing
95
- if os.path.exists(project_path):
96
- import shutil
97
- shutil.rmtree(project_path)
98
  raise HTTPException(
99
  status_code=400,
100
- detail=f"The cloned repository is missing required file(s): {', '.join(missing_files)} (case-insensitive search)."
101
  )
102
 
103
- # Ensure Dockerfile is at the root of the project_path for Docker build context
104
- if os.path.dirname(dockerfile_path) != project_path:
105
- print(f"[DEBUG] Moving Dockerfile from {dockerfile_path} to project root: {project_path}")
106
- target_dockerfile_path = os.path.join(project_path, "Dockerfile")
107
- os.replace(dockerfile_path, target_dockerfile_path)
108
- dockerfile_path = target_dockerfile_path # Update the path to reference the new location
109
-
110
- # Step 3: Store initial project details in global state (or database)
111
- deployed_projects[project_id] = {
112
- "app_name": app_name,
113
- "repo_url": repo_url,
114
- "project_path": project_path,
115
- "status": "building", # Set initial status
116
- "container_name": None, # Will be set by _build_and_deploy
117
- "public_url": None, # Will be set by _build_and_deploy
118
- "ngrok_tunnel": None # Will be set by _build_and_deploy
119
- }
120
- print(f"Project {project_id} initialized for deployment.")
121
-
122
- # Step 4: Trigger the build and deploy process
123
  try:
124
- public_url, container_name = await _build_and_deploy(project_id, project_path, app_name)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  return JSONResponse({
126
  "project_id": project_id,
127
  "container_name": container_name,
128
- "preview_url": public_url,
129
- "message": "Deployment initiated from Git repository. Check logs for status."
130
- }, status_code=202) # Use 202 Accepted, as deployment happens in background
131
- except HTTPException as e:
132
- # If _build_and_deploy raises a specific HTTPException, re-raise it
133
- if project_id in deployed_projects:
134
- deployed_projects[project_id]["status"] = "failed"
135
- raise e
136
- except Exception as e:
137
- # Catch any other unexpected errors during the build/deploy phase
138
- if project_id in deployed_projects:
139
- deployed_projects[project_id]["status"] = "failed"
140
- print(f"Error during initial _build_and_deploy for project {project_id}: {e}")
141
- raise HTTPException(status_code=500, detail=f"Initial deployment failed unexpectedly: {str(e)}")
142
-
143
-
144
- # --- Cleanup Endpoint (Optional, for manual testing/management) ---
145
- @router.post("/project/delete/{project_id}")
146
- async def delete_project(project_id: str):
147
- """
148
- Deletes a deployed project, its Docker container, ngrok tunnel, and local files.
149
- """
150
- if project_id not in deployed_projects:
151
- raise HTTPException(status_code=404, detail=f"Project with ID {project_id} not found.")
152
-
153
- project_data = deployed_projects[project_id]
154
-
155
- # Stop and remove Docker container
156
- docker_client = docker.from_env()
157
- container_name = project_data.get("container_name")
158
- if container_name:
159
- try:
160
- container = docker_client.containers.get(container_name)
161
- container.stop(timeout=5)
162
- container.remove(force=True)
163
- print(f"Container {container_name} for project {project_id} removed.")
164
- except docker.errors.NotFound:
165
- print(f"Container {container_name} not found, already removed?")
166
- except Exception as e:
167
- print(f"Error removing container {container_name}: {e}")
168
- # Do not raise HTTPException, try to continue cleanup
169
-
170
- # Disconnect ngrok tunnel
171
- ngrok_tunnel = project_data.get("ngrok_tunnel")
172
- if ngrok_tunnel:
173
- try:
174
- ngrok_tunnel.disconnect()
175
- print(f"Ngrok tunnel for project {project_id} disconnected.")
176
- except Exception as e:
177
- print(f"Error disconnecting ngrok tunnel for project {project_id}: {e}")
178
-
179
- # Remove local project directory
180
- project_path = project_data.get("project_path")
181
- if project_path and os.path.exists(project_path):
182
- try:
183
- import shutil
184
- shutil.rmtree(project_path)
185
- print(f"Project directory {project_path} removed.")
186
- except Exception as e:
187
- print(f"Error removing project directory {project_path}: {e}")
188
-
189
- # Remove from global state
190
- del deployed_projects[project_id]
191
- print(f"Project {project_id} removed from deployed_projects.")
192
-
193
- return JSONResponse({"message": f"Project {project_id} and associated resources deleted."})
194
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, UploadFile, Form
2
+ from fastapi.responses import JSONResponse
3
+ import docker
4
  import uuid
5
+ import zipfile
6
+ import os
7
  import time
8
+ from pyngrok import ngrok
 
 
 
 
 
 
 
 
9
 
 
10
  router = APIRouter()
11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  @router.post("/project")
13
+ async def deploy_code(file: UploadFile, app_name: str = Form(...)):
14
+ if not file.filename.endswith(".zip"):
15
+ raise HTTPException(status_code=400, detail="Only .zip files are supported")
 
 
 
 
 
 
 
 
16
 
17
+ # Create unique project directory
18
+ project_id = str(uuid.uuid4())
19
+ base_dir = os.path.dirname(os.path.abspath(__file__)) # /router/
20
+ projects_dir = os.path.abspath(os.path.join(base_dir, "..", "projects"))
21
+ os.makedirs(projects_dir, exist_ok=True)
22
 
23
  project_path = os.path.join(projects_dir, project_id)
24
+ os.makedirs(project_path, exist_ok=True)
25
+
26
+ # Save uploaded zip
27
+ zip_path = os.path.join(project_path, file.filename)
28
+ with open(zip_path, "wb") as f:
29
+ f.write(await file.read())
30
 
31
+ # Extract zip contents
32
  try:
33
+ with zipfile.ZipFile(zip_path, 'r') as zip_ref:
34
+ zip_ref.extractall(project_path)
 
 
 
 
 
 
 
 
 
 
35
  except Exception as e:
36
+ raise HTTPException(status_code=400, detail=f"Invalid zip file: {str(e)}")
37
+
38
+ # Debug: print all extracted files
39
+ print(f"[DEBUG] Extracted files in: {project_path}")
40
+ for dirpath, _, files in os.walk(project_path):
41
+ for fname in files:
42
+ print(" -", os.path.join(dirpath, fname))
43
 
44
+ # Case-insensitive recursive search for file
45
+ def find_file(filename, root):
46
+ filename = filename.lower()
47
+ for dirpath, _, files in os.walk(root):
48
+ for file in files:
49
+ if file.lower() == filename:
50
+ return os.path.join(dirpath, file)
51
+ return None
52
+
53
+
54
+ main_py = find_file("main.py", project_path)
55
+ requirements_txt = find_file("requirements.txt", project_path)
56
+ dockerfile = find_file("Dockerfile", project_path) # lowercase f
57
 
58
  missing_files = []
59
+ if not main_py:
60
  missing_files.append("main.py")
61
+ if not requirements_txt:
62
  missing_files.append("requirements.txt")
63
+ if not dockerfile:
64
  missing_files.append("Dockerfile")
65
 
66
  if missing_files:
 
 
 
 
67
  raise HTTPException(
68
  status_code=400,
69
+ detail=f"Zip is missing required file(s): {', '.join(missing_files)} (case-insensitive)"
70
  )
71
 
72
+ # Move Dockerfile to root if needed
73
+ if os.path.dirname(dockerfile) != project_path:
74
+ print(f"[DEBUG] Moving Dockerfile from {dockerfile} to project root")
75
+ target_path = os.path.join(project_path, "Dockerfile")
76
+ os.replace(dockerfile, target_path)
77
+ dockerfile = target_path
78
+
79
+ # Build and run Docker container
80
+ image_name = f"{app_name.lower()}_{project_id[:8]}"
81
+ container_name = image_name
82
+
 
 
 
 
 
 
 
 
 
83
  try:
84
+ docker_client = docker.from_env()
85
+ for c in docker_client.containers.list(all=True):
86
+ if c.status in ["created", "exited"]:
87
+ print(f"Removing leftover container {c.name} ({c.id})")
88
+ c.remove(force=True)
89
+
90
+ image, _ = docker_client.images.build(path=project_path, tag=image_name)
91
+
92
+ container = docker_client.containers.run(
93
+ image=image_name,
94
+ ports={"8080/tcp": None},
95
+ name=container_name,
96
+ detach=True,
97
+ mem_limit="512m",
98
+ nano_cpus=1_000_000_000,
99
+ read_only=True,
100
+ tmpfs={"/tmp": ""},
101
+ user="1001:1001"
102
+ )
103
+
104
+ time.sleep(2)
105
+
106
+ port_info = docker_client.api.port(container.id, 8080)
107
+ if not port_info:
108
+ raise Exception("Port 8080 not exposed by container")
109
+ port = port_info[0]['HostPort']
110
+
111
+ tunnel = ngrok.connect(port, bind_tls=True)
112
+ public_url = tunnel.public_url
113
+
114
  return JSONResponse({
115
  "project_id": project_id,
116
  "container_name": container_name,
117
+ "preview_url": public_url
118
+ })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
 
120
+ except Exception as e:
121
+ return JSONResponse({"error": str(e)}, status_code=500)