File size: 11,988 Bytes
e87efd1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
import gradio as gr
import subprocess
import os
import tempfile
import shutil
from pathlib import Path
import logging
from urllib.parse import urlparse
import re
import time

# Set up logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

def format_status_message(message, status="info"):
    """
    Format a status message with HTML styling.
    
    Args:
        message (str): The message to format
        status (str): One of "info", "success", "error"
        
    Returns:
        str: HTML formatted message
    """
    colors = {
        "info": "#2196F3",    # Blue
        "success": "#4CAF50", # Green
        "error": "#F44336"    # Red
    }
    color = colors.get(status, colors["info"])
    timestamp = time.strftime("%H:%M:%S")
    return f'<div style="color: {color}; margin: 5px 0; font-family: monospace;"><span style="color: #666;">[{timestamp}]</span> {message}</div>'

def extract_github_username(repo_url):
    """
    Extract GitHub username from repository URL.
    
    Args:
        repo_url (str): GitHub repository URL
        
    Returns:
        str: GitHub username
    """
    # Handle both HTTPS and SSH URLs
    if repo_url.startswith('git@'):
        # SSH format: [email protected]:username/repo.git
        return repo_url.split(':')[1].split('/')[0]
    else:
        # HTTPS format: https://github.com/username/repo.git
        path = urlparse(repo_url).path
        return path.strip('/').split('/')[0]

def validate_image_name(image_name):
    """
    Validate that the image name follows Docker tag naming conventions.
    
    Args:
        image_name (str): The image name to validate
        
    Returns:
        tuple: (bool, str) - (is_valid, error_message)
    """
    if not image_name:
        return False, "Image name cannot be empty"
    
    # Docker tag naming rules:
    # - Can contain lowercase letters, digits, and separators (., -, _)
    # - Must start with a letter or digit
    # - Must not end with a separator
    if not re.match(r'^[a-z0-9][a-z0-9._-]*[a-z0-9]$', image_name):
        return False, "Image name must contain only lowercase letters, numbers, and separators (., -, _), and must start and end with a letter or number"
    
    return True, ""

