jonathanagustin commited on
Commit
6fe9185
·
verified ·
1 Parent(s): ca16345

Upload folder using huggingface_hub

Browse files
Files changed (4) hide show
  1. Dockerfile +28 -0
  2. README.md +48 -4
  3. app.py +716 -0
  4. requirements.txt +5 -0
Dockerfile ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Builder worker using Kaniko for daemonless Docker builds
2
+ FROM gcr.io/kaniko-project/executor:v1.23.2 AS kaniko
3
+
4
+ FROM python:3.11-slim
5
+
6
+ # Copy Kaniko executor
7
+ COPY --from=kaniko /kaniko /kaniko
8
+
9
+ # Install dependencies
10
+ RUN apt-get update && apt-get install -y --no-install-recommends \
11
+ git \
12
+ ca-certificates \
13
+ && rm -rf /var/lib/apt/lists/*
14
+
15
+ WORKDIR /app
16
+
17
+ COPY requirements.txt .
18
+ RUN pip install --no-cache-dir -r requirements.txt
19
+
20
+ COPY app.py .
21
+
22
+ # Kaniko needs this
23
+ ENV PATH="/kaniko:${PATH}"
24
+ ENV DOCKER_CONFIG="/kaniko/.docker"
25
+
26
+ EXPOSE 7860
27
+
28
+ CMD ["python", "app.py"]
README.md CHANGED
@@ -1,10 +1,54 @@
1
  ---
2
  title: Builder
3
- emoji: 💻
4
- colorFrom: purple
5
- colorTo: blue
6
  sdk: docker
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Builder
 
 
 
3
  sdk: docker
4
  pinned: false
5
  ---
6
 
7
+ # Kaniko Image Builder
8
+
9
+ Daemonless Docker image builder using Kaniko. Builds and pushes images to GHCR without requiring Docker daemon access.
10
+
11
+ ## Configuration
12
+
13
+ Set these as HuggingFace Secrets:
14
+
15
+ - `REGISTRY_USER`: GitHub username
16
+ - `REGISTRY_PASSWORD`: GitHub PAT with `packages:write` scope
17
+ - `UPSTASH_REDIS_REST_URL`: (optional) Redis URL for build queue
18
+ - `UPSTASH_REDIS_REST_TOKEN`: (optional) Redis token
19
+
20
+ ## Usage
21
+
22
+ ### Via Web UI
23
+ Navigate to the Space and use the build form.
24
+
25
+ ### Via API
26
+ ```bash
27
+ curl -X POST https://jonathanagustin-builder.hf.space/build \
28
+ -H "Authorization: Bearer $HF_TOKEN" \
29
+ -H "Content-Type: application/json" \
30
+ -d '{
31
+ "repo_url": "https://github.com/jonathanagustin/lawforge",
32
+ "image_name": "jonathanagustin/lawforge-worker",
33
+ "branch": "main",
34
+ "tags": ["latest"],
35
+ "dockerfile": "Dockerfile",
36
+ "context_path": "jobs/worker"
37
+ }'
38
+ ```
39
+
40
+ ### Via Taskfile
41
+ ```bash
42
+ # Build worker image
43
+ task builder:build:worker
44
+
45
+ # Build any image
46
+ REPO_URL=https://github.com/user/repo IMAGE_NAME=user/image task builder:build
47
+ ```
48
+
49
+ ## Endpoints
50
+
51
+ - `GET /` - Web UI
52
+ - `GET /api/status` - Builder status JSON
53
+ - `POST /build` - Trigger a build
54
+ - `POST /api/queue` - Queue a build (requires Redis)
app.py ADDED
@@ -0,0 +1,716 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Docker image builder using Kaniko - no Docker daemon required.
3
+
4
+ Builds Docker images and pushes to container registries (GHCR, Docker Hub, etc.)
5
+ Can be triggered via API or run builds from a queue in Redis.
6
+
7
+ Environment variables:
8
+ - REGISTRY_USER: Registry username (e.g., GitHub username for GHCR)
9
+ - REGISTRY_PASSWORD: Registry password/token (e.g., GitHub PAT with packages:write)
10
+ - REGISTRY_URL: Registry URL (default: ghcr.io)
11
+ - UPSTASH_REDIS_REST_URL: Redis URL for build queue
12
+ - UPSTASH_REDIS_REST_TOKEN: Redis token
13
+ """
14
+
15
+ import json
16
+ import os
17
+ import shutil
18
+ import subprocess
19
+ import tempfile
20
+ import threading
21
+ import time
22
+ import uuid
23
+ from datetime import datetime, timezone
24
+ from pathlib import Path
25
+
26
+ from flask import Flask, jsonify, render_template_string, request
27
+ import git
28
+
29
+ # =============================================================================
30
+ # Configuration
31
+ # =============================================================================
32
+
33
+ RUNNER_ID = os.environ.get("RUNNER_ID", str(uuid.uuid4())[:8])
34
+ RUNNER_NAME = os.environ.get("RUNNER_NAME", f"Builder {RUNNER_ID}")
35
+ REGISTRY_URL = os.environ.get("REGISTRY_URL", "ghcr.io")
36
+ REGISTRY_USER = os.environ.get("REGISTRY_USER", "")
37
+ REGISTRY_PASSWORD = os.environ.get("REGISTRY_PASSWORD", "")
38
+ AUTO_START = os.environ.get("AUTO_START", "false").lower() == "true"
39
+
40
+ # Global state
41
+ state = {
42
+ "status": "idle",
43
+ "current_build": None,
44
+ "builds_completed": 0,
45
+ "builds_failed": 0,
46
+ "last_build": None,
47
+ "logs": [],
48
+ "redis_connected": False,
49
+ }
50
+ state_lock = threading.Lock()
51
+
52
+ app = Flask(__name__)
53
+ redis_client = None
54
+
55
+
56
+ def log(msg: str):
57
+ """Thread-safe logging."""
58
+ with state_lock:
59
+ ts = datetime.now().strftime("%H:%M:%S")
60
+ state["logs"].append(f"[{ts}] {msg}")
61
+ if len(state["logs"]) > 200:
62
+ state["logs"] = state["logs"][-200:]
63
+ print(f"[{ts}] {msg}")
64
+
65
+
66
+ # =============================================================================
67
+ # Redis Integration
68
+ # =============================================================================
69
+
70
+ def init_redis():
71
+ """Initialize Redis for build queue."""
72
+ global redis_client
73
+
74
+ redis_url = os.environ.get("UPSTASH_REDIS_REST_URL", "")
75
+ redis_token = os.environ.get("UPSTASH_REDIS_REST_TOKEN", "")
76
+
77
+ if not redis_url or not redis_token:
78
+ log("Redis not configured - queue mode disabled")
79
+ return None
80
+
81
+ try:
82
+ from upstash_redis import Redis
83
+ redis_client = Redis.from_env()
84
+ redis_client.ping()
85
+ with state_lock:
86
+ state["redis_connected"] = True
87
+ log("✓ Redis connected - queue mode enabled")
88
+ return redis_client
89
+ except Exception as e:
90
+ log(f"Redis connection failed: {e}")
91
+ return None
92
+
93
+
94
+ def get_next_build():
95
+ """Get next build from Redis queue."""
96
+ if not redis_client:
97
+ return None
98
+
99
+ try:
100
+ # Pop from build queue
101
+ build_json = redis_client.lpop("builds:queue")
102
+ if build_json:
103
+ return json.loads(build_json)
104
+ except Exception as e:
105
+ log(f"Queue error: {e}")
106
+ return None
107
+
108
+
109
+ def queue_build(build_config: dict) -> str:
110
+ """Add a build to the queue."""
111
+ build_id = str(uuid.uuid4())[:8]
112
+ build_config["id"] = build_id
113
+ build_config["queued_at"] = datetime.now(timezone.utc).isoformat()
114
+
115
+ if redis_client:
116
+ try:
117
+ redis_client.rpush("builds:queue", json.dumps(build_config))
118
+ log(f"Build {build_id} queued")
119
+ except Exception as e:
120
+ log(f"Failed to queue build: {e}")
121
+ return build_id
122
+
123
+
124
+ # =============================================================================
125
+ # Docker Registry Auth
126
+ # =============================================================================
127
+
128
+ def setup_registry_auth():
129
+ """Configure Kaniko registry authentication."""
130
+ if not REGISTRY_USER or not REGISTRY_PASSWORD:
131
+ log("⚠️ Registry credentials not configured")
132
+ return False
133
+
134
+ # Create Docker config for Kaniko
135
+ docker_config_dir = Path("/kaniko/.docker")
136
+ docker_config_dir.mkdir(parents=True, exist_ok=True)
137
+
138
+ import base64
139
+ auth = base64.b64encode(f"{REGISTRY_USER}:{REGISTRY_PASSWORD}".encode()).decode()
140
+
141
+ config = {
142
+ "auths": {
143
+ REGISTRY_URL: {"auth": auth},
144
+ f"https://{REGISTRY_URL}": {"auth": auth},
145
+ }
146
+ }
147
+
148
+ config_path = docker_config_dir / "config.json"
149
+ with open(config_path, "w") as f:
150
+ json.dump(config, f)
151
+
152
+ log(f"✓ Registry auth configured for {REGISTRY_URL}")
153
+ return True
154
+
155
+
156
+ # =============================================================================
157
+ # Build Logic
158
+ # =============================================================================
159
+
160
+ def clone_repo(repo_url: str, branch: str = "main", target_dir: Path = None) -> Path:
161
+ """Clone a git repository."""
162
+ if target_dir is None:
163
+ target_dir = Path(tempfile.mkdtemp())
164
+
165
+ log(f"Cloning {repo_url} ({branch})...")
166
+
167
+ try:
168
+ git.Repo.clone_from(
169
+ repo_url,
170
+ target_dir,
171
+ branch=branch,
172
+ depth=1,
173
+ single_branch=True
174
+ )
175
+ log(f"✓ Cloned to {target_dir}")
176
+ return target_dir
177
+ except Exception as e:
178
+ log(f"✗ Clone failed: {e}")
179
+ raise
180
+
181
+
182
+ def build_and_push(
183
+ context_dir: str,
184
+ image_name: str,
185
+ dockerfile: str = "Dockerfile",
186
+ tags: list = None,
187
+ build_args: dict = None,
188
+ ) -> bool:
189
+ """Build Docker image using Kaniko and push to registry."""
190
+
191
+ if tags is None:
192
+ tags = ["latest"]
193
+
194
+ # Full image name with registry
195
+ full_image = f"{REGISTRY_URL}/{image_name}"
196
+
197
+ log(f"Building {full_image}...")
198
+ log(f" Context: {context_dir}")
199
+ log(f" Dockerfile: {dockerfile}")
200
+ log(f" Tags: {tags}")
201
+
202
+ with state_lock:
203
+ state["status"] = "building"
204
+ state["current_build"] = {
205
+ "image": full_image,
206
+ "tags": tags,
207
+ "started_at": datetime.now(timezone.utc).isoformat(),
208
+ }
209
+
210
+ # Build Kaniko command
211
+ cmd = [
212
+ "/kaniko/executor",
213
+ f"--context=dir://{context_dir}",
214
+ f"--dockerfile={context_dir}/{dockerfile}",
215
+ ]
216
+
217
+ # Add destination tags
218
+ for tag in tags:
219
+ cmd.append(f"--destination={full_image}:{tag}")
220
+
221
+ # Add build args
222
+ if build_args:
223
+ for key, value in build_args.items():
224
+ cmd.append(f"--build-arg={key}={value}")
225
+
226
+ # Kaniko options
227
+ cmd.extend([
228
+ "--cache=true",
229
+ "--cache-ttl=24h",
230
+ "--compressed-caching=false",
231
+ "--snapshot-mode=redo",
232
+ "--use-new-run",
233
+ ])
234
+
235
+ log(f"Executing: {' '.join(cmd[:5])}...")
236
+
237
+ try:
238
+ process = subprocess.Popen(
239
+ cmd,
240
+ stdout=subprocess.PIPE,
241
+ stderr=subprocess.STDOUT,
242
+ text=True,
243
+ )
244
+
245
+ # Stream output
246
+ for line in process.stdout:
247
+ line = line.strip()
248
+ if line:
249
+ log(f" {line[:100]}")
250
+
251
+ process.wait()
252
+
253
+ if process.returncode == 0:
254
+ log(f"✓ Build successful: {full_image}")
255
+ with state_lock:
256
+ state["builds_completed"] += 1
257
+ state["last_build"] = {
258
+ "image": full_image,
259
+ "tags": tags,
260
+ "status": "success",
261
+ "completed_at": datetime.now(timezone.utc).isoformat(),
262
+ }
263
+ return True
264
+ else:
265
+ log(f"✗ Build failed with exit code {process.returncode}")
266
+ with state_lock:
267
+ state["builds_failed"] += 1
268
+ state["last_build"] = {
269
+ "image": full_image,
270
+ "tags": tags,
271
+ "status": "failed",
272
+ "exit_code": process.returncode,
273
+ "completed_at": datetime.now(timezone.utc).isoformat(),
274
+ }
275
+ return False
276
+
277
+ except Exception as e:
278
+ log(f"✗ Build error: {e}")
279
+ with state_lock:
280
+ state["builds_failed"] += 1
281
+ return False
282
+ finally:
283
+ with state_lock:
284
+ state["status"] = "idle"
285
+ state["current_build"] = None
286
+
287
+
288
+ def run_build(build_config: dict) -> bool:
289
+ """Run a build from config."""
290
+ repo_url = build_config.get("repo_url")
291
+ branch = build_config.get("branch", "main")
292
+ image_name = build_config.get("image_name")
293
+ dockerfile = build_config.get("dockerfile", "Dockerfile")
294
+ context_path = build_config.get("context_path", ".")
295
+ tags = build_config.get("tags", ["latest"])
296
+ build_args = build_config.get("build_args", {})
297
+
298
+ if not repo_url or not image_name:
299
+ log("✗ Missing repo_url or image_name")
300
+ return False
301
+
302
+ tmpdir = None
303
+ try:
304
+ # Clone repo
305
+ tmpdir = clone_repo(repo_url, branch)
306
+
307
+ # Set context directory
308
+ context_dir = str(tmpdir / context_path) if context_path != "." else str(tmpdir)
309
+
310
+ # Build and push
311
+ return build_and_push(
312
+ context_dir=context_dir,
313
+ image_name=image_name,
314
+ dockerfile=dockerfile,
315
+ tags=tags,
316
+ build_args=build_args,
317
+ )
318
+
319
+ finally:
320
+ if tmpdir and tmpdir.exists():
321
+ shutil.rmtree(tmpdir, ignore_errors=True)
322
+
323
+
324
+ # =============================================================================
325
+ # Queue Worker
326
+ # =============================================================================
327
+
328
+ def queue_worker():
329
+ """Process builds from the queue."""
330
+ log("Queue worker started")
331
+
332
+ while True:
333
+ if state["status"] == "idle":
334
+ build = get_next_build()
335
+ if build:
336
+ log(f"Processing queued build: {build.get('id')}")
337
+ run_build(build)
338
+
339
+ time.sleep(5)
340
+
341
+
342
+ # =============================================================================
343
+ # Web UI
344
+ # =============================================================================
345
+
346
+ HTML_TEMPLATE = """
347
+ <!DOCTYPE html>
348
+ <html lang="en">
349
+ <head>
350
+ <meta charset="UTF-8">
351
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
352
+ <title>Builder</title>
353
+ <script src="https://unpkg.com/htmx.org@1.9.10"></script>
354
+ <style>
355
+ :root {
356
+ --bg: #09090b;
357
+ --surface: #18181b;
358
+ --border: #27272a;
359
+ --text: #fafafa;
360
+ --text-muted: #71717a;
361
+ --accent: #f59e0b;
362
+ --green: #4ade80;
363
+ --red: #f87171;
364
+ }
365
+ * { box-sizing: border-box; margin: 0; padding: 0; }
366
+ body {
367
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
368
+ background: var(--bg);
369
+ color: var(--text);
370
+ min-height: 100vh;
371
+ display: flex;
372
+ flex-direction: column;
373
+ }
374
+ .main { flex: 1; padding: 2rem; max-width: 900px; margin: 0 auto; width: 100%; }
375
+ .header { margin-bottom: 2rem; }
376
+ .header h1 { font-size: 1.5rem; font-weight: 600; letter-spacing: -0.025em; }
377
+ .header-meta { display: flex; flex-wrap: wrap; gap: 1rem; margin-top: 0.5rem; font-size: 0.8125rem; color: var(--text-muted); }
378
+
379
+ .status-card {
380
+ background: var(--surface);
381
+ border: 1px solid var(--border);
382
+ border-radius: 0.75rem;
383
+ padding: 1.5rem;
384
+ margin-bottom: 1rem;
385
+ }
386
+ .status-header {
387
+ display: flex;
388
+ justify-content: space-between;
389
+ align-items: center;
390
+ margin-bottom: 1.5rem;
391
+ }
392
+ .status-info { display: flex; align-items: center; gap: 0.75rem; }
393
+ .dot {
394
+ width: 10px;
395
+ height: 10px;
396
+ border-radius: 50%;
397
+ flex-shrink: 0;
398
+ }
399
+ .dot.idle { background: var(--text-muted); }
400
+ .dot.building { background: var(--accent); animation: pulse 1.5s infinite; }
401
+ @keyframes pulse {
402
+ 0%, 100% { opacity: 1; transform: scale(1); }
403
+ 50% { opacity: 0.5; transform: scale(0.95); }
404
+ }
405
+ .status-text { font-size: 0.875rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.025em; }
406
+
407
+ .stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
408
+ .stat { text-align: center; padding: 1rem; background: var(--bg); border-radius: 0.5rem; }
409
+ .stat-value { font-size: 1.75rem; font-weight: 600; font-variant-numeric: tabular-nums; }
410
+ .stat-value.success { color: var(--green); }
411
+ .stat-value.failed { color: var(--red); }
412
+ .stat-label { font-size: 0.6875rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-top: 0.25rem; }
413
+
414
+ .form-card {
415
+ background: var(--surface);
416
+ border: 1px solid var(--border);
417
+ border-radius: 0.75rem;
418
+ padding: 1.5rem;
419
+ margin-bottom: 1rem;
420
+ }
421
+ .form-title { font-size: 0.875rem; font-weight: 500; margin-bottom: 1.25rem; }
422
+ .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
423
+ .form-group { margin-bottom: 1rem; }
424
+ .form-group.full { grid-column: span 2; }
425
+ .form-group label {
426
+ display: block;
427
+ margin-bottom: 0.375rem;
428
+ color: var(--text-muted);
429
+ font-size: 0.75rem;
430
+ text-transform: uppercase;
431
+ letter-spacing: 0.025em;
432
+ }
433
+ .form-group input {
434
+ width: 100%;
435
+ padding: 0.625rem 0.75rem;
436
+ background: var(--bg);
437
+ border: 1px solid var(--border);
438
+ border-radius: 0.5rem;
439
+ color: var(--text);
440
+ font-size: 0.875rem;
441
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
442
+ transition: border-color 0.15s;
443
+ }
444
+ .form-group input:focus {
445
+ outline: none;
446
+ border-color: var(--accent);
447
+ }
448
+ .form-group input::placeholder { color: var(--text-muted); opacity: 0.5; }
449
+
450
+ .btn {
451
+ background: var(--accent);
452
+ color: var(--bg);
453
+ border: none;
454
+ padding: 0.75rem 1.5rem;
455
+ border-radius: 0.5rem;
456
+ font-size: 0.875rem;
457
+ font-weight: 500;
458
+ cursor: pointer;
459
+ transition: opacity 0.15s, transform 0.15s;
460
+ width: 100%;
461
+ }
462
+ .btn:hover { opacity: 0.9; }
463
+ .btn:active { transform: scale(0.98); }
464
+ .btn:disabled { opacity: 0.4; cursor: not-allowed; }
465
+
466
+ .logs-panel {
467
+ background: var(--surface);
468
+ border: 1px solid var(--border);
469
+ border-radius: 0.75rem;
470
+ overflow: hidden;
471
+ }
472
+ .logs-header {
473
+ display: flex;
474
+ justify-content: space-between;
475
+ align-items: center;
476
+ padding: 0.75rem 1rem;
477
+ border-bottom: 1px solid var(--border);
478
+ background: rgba(0,0,0,0.2);
479
+ }
480
+ .logs-title { font-size: 0.8125rem; font-weight: 500; color: var(--text-muted); }
481
+ .logs {
482
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
483
+ font-size: 0.75rem;
484
+ line-height: 1.5;
485
+ padding: 1rem;
486
+ max-height: 300px;
487
+ overflow-y: auto;
488
+ background: var(--bg);
489
+ }
490
+ .log-line { color: var(--text-muted); white-space: pre-wrap; word-break: break-all; }
491
+ .log-line:hover { color: var(--text); }
492
+
493
+ @media (max-width: 640px) {
494
+ .form-grid { grid-template-columns: 1fr; }
495
+ .form-group.full { grid-column: span 1; }
496
+ .stats { grid-template-columns: 1fr; }
497
+ }
498
+ </style>
499
+ </head>
500
+ <body>
501
+ <div class="main">
502
+ <div class="header">
503
+ <h1>Builder</h1>
504
+ <div class="header-meta">
505
+ <span>{{ runner_id }}</span>
506
+ <span>{{ registry_url }}</span>
507
+ {% if redis_connected %}<span style="color: var(--green);">Redis</span>{% endif %}
508
+ </div>
509
+ </div>
510
+
511
+ <div class="status-card" id="status-panel" hx-get="/status" hx-trigger="every 2s" hx-swap="innerHTML">
512
+ <div class="status-header">
513
+ <div class="status-info">
514
+ <div class="dot {{ status }}"></div>
515
+ <div class="status-text">{{ status }}</div>
516
+ </div>
517
+ </div>
518
+ <div class="stats">
519
+ <div class="stat">
520
+ <div class="stat-value success">{{ builds_completed }}</div>
521
+ <div class="stat-label">Completed</div>
522
+ </div>
523
+ <div class="stat">
524
+ <div class="stat-value failed">{{ builds_failed }}</div>
525
+ <div class="stat-label">Failed</div>
526
+ </div>
527
+ <div class="stat">
528
+ <div class="stat-value">{{ 'Yes' if current_build else '-' }}</div>
529
+ <div class="stat-label">Active</div>
530
+ </div>
531
+ </div>
532
+ </div>
533
+
534
+ <div class="form-card">
535
+ <div class="form-title">Trigger Build</div>
536
+ <form hx-post="/build" hx-swap="none">
537
+ <div class="form-grid">
538
+ <div class="form-group full">
539
+ <label>Repository URL</label>
540
+ <input type="text" name="repo_url" placeholder="https://github.com/user/repo" required>
541
+ </div>
542
+ <div class="form-group">
543
+ <label>Branch</label>
544
+ <input type="text" name="branch" value="main">
545
+ </div>
546
+ <div class="form-group">
547
+ <label>Image Name</label>
548
+ <input type="text" name="image_name" placeholder="username/repo" required>
549
+ </div>
550
+ <div class="form-group">
551
+ <label>Tags</label>
552
+ <input type="text" name="tags" value="latest" placeholder="latest, v1.0">
553
+ </div>
554
+ <div class="form-group">
555
+ <label>Dockerfile</label>
556
+ <input type="text" name="dockerfile" value="Dockerfile">
557
+ </div>
558
+ <div class="form-group full">
559
+ <button type="submit" class="btn" {% if status == 'building' %}disabled{% endif %}>
560
+ {% if status == 'building' %}Building...{% else %}Build & Push{% endif %}
561
+ </button>
562
+ </div>
563
+ </div>
564
+ </form>
565
+ </div>
566
+
567
+ <div class="logs-panel">
568
+ <div class="logs-header">
569
+ <span class="logs-title">Logs</span>
570
+ </div>
571
+ <div id="logs" class="logs" hx-get="/logs" hx-trigger="every 2s" hx-swap="innerHTML">
572
+ {% for line in logs %}<div class="log-line">{{ line }}</div>{% endfor %}
573
+ </div>
574
+ </div>
575
+ </div>
576
+ </body>
577
+ </html>
578
+ """
579
+
580
+
581
+ @app.route("/")
582
+ def index():
583
+ with state_lock:
584
+ return render_template_string(
585
+ HTML_TEMPLATE,
586
+ runner_name=RUNNER_NAME,
587
+ runner_id=RUNNER_ID,
588
+ registry_url=REGISTRY_URL,
589
+ redis_connected=state["redis_connected"],
590
+ status=state["status"],
591
+ builds_completed=state["builds_completed"],
592
+ builds_failed=state["builds_failed"],
593
+ current_build=state["current_build"],
594
+ logs=state["logs"][-50:],
595
+ )
596
+
597
+
598
+ @app.route("/status")
599
+ def status():
600
+ with state_lock:
601
+ return render_template_string("""
602
+ <div class="status-header">
603
+ <div class="status-info">
604
+ <div class="dot {{ status }}"></div>
605
+ <div class="status-text">{{ status }}</div>
606
+ </div>
607
+ </div>
608
+ <div class="stats">
609
+ <div class="stat">
610
+ <div class="stat-value success">{{ builds_completed }}</div>
611
+ <div class="stat-label">Completed</div>
612
+ </div>
613
+ <div class="stat">
614
+ <div class="stat-value failed">{{ builds_failed }}</div>
615
+ <div class="stat-label">Failed</div>
616
+ </div>
617
+ <div class="stat">
618
+ <div class="stat-value">{{ 'Yes' if current_build else '-' }}</div>
619
+ <div class="stat-label">Active</div>
620
+ </div>
621
+ </div>
622
+ """,
623
+ status=state["status"],
624
+ builds_completed=state["builds_completed"],
625
+ builds_failed=state["builds_failed"],
626
+ current_build=state["current_build"],
627
+ )
628
+
629
+
630
+ @app.route("/logs")
631
+ def logs():
632
+ with state_lock:
633
+ return "".join(f'<div class="log-line">{line}</div>' for line in state["logs"][-50:])
634
+
635
+
636
+ @app.route("/build", methods=["POST"])
637
+ def trigger_build():
638
+ """Trigger a build via API or form."""
639
+ if state["status"] == "building":
640
+ return jsonify({"error": "Build already in progress"}), 409
641
+
642
+ # Get build config from form or JSON
643
+ if request.is_json:
644
+ config = request.json
645
+ else:
646
+ config = {
647
+ "repo_url": request.form.get("repo_url"),
648
+ "branch": request.form.get("branch", "main"),
649
+ "image_name": request.form.get("image_name"),
650
+ "dockerfile": request.form.get("dockerfile", "Dockerfile"),
651
+ "tags": [t.strip() for t in request.form.get("tags", "latest").split(",")],
652
+ }
653
+
654
+ if not config.get("repo_url") or not config.get("image_name"):
655
+ return jsonify({"error": "repo_url and image_name required"}), 400
656
+
657
+ # Run build in background
658
+ threading.Thread(target=run_build, args=(config,), daemon=True).start()
659
+
660
+ return jsonify({"status": "started", "config": config}), 202
661
+
662
+
663
+ @app.route("/api/status")
664
+ def api_status():
665
+ with state_lock:
666
+ return jsonify({
667
+ "runner_id": RUNNER_ID,
668
+ "runner_name": RUNNER_NAME,
669
+ "status": state["status"],
670
+ "current_build": state["current_build"],
671
+ "builds_completed": state["builds_completed"],
672
+ "builds_failed": state["builds_failed"],
673
+ "last_build": state["last_build"],
674
+ "redis_connected": state["redis_connected"],
675
+ })
676
+
677
+
678
+ @app.route("/api/queue", methods=["POST"])
679
+ def api_queue():
680
+ """Queue a build for later processing."""
681
+ if not request.is_json:
682
+ return jsonify({"error": "JSON required"}), 400
683
+
684
+ config = request.json
685
+ build_id = queue_build(config)
686
+ return jsonify({"status": "queued", "build_id": build_id}), 202
687
+
688
+
689
+ # =============================================================================
690
+ # Startup
691
+ # =============================================================================
692
+
693
+ def startup():
694
+ log(f"Builder started: {RUNNER_NAME} ({RUNNER_ID})")
695
+ log(f"Registry: {REGISTRY_URL}")
696
+
697
+ # Initialize Redis
698
+ init_redis()
699
+
700
+ # Setup registry auth
701
+ if REGISTRY_USER:
702
+ setup_registry_auth()
703
+ else:
704
+ log("⚠️ REGISTRY_USER not set - pushes will fail")
705
+
706
+ # Start queue worker if Redis is configured
707
+ if redis_client:
708
+ threading.Thread(target=queue_worker, daemon=True).start()
709
+
710
+ log("Ready for builds!")
711
+
712
+
713
+ threading.Thread(target=startup, daemon=True).start()
714
+
715
+ if __name__ == "__main__":
716
+ app.run(host="0.0.0.0", port=7860)
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ flask>=3.0.0
2
+ huggingface_hub>=0.24.0
3
+ httpx>=0.27.0
4
+ gitpython>=3.1.0
5
+ upstash-redis>=1.0.0