Spaces:
Sleeping
Sleeping
Upload 2 files
Browse files- app.py +260 -0
- requirements.txt +3 -0
app.py
ADDED
@@ -0,0 +1,260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import gradio as gr
|
2 |
+
import subprocess
|
3 |
+
import os
|
4 |
+
import tempfile
|
5 |
+
import shutil
|
6 |
+
from pathlib import Path
|
7 |
+
import logging
|
8 |
+
from urllib.parse import urlparse
|
9 |
+
import re
|
10 |
+
import time
|
11 |
+
|
12 |
+
# Set up logging
|
13 |
+
logging.basicConfig(level=logging.INFO)
|
14 |
+
logger = logging.getLogger(__name__)
|
15 |
+
|
16 |
+
def format_status_message(message, status="info"):
|
17 |
+
"""
|
18 |
+
Format a status message with HTML styling.
|
19 |
+
|
20 |
+
Args:
|
21 |
+
message (str): The message to format
|
22 |
+
status (str): One of "info", "success", "error"
|
23 |
+
|
24 |
+
Returns:
|
25 |
+
str: HTML formatted message
|
26 |
+
"""
|
27 |
+
colors = {
|
28 |
+
"info": "#2196F3", # Blue
|
29 |
+
"success": "#4CAF50", # Green
|
30 |
+
"error": "#F44336" # Red
|
31 |
+
}
|
32 |
+
color = colors.get(status, colors["info"])
|
33 |
+
timestamp = time.strftime("%H:%M:%S")
|
34 |
+
return f'<div style="color: {color}; margin: 5px 0; font-family: monospace;"><span style="color: #666;">[{timestamp}]</span> {message}</div>'
|
35 |
+
|
36 |
+
def extract_github_username(repo_url):
|
37 |
+
"""
|
38 |
+
Extract GitHub username from repository URL.
|
39 |
+
|
40 |
+
Args:
|
41 |
+
repo_url (str): GitHub repository URL
|
42 |
+
|
43 |
+
Returns:
|
44 |
+
str: GitHub username
|
45 |
+
"""
|
46 |
+
# Handle both HTTPS and SSH URLs
|
47 |
+
if repo_url.startswith('git@'):
|
48 |
+
# SSH format: [email protected]:username/repo.git
|
49 |
+
return repo_url.split(':')[1].split('/')[0]
|
50 |
+
else:
|
51 |
+
# HTTPS format: https://github.com/username/repo.git
|
52 |
+
path = urlparse(repo_url).path
|
53 |
+
return path.strip('/').split('/')[0]
|
54 |
+
|
55 |
+
def validate_image_name(image_name):
|
56 |
+
"""
|
57 |
+
Validate that the image name follows Docker tag naming conventions.
|
58 |
+
|
59 |
+
Args:
|
60 |
+
image_name (str): The image name to validate
|
61 |
+
|
62 |
+
Returns:
|
63 |
+
tuple: (bool, str) - (is_valid, error_message)
|
64 |
+
"""
|
65 |
+
if not image_name:
|
66 |
+
return False, "Image name cannot be empty"
|
67 |
+
|
68 |
+
# Docker tag naming rules:
|
69 |
+
# - Can contain lowercase letters, digits, and separators (., -, _)
|
70 |
+
# - Must start with a letter or digit
|
71 |
+
# - Must not end with a separator
|
72 |
+
if not re.match(r'^[a-z0-9][a-z0-9._-]*[a-z0-9]$', image_name):
|
73 |
+
return False, "Image name must contain only lowercase letters, numbers, and separators (., -, _), and must start and end with a letter or number"
|
74 |
+
|
75 |
+
return True, ""
|
76 |
+
|
77 |
+
def build_and_push_image(repo_url, registry_token, image_name, username=None, registry="GitHub Container Registry (GHCR)", dockerfile_subdir="."):
|
78 |
+
temp_dir = None
|
79 |
+
status_messages = []
|
80 |
+
original_cwd = os.getcwd() # Save the original working directory
|
81 |
+
try:
|
82 |
+
is_valid, error_msg = validate_image_name(image_name)
|
83 |
+
if not is_valid:
|
84 |
+
status_messages.append(format_status_message(f"Invalid image name: {error_msg}", "error"))
|
85 |
+
yield _format_log(status_messages)
|
86 |
+
return
|
87 |
+
|
88 |
+
gh_username = username if username else extract_github_username(repo_url)
|
89 |
+
if registry == "Docker Hub":
|
90 |
+
registry_url = "docker.io"
|
91 |
+
full_image_name = f"{gh_username}/{image_name}"
|
92 |
+
else:
|
93 |
+
registry_url = "ghcr.io"
|
94 |
+
full_image_name = f"ghcr.io/{gh_username}/{image_name}"
|
95 |
+
|
96 |
+
steps = [
|
97 |
+
(f"Starting build process for {full_image_name}...", "info"),
|
98 |
+
(f"Authenticating with {registry_url}...", "info")
|
99 |
+
]
|
100 |
+
for msg, st in steps:
|
101 |
+
status_messages.append(format_status_message(msg, st))
|
102 |
+
logger.info(f"β {msg}")
|
103 |
+
yield _format_log(status_messages)
|
104 |
+
|
105 |
+
login_cmd = ["docker", "login", registry_url, "-u", gh_username, "--password-stdin"]
|
106 |
+
result = subprocess.run(login_cmd, input=registry_token, capture_output=True, text=True)
|
107 |
+
if result.returncode != 0:
|
108 |
+
status_messages.append(format_status_message(f"Failed to authenticate with {registry_url}: {result.stderr}", "error"))
|
109 |
+
logger.error(f"Failed to authenticate with {registry_url}: {result.stderr}")
|
110 |
+
yield _format_log(status_messages)
|
111 |
+
return
|
112 |
+
status_messages.append(format_status_message(f"Successfully authenticated with {registry_url}", "success"))
|
113 |
+
logger.info(f"β Successfully authenticated with {registry_url}")
|
114 |
+
yield _format_log(status_messages)
|
115 |
+
|
116 |
+
temp_dir = tempfile.mkdtemp()
|
117 |
+
status_messages.append(format_status_message(f"Created temporary directory: {temp_dir}", "info"))
|
118 |
+
logger.info(f"β Created temporary directory: {temp_dir}")
|
119 |
+
yield _format_log(status_messages)
|
120 |
+
|
121 |
+
status_messages.append(format_status_message(f"Cloning repository: {repo_url}", "info"))
|
122 |
+
logger.info(f"β Cloning repository: {repo_url}")
|
123 |
+
yield _format_log(status_messages)
|
124 |
+
clone_cmd = ["git", "clone", repo_url, temp_dir]
|
125 |
+
result = subprocess.run(clone_cmd, capture_output=True, text=True)
|
126 |
+
if result.returncode != 0:
|
127 |
+
status_messages.append(format_status_message(f"Failed to clone repository: {result.stderr}", "error"))
|
128 |
+
logger.error(f"Failed to clone repository: {result.stderr}")
|
129 |
+
yield _format_log(status_messages)
|
130 |
+
return
|
131 |
+
status_messages.append(format_status_message("Repository cloned successfully", "success"))
|
132 |
+
logger.info("β Repository cloned successfully")
|
133 |
+
yield _format_log(status_messages)
|
134 |
+
|
135 |
+
status_messages.append(format_status_message(f"Changing to repository directory: {temp_dir}", "info"))
|
136 |
+
logger.info(f"β Changing to repository directory: {temp_dir}")
|
137 |
+
os.chdir(temp_dir)
|
138 |
+
yield _format_log(status_messages)
|
139 |
+
|
140 |
+
# Check for Dockerfile in the specified subdirectory
|
141 |
+
dockerfile_path = os.path.join(temp_dir, dockerfile_subdir, "Dockerfile")
|
142 |
+
if not os.path.exists(dockerfile_path):
|
143 |
+
status_messages.append(format_status_message(f"No Dockerfile found in the specified subdirectory: {dockerfile_subdir}", "error"))
|
144 |
+
logger.error(f"No Dockerfile found in the specified subdirectory: {dockerfile_subdir}")
|
145 |
+
yield _format_log(status_messages)
|
146 |
+
return
|
147 |
+
status_messages.append(format_status_message(f"Dockerfile found in {dockerfile_subdir}", "success"))
|
148 |
+
logger.info(f"β Dockerfile found in {dockerfile_subdir}")
|
149 |
+
yield _format_log(status_messages)
|
150 |
+
|
151 |
+
# Build the Docker image using the specified subdirectory as context
|
152 |
+
status_messages.append(format_status_message(f"Building Docker image: {full_image_name}", "info"))
|
153 |
+
logger.info(f"β Building Docker image: {full_image_name}")
|
154 |
+
yield _format_log(status_messages)
|
155 |
+
build_cmd = [
|
156 |
+
"docker", "build", "-t", full_image_name, "-f", dockerfile_path, os.path.join(temp_dir, dockerfile_subdir)
|
157 |
+
]
|
158 |
+
result = subprocess.run(build_cmd, capture_output=True, text=True)
|
159 |
+
if result.returncode != 0:
|
160 |
+
status_messages.append(format_status_message(f"Failed to build Docker image: {result.stderr}", "error"))
|
161 |
+
logger.error(f"Failed to build Docker image: {result.stderr}")
|
162 |
+
yield _format_log(status_messages)
|
163 |
+
return
|
164 |
+
status_messages.append(format_status_message("Docker image built successfully", "success"))
|
165 |
+
logger.info("β Docker image built successfully")
|
166 |
+
yield _format_log(status_messages)
|
167 |
+
|
168 |
+
status_messages.append(format_status_message(f"Pushing Docker image to {registry_url}...", "info"))
|
169 |
+
logger.info(f"β Pushing Docker image to {registry_url}...")
|
170 |
+
yield _format_log(status_messages)
|
171 |
+
push_cmd = ["docker", "push", full_image_name]
|
172 |
+
result = subprocess.run(push_cmd, capture_output=True, text=True)
|
173 |
+
if result.returncode != 0:
|
174 |
+
status_messages.append(format_status_message(f"Failed to push Docker image: {result.stderr}", "error"))
|
175 |
+
logger.error(f"Failed to push Docker image: {result.stderr}")
|
176 |
+
yield _format_log(status_messages)
|
177 |
+
return
|
178 |
+
status_messages.append(format_status_message(f"Successfully built and pushed image: {full_image_name}", "success"))
|
179 |
+
logger.info(f"β Successfully built and pushed image: {full_image_name}")
|
180 |
+
yield _format_log(status_messages)
|
181 |
+
status_messages.append(format_status_message("Build process completed successfully!", "success"))
|
182 |
+
yield _format_log(status_messages)
|
183 |
+
except Exception as e:
|
184 |
+
error_msg = f"Error: {str(e)}"
|
185 |
+
logger.error(error_msg)
|
186 |
+
status_messages.append(format_status_message(error_msg, "error"))
|
187 |
+
yield _format_log(status_messages)
|
188 |
+
finally:
|
189 |
+
try:
|
190 |
+
os.chdir(original_cwd)
|
191 |
+
except Exception:
|
192 |
+
pass
|
193 |
+
if temp_dir and os.path.exists(temp_dir):
|
194 |
+
shutil.rmtree(temp_dir)
|
195 |
+
status_messages.append(format_status_message(f"Cleaned up temporary directory: {temp_dir}", "info"))
|
196 |
+
yield _format_log(status_messages)
|
197 |
+
|
198 |
+
def _format_log(status_messages):
|
199 |
+
return f'<div style="background-color: #1E1E1E; padding: 15px; border-radius: 5px; border: 1px solid #333; font-family: monospace;">{"".join(status_messages)}</div>'
|
200 |
+
|
201 |
+
about_md = """
|
202 |
+
# Your Personal MCP Remote Builder for GitHub and Docker Registries
|
203 |
+
|
204 |
+
This project is a Gradio-based web and MCP server that automates building Docker images from remote GitHub repositories and publishes them to either the GitHub Container Registry (GHCR) or Docker Hub.
|
205 |
+
|
206 |
+
## The Problem
|
207 |
+
|
208 |
+
Using GitHub for CI/CD for personal projects can be frustrating, as Github's default runners are often too small and fail at even moderately complex Docker image builds due to memory constraints. And at present larger runners are only available for enterprise customers.
|
209 |
+
|
210 |
+
## Why Gradio + HuggingFace Spaces
|
211 |
+
|
212 |
+
Gradio gives your MCP setup even more freedom β you can kick off builds from anywhere: straight from your IDE or a local LLM using the MCP protocol, programmatically via your codebase or GitHub workflow, or by using the Gradio web UI.
|
213 |
+
|
214 |
+
HuggingFace Spaces is perfect for this use case β clone the app there and you can easily pick whatever size or type of machine you want to run it on, and when youβre not using it, it automatically scales right down to zero.
|
215 |
+
"""
|
216 |
+
|
217 |
+
with gr.Blocks() as demo:
|
218 |
+
with gr.Tab("Builder"):
|
219 |
+
gr.Markdown("# Your Personal MCP Remote Builder for Github and Docker Registries")
|
220 |
+
with gr.Column():
|
221 |
+
repo_url = gr.Textbox(
|
222 |
+
label="GitHub Repository URL",
|
223 |
+
value="https://github.com/neonwatty/gradio-mcp-test-build-repo"
|
224 |
+
)
|
225 |
+
registry_token = gr.Textbox(
|
226 |
+
label="Registry Token",
|
227 |
+
type="password",
|
228 |
+
value=""
|
229 |
+
)
|
230 |
+
image_name = gr.Textbox(
|
231 |
+
label="Image Name",
|
232 |
+
value="gradio-mcp-test-build-repo"
|
233 |
+
)
|
234 |
+
username = gr.Textbox(label="GitHub Username (optional)")
|
235 |
+
registry = gr.Dropdown(
|
236 |
+
label="Registry",
|
237 |
+
choices=["GitHub Container Registry (GHCR)", "Docker Hub"],
|
238 |
+
value="GitHub Container Registry (GHCR)"
|
239 |
+
)
|
240 |
+
dockerfile_subdir = gr.Textbox(
|
241 |
+
label="Dockerfile Subdirectory (relative to repo root)",
|
242 |
+
value=".",
|
243 |
+
info="Path to the subdirectory containing the Dockerfile. Use '.' for the root."
|
244 |
+
)
|
245 |
+
build_button = gr.Button("Build and Push Image")
|
246 |
+
output = gr.HTML(
|
247 |
+
label="Build Progress",
|
248 |
+
value='<div style="background-color: #1E1E1E; padding: 15px; border-radius: 5px; border: 1px solid #333; font-family: monospace;"></div>'
|
249 |
+
)
|
250 |
+
|
251 |
+
build_button.click(
|
252 |
+
fn=build_and_push_image,
|
253 |
+
inputs=[repo_url, registry_token, image_name, username, registry, dockerfile_subdir],
|
254 |
+
outputs=output,
|
255 |
+
concurrency_limit=1
|
256 |
+
)
|
257 |
+
with gr.Tab("About"):
|
258 |
+
gr.Markdown(about_md)
|
259 |
+
|
260 |
+
demo.queue().launch(mcp_server=True)
|
requirements.txt
ADDED
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
1 |
+
gradio[mcp]
|
2 |
+
docker
|
3 |
+
gitpython
|