def build_and_push_image(repo_url, registry_token, image_name, username=None, registry="GitHub Container Registry (GHCR)", dockerfile_subdir="."):
    temp_dir = None
    status_messages = []
    original_cwd = os.getcwd()  # Save the original working directory
    try:
        is_valid, error_msg = validate_image_name(image_name)
        if not is_valid:
            status_messages.append(format_status_message(f"Invalid image name: {error_msg}", "error"))
            yield _format_log(status_messages)
            return

        gh_username = username if username else extract_github_username(repo_url)
        if registry == "Docker Hub":
            registry_url = "docker.io"
            full_image_name = f"{gh_username}/{image_name}"
        else:
            registry_url = "ghcr.io"
            full_image_name = f"ghcr.io/{gh_username}/{image_name}"

        steps = [
            (f"Starting build process for {full_image_name}...", "info"),
            (f"Authenticating with {registry_url}...", "info")
        ]
        for msg, st in steps:
            status_messages.append(format_status_message(msg, st))
            logger.info(f"β†’ {msg}")
            yield _format_log(status_messages)

        login_cmd = ["docker", "login", registry_url, "-u", gh_username, "--password-stdin"]
        result = subprocess.run(login_cmd, input=registry_token, capture_output=True, text=True)
        if result.returncode != 0:
            status_messages.append(format_status_message(f"Failed to authenticate with {registry_url}: {result.stderr}", "error"))
            logger.error(f"Failed to authenticate with {registry_url}: {result.stderr}")
            yield _format_log(status_messages)
            return
        status_messages.append(format_status_message(f"Successfully authenticated with {registry_url}", "success"))
        logger.info(f"βœ“ Successfully authenticated with {registry_url}")
        yield _format_log(status_messages)

        temp_dir = tempfile.mkdtemp()
        status_messages.append(format_status_message(f"Created temporary directory: {temp_dir}", "info"))
        logger.info(f"β†’ Created temporary directory: {temp_dir}")
        yield _format_log(status_messages)

        status_messages.append(format_status_message(f"Cloning repository: {repo_url}", "info"))
        logger.info(f"β†’ Cloning repository: {repo_url}")
        yield _format_log(status_messages)
        clone_cmd = ["git", "clone", repo_url, temp_dir]
        result = subprocess.run(clone_cmd, capture_output=True, text=True)
        if result.returncode != 0:
            status_messages.append(format_status_message(f"Failed to clone repository: {result.stderr}", "error"))
            logger.error(f"Failed to clone repository: {result.stderr}")
            yield _format_log(status_messages)
            return
        status_messages.append(format_status_message("Repository cloned successfully", "success"))
        logger.info("βœ“ Repository cloned successfully")
        yield _format_log(status_messages)

        status_messages.append(format_status_message(f"Changing to repository directory: {temp_dir}", "info"))
        logger.info(f"β†’ Changing to repository directory: {temp_dir}")
        os.chdir(temp_dir)
        yield _format_log(status_messages)

        # Check for Dockerfile in the specified subdirectory
        dockerfile_path = os.path.join(temp_dir, dockerfile_subdir, "Dockerfile")
        if not os.path.exists(dockerfile_path):
            status_messages.append(format_status_message(f"No Dockerfile found in the specified subdirectory: {dockerfile_subdir}", "error"))
            logger.error(f"No Dockerfile found in the specified subdirectory: {dockerfile_subdir}")
            yield _format_log(status_messages)
            return
        status_messages.append(format_status_message(f"Dockerfile found in {dockerfile_subdir}", "success"))
        logger.info(f"βœ“ Dockerfile found in {dockerfile_subdir}")
        yield _format_log(status_messages)

        # Build the Docker image using the specified subdirectory as context
        status_messages.append(format_status_message(f"Building Docker image: {full_image_name}", "info"))
        logger.info(f"β†’ Building Docker image: {full_image_name}")
        yield _format_log(status_messages)
        build_cmd = [
            "docker", "build", "-t", full_image_name, "-f", dockerfile_path, os.path.join(temp_dir, dockerfile_subdir)
        ]
        result = subprocess.run(build_cmd, capture_output=True, text=True)
        if result.returncode != 0:
            status_messages.append(format_status_message(f"Failed to build Docker image: {result.stderr}", "error"))
            logger.error(f"Failed to build Docker image: {result.stderr}")
            yield _format_log(status_messages)
            return
        status_messages.append(format_status_message("Docker image built successfully", "success"))
        logger.info("βœ“ Docker image built successfully")
        yield _format_log(status_messages)

        status_messages.append(format_status_message(f"Pushing Docker image to {registry_url}...", "info"))
        logger.info(f"β†’ Pushing Docker image to {registry_url}...")
        yield _format_log(status_messages)
        push_cmd = ["docker", "push", full_image_name]
        result = subprocess.run(push_cmd, capture_output=True, text=True)
        if result.returncode != 0:
            status_messages.append(format_status_message(f"Failed to push Docker image: {result.stderr}", "error"))
            logger.error(f"Failed to push Docker image: {result.stderr}")
            yield _format_log(status_messages)
            return
        status_messages.append(format_status_message(f"Successfully built and pushed image: {full_image_name}", "success"))
        logger.info(f"βœ“ Successfully built and pushed image: {full_image_name}")
        yield _format_log(status_messages)
        status_messages.append(format_status_message("Build process completed successfully!", "success"))
        yield _format_log(status_messages)
    except Exception as e:
        error_msg = f"Error: {str(e)}"
        logger.error(error_msg)
        status_messages.append(format_status_message(error_msg, "error"))
        yield _format_log(status_messages)
    finally:
        try:
            os.chdir(original_cwd)
        except Exception:
            pass
        if temp_dir and os.path.exists(temp_dir):
            shutil.rmtree(temp_dir)
            status_messages.append(format_status_message(f"Cleaned up temporary directory: {temp_dir}", "info"))
            yield _format_log(status_messages)

def _format_log(status_messages):
    return f'<div style="background-color: #1E1E1E; padding: 15px; border-radius: 5px; border: 1px solid #333; font-family: monospace;">{"".join(status_messages)}</div>'

about_md = """
# Your Personal MCP Remote Builder for GitHub and Docker Registries

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.

## The Problem

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.

## Why Gradio + HuggingFace Spaces

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.

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.
"""

with gr.Blocks() as demo:
    with gr.Tab("Builder"):
        gr.Markdown("# Your Personal MCP Remote Builder for Github and Docker Registries")
        with gr.Column():
            repo_url = gr.Textbox(
                label="GitHub Repository URL",
                value="https://github.com/neonwatty/gradio-mcp-test-build-repo"
            )
            registry_token = gr.Textbox(
                label="Registry Token", 
                type="password",
                value=""
            )
            image_name = gr.Textbox(
                label="Image Name",
                value="gradio-mcp-test-build-repo"
            )
            username = gr.Textbox(label="GitHub Username (optional)")
            registry = gr.Dropdown(
                label="Registry",
                choices=["GitHub Container Registry (GHCR)", "Docker Hub"],
                value="GitHub Container Registry (GHCR)"
            )
            dockerfile_subdir = gr.Textbox(
                label="Dockerfile Subdirectory (relative to repo root)",
                value=".",
                info="Path to the subdirectory containing the Dockerfile. Use '.' for the root."
            )
            build_button = gr.Button("Build and Push Image")
            output = gr.HTML(
                label="Build Progress",
                value='<div style="background-color: #1E1E1E; padding: 15px; border-radius: 5px; border: 1px solid #333; font-family: monospace;"></div>'
            )

        build_button.click(
            fn=build_and_push_image,
            inputs=[repo_url, registry_token, image_name, username, registry, dockerfile_subdir],
            outputs=output,
            concurrency_limit=1
        )
    with gr.Tab("About"):
        gr.Markdown(about_md)

demo.queue().launch(mcp_server=True)