Claude commited on
Commit
8029b17
·
1 Parent(s): 9e22678

feat(builder): add HTMX dashboard, hivemind integration, and robust enum parsing

Browse files
Files changed (4) hide show
  1. README.md +217 -73
  2. Taskfile.yml +210 -0
  3. app.py +1400 -862
  4. requirements.txt +6 -3
README.md CHANGED
@@ -1,108 +1,252 @@
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
- | Secret | Required | Description |
16
- |--------|----------|-------------|
17
- | `REGISTRY_USER` | Yes | GitHub username |
18
- | `REGISTRY_PASSWORD` | Yes | GitHub PAT with `packages:write` scope |
19
- | `GITHUB_TOKEN` | For private repos | GitHub PAT for cloning private repositories |
20
- | `GITHUB_WEBHOOK_SECRET` | Recommended | Secret for validating GitHub webhook payloads |
21
- | `DEFAULT_IMAGE_NAME` | For webhooks | Default image name (e.g., `jonathanagustin/lawforge`) |
22
- | `UPSTASH_REDIS_REST_URL` | Optional | Redis URL for build queue |
23
- | `UPSTASH_REDIS_REST_TOKEN` | Optional | Redis token |
 
24
 
25
- ## Usage
26
 
27
- ### Via GitHub Webhook (Automatic Builds)
 
 
 
 
 
28
 
29
- Set up automatic builds when you push to your repository:
30
 
31
- 1. Go to your GitHub repo → **Settings** → **Webhooks** → **Add webhook**
32
- 2. **Payload URL**: `https://jonathanagustin-builder.hf.space/webhook/github`
33
- 3. **Content type**: `application/json`
34
- 4. **Secret**: Same value as `GITHUB_WEBHOOK_SECRET` (generate with `openssl rand -hex 32`)
35
- 5. **Events**: Select "Just the push event"
36
 
37
- The builder will automatically:
38
- - Trigger on pushes to the default branch (main/master)
39
- - Only build when relevant files change (Dockerfile, src/**, pyproject.toml, etc.)
40
- - Skip builds for non-Docker-related changes (docs, tests, etc.)
41
 
42
- #### File Patterns That Trigger Builds
 
 
 
43
 
44
- ```
45
- Dockerfile, docker/*, src/**/*.py, pyproject.toml, uv.lock, requirements*.txt, .dockerignore
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  ```
47
 
48
- #### Optional Webhook Headers
49
 
50
- For per-repo configuration, add custom headers to your webhook:
51
 
52
- | Header | Description |
53
- |--------|-------------|
54
- | `X-Builder-Token` | GitHub token for cloning private repos |
55
- | `X-Builder-Image` | Override image name (default: repo name) |
56
- | `X-Builder-Tags` | Comma-separated tags (default: "latest") |
57
 
58
- ### Via Web UI
59
 
60
- Navigate to the Space and use the build form.
61
 
62
- ### Via API
63
 
64
- ```bash
65
- curl -X POST https://jonathanagustin-builder.hf.space/build \
66
- -H "Content-Type: application/json" \
67
- -d '{
68
- "repo_url": "https://github.com/owner/repo",
69
- "image_name": "owner/repo",
70
- "branch": "main",
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  "tags": ["latest"],
72
- "dockerfile": "Dockerfile"
73
- }'
 
 
 
 
74
  ```
75
 
76
- ### Via Test Endpoint
77
 
78
- Trigger a build without webhook signature validation:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
 
80
- ```bash
81
- curl -X POST https://jonathanagustin-builder.hf.space/webhook/test \
82
- -H "Content-Type: application/json" \
83
- -d '{
84
- "repo_url": "https://github.com/owner/repo",
85
- "image_name": "owner/repo"
86
- }'
 
 
 
 
 
 
87
  ```
88
 
89
- ## Endpoints
90
 
91
  | Endpoint | Method | Description |
92
  |----------|--------|-------------|
93
- | `/` | GET | Web UI |
94
- | `/api/status` | GET | Builder status JSON |
95
- | `/build` | POST | Trigger a build manually |
96
- | `/webhook/github` | POST | GitHub webhook endpoint |
97
- | `/webhook/test` | POST | Test build trigger (no signature) |
98
- | `/api/queue` | POST | Queue a build (requires Redis) |
99
-
100
- ## How It Works
101
-
102
- 1. **Webhook received**: GitHub sends push event to `/webhook/github`
103
- 2. **Signature verified**: HMAC-SHA256 validation using `GITHUB_WEBHOOK_SECRET`
104
- 3. **Files checked**: Only builds if Docker-relevant files changed
105
- 4. **Repo cloned**: Uses `GITHUB_TOKEN` (or per-repo `X-Builder-Token`) for private repos
106
- 5. **Image built**: Kaniko builds without Docker daemon
107
- 6. **Image pushed**: Pushes to GHCR using `REGISTRY_USER`/`REGISTRY_PASSWORD`
108
- 7. **Cleanup**: Temporary files removed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: HF Builder
3
  sdk: docker
4
  pinned: false
5
  ---
6
 
7
+ # HF Builder
8
 
9
+ Daemonless Docker image builder for HuggingFace Spaces using [Kaniko](https://github.com/GoogleContainerTools/kaniko).
10
+
11
+ ## Features
12
+
13
+ - **Build Tracking**: Unique build IDs, history, and metrics
14
+ - **GitHub Webhooks**: Automatic builds on push with signature verification
15
+ - **Notifications**: Slack, Discord, and custom webhook notifications
16
+ - **Observability**: OpenTelemetry tracing, Prometheus metrics, structured logging
17
+ - **Status Badge**: SVG badge for READMEs
18
+ - **Request Tracing**: Trace IDs through logs and responses
19
+ - **HTMX Dashboard**: Real-time web interface with live updates
20
+ - **Hivemind Integration**: Connect to hivemind controller for distributed builds
21
+ - **Task Runner**: go-task automation for common operations
22
+
23
+ ## Quick Start
24
+
25
+ ```bash
26
+ # Install go-task
27
+ brew install go-task
28
+
29
+ # Run locally
30
+ task dev
31
+
32
+ # Check status
33
+ task status
34
+
35
+ # Trigger a build
36
+ task build REPO_URL=https://github.com/owner/repo IMAGE=owner/repo
37
+ ```
38
 
39
  ## Configuration
40
 
41
+ ### Core Settings
42
 
43
+ | Secret | Required | Default | Description |
44
+ |--------|----------|---------|-------------|
45
+ | `REGISTRY_URL` | No | `ghcr.io` | Container registry URL |
46
+ | `REGISTRY_USER` | Yes | - | Registry username |
47
+ | `REGISTRY_PASSWORD` | Yes | - | Registry password/token |
48
+ | `GITHUB_TOKEN` | For private | - | Token for cloning |
49
+ | `WEBHOOK_SECRET` | Recommended | - | GitHub webhook secret |
50
+ | `DEFAULT_IMAGE` | No | - | Default image name |
51
+ | `BUILD_TIMEOUT` | No | `1800` | Timeout in seconds |
52
+ | `ENABLE_CACHE` | No | `false` | Enable Kaniko cache |
53
 
54
+ ### Notifications
55
 
56
+ | Secret | Description |
57
+ |--------|-------------|
58
+ | `NOTIFICATION_URL` | Generic webhook URL for build results |
59
+ | `SLACK_WEBHOOK_URL` | Slack incoming webhook URL |
60
+ | `DISCORD_WEBHOOK_URL` | Discord webhook URL |
61
+ | `NOTIFY_ON` | When to notify: `all`, `failure`, `success` (default: `failure`) |
62
 
63
+ ### Observability
64
 
65
+ | Secret | Description |
66
+ |--------|-------------|
67
+ | `LOG_FORMAT` | `text` or `json` (default: `text`) |
68
+ | `OTEL_EXPORTER_OTLP_ENDPOINT` | OpenTelemetry collector endpoint |
69
+ | `OTEL_SERVICE_NAME` | Service name for traces (default: `hf-builder`) |
70
 
71
+ ### Hivemind Integration
 
 
 
72
 
73
+ | Secret | Description |
74
+ |--------|-------------|
75
+ | `HIVEMIND_CONTROLLER_URL` | URL of the hivemind controller (enables worker mode) |
76
+ | `HIVEMIND_POLL_INTERVAL` | Seconds between work polls (default: `30`) |
77
 
78
+ When `HIVEMIND_CONTROLLER_URL` is set, the builder:
79
+ 1. Registers itself with the controller as a "build" worker
80
+ 2. Polls for build work items
81
+ 3. Executes builds and reports completion/failure
82
+ 4. Sends heartbeats to indicate status
83
+
84
+ ## Web Dashboard
85
+
86
+ The builder includes a real-time HTMX-powered dashboard at the root URL showing:
87
+
88
+ - **Stats**: Status, completed/failed builds, success rate
89
+ - **Current Build**: Active build with cancel button
90
+ - **Build History**: Recent builds with status badges
91
+ - **New Build Form**: Trigger builds from the UI
92
+ - **Live Logs**: Real-time log streaming
93
+
94
+ All panels auto-refresh via HTMX polling.
95
+
96
+ ## Status Badge
97
+
98
+ Add to your README:
99
+
100
+ ```markdown
101
+ ![Build Status](https://your-space.hf.space/badge)
102
  ```
103
 
104
+ Returns an SVG badge showing: `passing`, `failing`, `building`, or `no builds`.
105
 
106
+ ## Request Tracing
107
 
108
+ Every request gets a trace ID:
109
+ - Pass `X-Request-ID` or `X-Trace-ID` header, or one is auto-generated
110
+ - Response includes `X-Trace-ID` header
111
+ - Logs include trace ID: `[HH:MM:SS] [trace123] message`
112
+ - JSON logs include `trace_id` field
113
 
114
+ ## Notifications
115
 
116
+ ### Slack
117
 
118
+ Set `SLACK_WEBHOOK_URL` to receive formatted messages:
119
 
120
+ ```
121
+ Build SUCCESS
122
+ owner/repo:latest
123
+ ID: abc123 | Duration: 180.5s | Branch: main
124
+ ```
125
+
126
+ ### Discord
127
+
128
+ Set `DISCORD_WEBHOOK_URL` to receive embedded messages with build details.
129
+
130
+ ### Custom Webhook
131
+
132
+ Set `NOTIFICATION_URL` or pass `callback_url` in API request. Receives JSON:
133
+
134
+ ```json
135
+ {
136
+ "build": {
137
+ "id": "abc123",
138
+ "status": "success",
139
+ "image": "ghcr.io/owner/repo",
140
  "tags": ["latest"],
141
+ "duration_seconds": 180.5,
142
+ "trace_id": "xyz789"
143
+ },
144
+ "runner_id": "runner1",
145
+ "registry": "ghcr.io"
146
+ }
147
  ```
148
 
149
+ ## OpenTelemetry
150
 
151
+ Set `OTEL_EXPORTER_OTLP_ENDPOINT` to enable distributed tracing.
152
+
153
+ Traces include:
154
+ - Span per build with `build.id`, `build.image`, `build.status`
155
+ - Errors recorded as span events
156
+
157
+ Works with:
158
+ - Jaeger
159
+ - Grafana Tempo
160
+ - Honeycomb
161
+ - Any OTLP-compatible backend
162
+
163
+ ## Prometheus Metrics
164
+
165
+ `GET /api/metrics` returns:
166
 
167
+ ```
168
+ # HELP hf_builder_builds_total Total builds
169
+ # TYPE hf_builder_builds_total counter
170
+ hf_builder_builds_total{status="success"} 42
171
+ hf_builder_builds_total{status="failed"} 3
172
+
173
+ # HELP hf_builder_build_duration_seconds Avg build duration
174
+ # TYPE hf_builder_build_duration_seconds gauge
175
+ hf_builder_build_duration_seconds 180.50
176
+
177
+ # HELP hf_builder_success_rate Success rate
178
+ # TYPE hf_builder_success_rate gauge
179
+ hf_builder_success_rate 0.9333
180
  ```
181
 
182
+ ## API Endpoints
183
 
184
  | Endpoint | Method | Description |
185
  |----------|--------|-------------|
186
+ | `/` | GET | Web dashboard (HTMX) |
187
+ | `/health` | GET | Health check |
188
+ | `/ready` | GET | Readiness check |
189
+ | `/badge` | GET | SVG status badge |
190
+ | `/api/status` | GET | Builder status |
191
+ | `/api/metrics` | GET | Prometheus metrics |
192
+ | `/api/history` | GET | Build history |
193
+ | `/api/logs` | GET | Recent logs |
194
+ | `/api/build` | POST | Trigger build |
195
+ | `/api/build/{id}/cancel` | POST | Cancel build |
196
+ | `/webhook/github` | POST | GitHub webhook |
197
+ | `/webhook/test` | POST | Test endpoint |
198
+ | `/stats-partial` | GET | Stats HTML partial |
199
+ | `/current-partial` | GET | Current build HTML partial |
200
+ | `/history-partial` | GET | History HTML partial |
201
+ | `/logs-partial` | GET | Logs HTML partial |
202
+
203
+ ## Webhook Headers
204
+
205
+ | Header | Description |
206
+ |--------|-------------|
207
+ | `X-Builder-Image` | Override image name |
208
+ | `X-Builder-Tags` | Comma-separated tags |
209
+ | `X-Builder-Token` | GitHub token |
210
+ | `X-Builder-Platform` | Target platform |
211
+ | `X-Builder-Args` | Build args: `K1=V1,K2=V2` |
212
+ | `X-Builder-Callback` | Notification URL |
213
+
214
+ ## Task Runner
215
+
216
+ The project includes a `Taskfile.yml` for [go-task](https://taskfile.dev/):
217
+
218
+ ```bash
219
+ # Development
220
+ task dev # Run locally
221
+ task dev:watch # Run with auto-reload
222
+
223
+ # Testing
224
+ task lint # Run linting
225
+ task lint:fix # Fix linting issues
226
+
227
+ # Docker
228
+ task docker:build # Build Docker image
229
+ task docker:run # Run Docker image
230
+
231
+ # API Operations
232
+ task health # Check health
233
+ task status # Get status
234
+ task metrics # Get metrics
235
+ task history # Get build history
236
+ task logs # Get recent logs
237
+
238
+ # Build Operations
239
+ task build REPO_URL=... IMAGE=... # Trigger a build
240
+ task build:cancel BUILD_ID=... # Cancel a build
241
+
242
+ # Hivemind
243
+ task hivemind:register # Register with controller
244
+ task hivemind:heartbeat # Send heartbeat
245
+
246
+ # Deployment
247
+ task deploy:hf # Deploy to HuggingFace
248
+ ```
249
+
250
+ ## License
251
+
252
+ MIT
Taskfile.yml ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3'
2
+
3
+ vars:
4
+ REGISTRY: '{{.REGISTRY | default "ghcr.io"}}'
5
+ IMAGE_NAME: '{{.IMAGE_NAME | default "hf-builder"}}'
6
+
7
+ tasks:
8
+ default:
9
+ desc: Show available tasks
10
+ cmds:
11
+ - task --list
12
+
13
+ # Development
14
+ dev:
15
+ desc: Run builder locally for development
16
+ env:
17
+ FLASK_DEBUG: "1"
18
+ LOG_FORMAT: text
19
+ cmds:
20
+ - python app.py
21
+
22
+ dev:watch:
23
+ desc: Run with auto-reload on file changes
24
+ deps: [check:deps]
25
+ cmds:
26
+ - watchmedo auto-restart --patterns="*.py" --recursive -- python app.py
27
+
28
+ # Testing
29
+ test:
30
+ desc: Run all tests
31
+ cmds:
32
+ - pytest tests/ -v
33
+
34
+ test:unit:
35
+ desc: Run unit tests only
36
+ cmds:
37
+ - pytest tests/unit/ -v
38
+
39
+ test:integration:
40
+ desc: Run integration tests (requires Docker)
41
+ cmds:
42
+ - pytest tests/integration/ -v
43
+
44
+ lint:
45
+ desc: Run linting
46
+ cmds:
47
+ - ruff check .
48
+ - ruff format --check .
49
+
50
+ lint:fix:
51
+ desc: Fix linting issues
52
+ cmds:
53
+ - ruff check --fix .
54
+ - ruff format .
55
+
56
+ # Docker
57
+ docker:build:
58
+ desc: Build Docker image locally
59
+ cmds:
60
+ - docker build -t {{.IMAGE_NAME}}:local .
61
+
62
+ docker:run:
63
+ desc: Run Docker image locally
64
+ deps: [docker:build]
65
+ env:
66
+ REGISTRY_URL: '{{.REGISTRY}}'
67
+ cmds:
68
+ - |
69
+ docker run --rm -it \
70
+ -p 7860:7860 \
71
+ -e REGISTRY_URL=$REGISTRY_URL \
72
+ -e REGISTRY_USER=$REGISTRY_USER \
73
+ -e REGISTRY_PASSWORD=$REGISTRY_PASSWORD \
74
+ {{.IMAGE_NAME}}:local
75
+
76
+ # Deployment
77
+ deploy:hf:
78
+ desc: Deploy to HuggingFace Space
79
+ vars:
80
+ SPACE: '{{.SPACE | default "drengskapur/hf-builder"}}'
81
+ cmds:
82
+ - echo "Deploying to {{.SPACE}}..."
83
+ - git push hf main
84
+
85
+ # Hivemind Integration
86
+ hivemind:register:
87
+ desc: Register this builder with hivemind controller
88
+ vars:
89
+ CONTROLLER_URL: '{{.CONTROLLER_URL | default "http://localhost:7860"}}'
90
+ BUILDER_ID: '{{.BUILDER_ID | default "builder-1"}}'
91
+ cmds:
92
+ - |
93
+ curl -X POST {{.CONTROLLER_URL}}/api/register \
94
+ -H "Content-Type: application/json" \
95
+ -d '{"worker_id": "{{.BUILDER_ID}}", "name": "Builder {{.BUILDER_ID}}", "capabilities": ["build"]}'
96
+
97
+ hivemind:heartbeat:
98
+ desc: Send heartbeat to hivemind controller
99
+ vars:
100
+ CONTROLLER_URL: '{{.CONTROLLER_URL | default "http://localhost:7860"}}'
101
+ BUILDER_ID: '{{.BUILDER_ID | default "builder-1"}}'
102
+ cmds:
103
+ - |
104
+ curl -X POST {{.CONTROLLER_URL}}/api/heartbeat \
105
+ -H "Content-Type: application/json" \
106
+ -d '{"worker_id": "{{.BUILDER_ID}}", "status": "idle"}'
107
+
108
+ # Health checks
109
+ health:
110
+ desc: Check builder health
111
+ vars:
112
+ URL: '{{.URL | default "http://localhost:7860"}}'
113
+ cmds:
114
+ - curl -s {{.URL}}/health | jq .
115
+
116
+ status:
117
+ desc: Get builder status
118
+ vars:
119
+ URL: '{{.URL | default "http://localhost:7860"}}'
120
+ cmds:
121
+ - curl -s {{.URL}}/api/status | jq .
122
+
123
+ metrics:
124
+ desc: Get Prometheus metrics
125
+ vars:
126
+ URL: '{{.URL | default "http://localhost:7860"}}'
127
+ cmds:
128
+ - curl -s {{.URL}}/api/metrics
129
+
130
+ history:
131
+ desc: Get build history
132
+ vars:
133
+ URL: '{{.URL | default "http://localhost:7860"}}'
134
+ cmds:
135
+ - curl -s {{.URL}}/api/history | jq .
136
+
137
+ logs:
138
+ desc: Get recent logs
139
+ vars:
140
+ URL: '{{.URL | default "http://localhost:7860"}}'
141
+ cmds:
142
+ - curl -s {{.URL}}/api/logs | jq -r '.logs[]'
143
+
144
+ # Build triggers
145
+ build:
146
+ desc: Trigger a build via API
147
+ vars:
148
+ URL: '{{.URL | default "http://localhost:7860"}}'
149
+ requires:
150
+ vars: [REPO_URL, IMAGE]
151
+ cmds:
152
+ - |
153
+ curl -X POST {{.URL}}/api/build \
154
+ -H "Content-Type: application/json" \
155
+ -d '{
156
+ "repo_url": "{{.REPO_URL}}",
157
+ "image_name": "{{.IMAGE}}",
158
+ "branch": "{{.BRANCH | default "main"}}",
159
+ "tags": ["{{.TAG | default "latest"}}"]
160
+ }'
161
+
162
+ build:cancel:
163
+ desc: Cancel a running build
164
+ vars:
165
+ URL: '{{.URL | default "http://localhost:7860"}}'
166
+ requires:
167
+ vars: [BUILD_ID]
168
+ cmds:
169
+ - curl -X POST {{.URL}}/api/build/{{.BUILD_ID}}/cancel
170
+
171
+ # Webhook testing
172
+ webhook:test:
173
+ desc: Send a test webhook
174
+ vars:
175
+ URL: '{{.URL | default "http://localhost:7860"}}'
176
+ requires:
177
+ vars: [REPO_URL, IMAGE]
178
+ cmds:
179
+ - |
180
+ curl -X POST {{.URL}}/webhook/test \
181
+ -H "Content-Type: application/json" \
182
+ -d '{
183
+ "repo_url": "{{.REPO_URL}}",
184
+ "image_name": "{{.IMAGE}}",
185
+ "branch": "{{.BRANCH | default "main"}}"
186
+ }'
187
+
188
+ # Dependency management
189
+ deps:
190
+ desc: Install dependencies
191
+ cmds:
192
+ - pip install -r requirements.txt
193
+
194
+ deps:dev:
195
+ desc: Install dev dependencies
196
+ cmds:
197
+ - pip install -r requirements.txt pytest ruff watchdog
198
+
199
+ check:deps:
200
+ desc: Check if dependencies are installed
201
+ cmds:
202
+ - python -c "import flask; import git; import requests"
203
+ silent: true
204
+
205
+ # Cleanup
206
+ clean:
207
+ desc: Clean up temporary files
208
+ cmds:
209
+ - rm -rf __pycache__ .pytest_cache .ruff_cache
210
+ - find . -name "*.pyc" -delete
app.py CHANGED
@@ -1,560 +1,1132 @@
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, GitHub webhooks, 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
- - GITHUB_TOKEN: GitHub token for cloning private repos
12
- - GITHUB_WEBHOOK_SECRET: Secret for validating GitHub webhook payloads
13
- - UPSTASH_REDIS_REST_URL: Redis URL for build queue
14
- - UPSTASH_REDIS_REST_TOKEN: Redis token
15
-
16
- Webhook setup:
17
- 1. Go to your GitHub repo → Settings → Webhooks → Add webhook
18
- 2. Payload URL: https://your-space.hf.space/webhook/github
19
- 3. Content type: application/json
20
- 4. Secret: Same value as GITHUB_WEBHOOK_SECRET
21
- 5. Events: Just the push event
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  """
23
 
 
 
 
 
24
  import fnmatch
25
  import hashlib
26
  import hmac
27
  import json
28
  import os
 
29
  import shutil
30
  import subprocess
31
  import tempfile
32
  import threading
33
  import time
 
34
  import uuid
 
 
35
  from datetime import datetime, timezone
 
36
  from pathlib import Path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
- from flask import Flask, jsonify, render_template_string, request, abort
39
- import git
40
 
41
  # =============================================================================
42
  # Configuration
43
  # =============================================================================
44
 
45
- RUNNER_ID = os.environ.get("RUNNER_ID", str(uuid.uuid4())[:8])
46
- RUNNER_NAME = os.environ.get("RUNNER_NAME", f"Builder {RUNNER_ID}")
47
- REGISTRY_URL = os.environ.get("REGISTRY_URL", "ghcr.io")
48
- REGISTRY_USER = os.environ.get("REGISTRY_USER", "")
49
- REGISTRY_PASSWORD = os.environ.get("REGISTRY_PASSWORD", "")
50
- GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "") # For cloning private repos
51
- GITHUB_WEBHOOK_SECRET = os.environ.get("GITHUB_WEBHOOK_SECRET", "") # For webhook validation
52
- AUTO_START = os.environ.get("AUTO_START", "false").lower() == "true"
53
-
54
- # Default image name for webhook-triggered builds (owner/repo format)
55
- DEFAULT_IMAGE_NAME = os.environ.get("DEFAULT_IMAGE_NAME", "")
56
-
57
- # File patterns that should trigger a Docker rebuild when changed
58
- # Uses glob-style patterns
59
- BUILD_TRIGGER_PATTERNS = [
60
- "Dockerfile",
61
- "docker/*",
62
- "docker/**/*",
63
- "src/**/*.py",
64
- "pyproject.toml",
65
- "uv.lock",
66
- "requirements*.txt",
67
- ".dockerignore",
68
- ]
69
-
70
- # Global state
71
- state = {
72
- "status": "idle",
73
- "current_build": None,
74
- "builds_completed": 0,
75
- "builds_failed": 0,
76
- "last_build": None,
77
- "logs": [],
78
- "redis_connected": False,
79
- }
80
- state_lock = threading.Lock()
81
 
82
- app = Flask(__name__)
83
- redis_client = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
 
85
 
86
- def log(msg: str):
87
- """Thread-safe logging."""
88
- with state_lock:
89
- ts = datetime.now().strftime("%H:%M:%S")
90
- state["logs"].append(f"[{ts}] {msg}")
91
- if len(state["logs"]) > 200:
92
- state["logs"] = state["logs"][-200:]
93
- print(f"[{ts}] {msg}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
 
95
 
96
  # =============================================================================
97
- # Redis Integration
98
  # =============================================================================
99
 
100
- def init_redis():
101
- """Initialize Redis for build queue."""
102
- global redis_client
103
 
104
- redis_url = os.environ.get("UPSTASH_REDIS_REST_URL", "")
105
- redis_token = os.environ.get("UPSTASH_REDIS_REST_TOKEN", "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
 
107
- if not redis_url or not redis_token:
108
- log("Redis not configured - queue mode disabled")
109
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
 
111
- try:
112
- from upstash_redis import Redis
113
- redis_client = Redis.from_env()
114
- redis_client.ping()
115
- with state_lock:
116
- state["redis_connected"] = True
117
- log("✓ Redis connected - queue mode enabled")
118
- return redis_client
119
- except Exception as e:
120
- log(f"Redis connection failed: {e}")
121
- return None
122
 
 
 
 
123
 
124
- def get_next_build():
125
- """Get next build from Redis queue."""
126
- if not redis_client:
127
- return None
128
 
129
- try:
130
- # Pop from build queue
131
- build_json = redis_client.lpop("builds:queue")
132
- if build_json:
133
- return json.loads(build_json)
134
- except Exception as e:
135
- log(f"Queue error: {e}")
136
- return None
137
 
 
 
 
138
 
139
- def queue_build(build_config: dict) -> str:
140
- """Add a build to the queue."""
141
- build_id = str(uuid.uuid4())[:8]
142
- build_config["id"] = build_id
143
- build_config["queued_at"] = datetime.now(timezone.utc).isoformat()
 
 
 
 
 
 
 
 
 
 
 
 
144
 
145
- if redis_client:
 
 
 
 
 
 
 
 
 
 
 
146
  try:
147
- redis_client.rpush("builds:queue", json.dumps(build_config))
148
- log(f"Build {build_id} queued")
 
 
 
 
 
149
  except Exception as e:
150
- log(f"Failed to queue build: {e}")
151
- return build_id
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
 
153
 
154
  # =============================================================================
155
- # Docker Registry Auth
156
  # =============================================================================
157
 
158
- def setup_registry_auth():
159
- """Configure Kaniko registry authentication."""
160
- if not REGISTRY_USER or not REGISTRY_PASSWORD:
161
- log("⚠️ Registry credentials not configured")
162
- return False
163
 
164
- # Create Docker config for Kaniko
165
- docker_config_dir = Path("/kaniko/.docker")
166
- docker_config_dir.mkdir(parents=True, exist_ok=True)
 
 
 
 
 
 
 
 
 
167
 
168
- import base64
169
- auth = base64.b64encode(f"{REGISTRY_USER}:{REGISTRY_PASSWORD}".encode()).decode()
170
 
171
- config = {
172
- "auths": {
173
- REGISTRY_URL: {"auth": auth},
174
- f"https://{REGISTRY_URL}": {"auth": auth},
175
- }
176
- }
 
 
177
 
178
- config_path = docker_config_dir / "config.json"
179
- with open(config_path, "w") as f:
180
- json.dump(config, f)
 
 
 
 
 
181
 
182
- log(f"✓ Registry auth configured for {REGISTRY_URL}")
183
- return True
 
 
 
184
 
185
 
186
  # =============================================================================
187
- # Build Logic
188
  # =============================================================================
189
 
190
- def clone_repo(repo_url: str, branch: str = "main", target_dir: Path = None, github_token: str = None) -> Path:
191
- """Clone a git repository, with optional GitHub token for private repos."""
192
- if target_dir is None:
193
- target_dir = Path(tempfile.mkdtemp())
194
 
195
- # Use GitHub token if provided or from env, for private repos
196
- token = github_token or GITHUB_TOKEN
197
- auth_url = repo_url
 
 
198
 
199
- if token and "github.com" in repo_url:
200
- # Insert token into URL for auth: https://TOKEN@github.com/...
201
- auth_url = repo_url.replace("https://github.com", f"https://{token}@github.com")
202
- log(f"Cloning {repo_url} ({branch}) [authenticated]...")
203
- else:
204
- log(f"Cloning {repo_url} ({branch})...")
205
 
206
- try:
207
- git.Repo.clone_from(
208
- auth_url,
209
- target_dir,
210
- branch=branch,
211
- depth=1,
212
- single_branch=True
213
- )
214
- log(f"✓ Cloned to {target_dir}")
215
- return target_dir
216
- except Exception as e:
217
- log(f"✗ Clone failed: {e}")
218
- raise
219
 
 
 
 
220
 
221
- def build_and_push(
222
- context_dir: str,
223
- image_name: str,
224
- dockerfile: str = "Dockerfile",
225
- tags: list = None,
226
- build_args: dict = None,
227
- ) -> bool:
228
- """Build Docker image using Kaniko and push to registry.
229
 
230
- Uses tar context to avoid Kaniko's 'failed to get files used from context'
231
- bug with multi-stage Dockerfiles that use FROM <previous_stage>.
232
- """
233
 
234
- if tags is None:
235
- tags = ["latest"]
 
 
236
 
237
- full_image = f"{REGISTRY_URL}/{image_name}"
 
 
 
 
238
 
239
- log(f"Building {full_image}...")
240
- log(f" Context: {context_dir}")
241
- log(f" Dockerfile: {dockerfile}")
242
- log(f" Tags: {tags}")
 
 
 
 
 
243
 
244
- with state_lock:
245
- state["status"] = "building"
246
- state["current_build"] = {
247
- "image": full_image,
248
- "tags": tags,
249
- "started_at": datetime.now(timezone.utc).isoformat(),
250
- }
251
 
252
- # Create tar of context - this avoids Kaniko's dir:// context resolution bug
253
- # that causes "failed to get files used from context" on multi-stage builds
254
- tar_path = f"{context_dir}.tar.gz"
255
- log(f"Creating tar context: {tar_path}")
256
- try:
257
- tar_result = subprocess.run(
258
- ["tar", "-czf", tar_path, "-C", context_dir, "."],
259
- capture_output=True,
260
- text=True,
261
- timeout=120,
262
- )
263
- if tar_result.returncode != 0:
264
- log(f"✗ Failed to create tar: {tar_result.stderr}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  return False
266
- log(f"✓ Tar created")
267
- except Exception as e:
268
- log(f"✗ Tar error: {e}")
269
- return False
270
 
271
- # Build Kaniko command with tar context
272
- cmd = [
273
- "/kaniko/executor",
274
- f"--context=tar://{tar_path}",
275
- f"--dockerfile={dockerfile}",
276
- ]
277
 
278
- # Add destination tags
279
- for tag in tags:
280
- cmd.append(f"--destination={full_image}:{tag}")
281
-
282
- # Add build args
283
- if build_args:
284
- for key, value in build_args.items():
285
- cmd.append(f"--build-arg={key}={value}")
286
-
287
- # Kaniko options
288
- cmd.extend([
289
- "--cache=false",
290
- "--reproducible",
291
- "--ignore-path=/product_uuid",
292
- "--ignore-path=/sys",
293
- "--log-format=text",
294
- "--verbosity=debug", # Get full error messages
295
- ])
296
 
297
- log(f"Executing: {' '.join(cmd[:5])}...")
 
 
298
 
299
- try:
300
- process = subprocess.Popen(
301
- cmd,
302
- stdout=subprocess.PIPE,
303
- stderr=subprocess.STDOUT,
304
- text=True,
305
- )
306
 
307
- # Stream output
308
- for line in process.stdout:
309
- line = line.strip()
310
- if line:
311
- log(f" {line[:100]}")
312
-
313
- process.wait()
314
-
315
- if process.returncode == 0:
316
- log(f"✓ Build successful: {full_image}")
317
- with state_lock:
318
- state["builds_completed"] += 1
319
- state["last_build"] = {
320
- "image": full_image,
321
- "tags": tags,
322
- "status": "success",
323
- "completed_at": datetime.now(timezone.utc).isoformat(),
324
- }
325
  return True
326
- else:
327
- log(f"✗ Build failed with exit code {process.returncode}")
328
- with state_lock:
329
- state["builds_failed"] += 1
330
- state["last_build"] = {
331
- "image": full_image,
332
- "tags": tags,
333
- "status": "failed",
334
- "exit_code": process.returncode,
335
- "completed_at": datetime.now(timezone.utc).isoformat(),
336
- }
337
  return False
338
-
339
- except Exception as e:
340
- log(f"✗ Build error: {e}")
341
- with state_lock:
342
- state["builds_failed"] += 1
343
- return False
344
- finally:
345
- with state_lock:
346
- state["status"] = "idle"
347
- state["current_build"] = None
348
-
349
-
350
- def build_and_push_git(
351
- git_context: str,
352
- image_name: str,
353
- dockerfile: str = "Dockerfile",
354
- context_subpath: str = None,
355
- tags: list = None,
356
- build_args: dict = None,
357
- ) -> bool:
358
- """Build Docker image using Kaniko with git context source."""
359
-
360
- if tags is None:
361
- tags = ["latest"]
362
-
363
- full_image = f"{REGISTRY_URL}/{image_name}"
364
-
365
- # Mask token in logs
366
- safe_context = git_context
367
- if "@github.com" in git_context:
368
- safe_context = git_context.split("@")[0][:10] + "...@github.com" + git_context.split("@github.com")[1]
369
-
370
- log(f"Building {full_image}...")
371
- log(f" Git context: {safe_context}")
372
- log(f" Dockerfile: {dockerfile}")
373
- log(f" Tags: {tags}")
374
-
375
- with state_lock:
376
- state["status"] = "building"
377
- state["current_build"] = {
378
- "image": full_image,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
379
  "tags": tags,
380
- "started_at": datetime.now(timezone.utc).isoformat(),
 
 
 
 
381
  }
382
 
383
- # Build Kaniko command with git context
384
- cmd = [
385
- "/kaniko/executor",
386
- f"--context={git_context}",
387
- f"--dockerfile={dockerfile}",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
  ]
 
389
 
390
- if context_subpath:
391
- cmd.append(f"--context-sub-path={context_subpath}")
392
 
393
- # Add destination tags
394
- for tag in tags:
395
- cmd.append(f"--destination={full_image}:{tag}")
396
-
397
- # Add build args
398
- if build_args:
399
- for key, value in build_args.items():
400
- cmd.append(f"--build-arg={key}={value}")
401
-
402
- # Kaniko options
403
- cmd.extend([
404
- "--cache=false",
405
- "--reproducible",
406
- "--ignore-path=/product_uuid",
407
- "--ignore-path=/sys",
408
- "--log-format=text",
409
- "--verbosity=debug",
410
- ])
411
 
412
- log(f"Executing kaniko with git context...")
413
 
414
- try:
415
- process = subprocess.Popen(
416
- cmd,
417
- stdout=subprocess.PIPE,
418
- stderr=subprocess.STDOUT,
419
- text=True,
420
- )
421
 
422
- for line in process.stdout:
423
- line = line.strip()
424
- if line:
425
- # Mask any tokens in output
426
- if GITHUB_TOKEN and GITHUB_TOKEN in line:
427
- line = line.replace(GITHUB_TOKEN, "***")
428
- log(f" {line[:100]}")
429
-
430
- process.wait()
431
-
432
- if process.returncode == 0:
433
- log(f"✓ Build successful: {full_image}")
434
- with state_lock:
435
- state["builds_completed"] += 1
436
- state["last_build"] = {
437
- "image": full_image,
438
- "tags": tags,
439
- "status": "success",
440
- "completed_at": datetime.now(timezone.utc).isoformat(),
441
- }
442
- return True
443
- else:
444
- log(f"✗ Build failed with exit code {process.returncode}")
445
- with state_lock:
446
- state["builds_failed"] += 1
447
- state["last_build"] = {
448
- "image": full_image,
449
- "tags": tags,
450
- "status": "failed",
451
- "exit_code": process.returncode,
452
- "completed_at": datetime.now(timezone.utc).isoformat(),
453
- }
454
- return False
455
 
456
- except Exception as e:
457
- log(f"✗ Build error: {e}")
458
- with state_lock:
459
- state["builds_failed"] += 1
460
- return False
461
- finally:
462
- with state_lock:
463
- state["status"] = "idle"
464
- state["current_build"] = None
465
-
466
-
467
- def run_build(build_config: dict) -> bool:
468
- """Run a build from config."""
469
- repo_url = build_config.get("repo_url")
470
- branch = build_config.get("branch", "main")
471
- image_name = build_config.get("image_name")
472
- dockerfile = build_config.get("dockerfile", "Dockerfile")
473
- context_path = build_config.get("context_path", ".")
474
- tags = build_config.get("tags", ["latest"])
475
- build_args = build_config.get("build_args", {})
476
- github_token = build_config.get("github_token") # Optional per-build token
477
- # Default to tar context (clone + tar) for reliability with multi-stage builds
478
- use_git_context = build_config.get("use_git_context", False)
479
-
480
- if not repo_url or not image_name:
481
- log("✗ Missing repo_url or image_name")
482
- return False
483
 
484
- # Option: Use git context source (Kaniko handles clone internally)
485
- # Note: git context can have issues with multi-stage builds, prefer tar context
486
- if use_git_context and "github.com" in repo_url:
487
- token = github_token or GITHUB_TOKEN
488
- if token:
489
- git_context = repo_url.replace("https://github.com", f"git://{token}@github.com")
490
- else:
491
- git_context = repo_url.replace("https://github.com", "git://github.com")
492
- git_context = f"{git_context}#refs/heads/{branch}"
493
-
494
- if context_path and context_path != ".":
495
- return build_and_push_git(
496
- git_context=git_context,
497
- image_name=image_name,
498
- dockerfile=dockerfile,
499
- context_subpath=context_path,
500
- tags=tags,
501
- build_args=build_args,
502
- )
503
- else:
504
- return build_and_push_git(
505
- git_context=git_context,
506
- image_name=image_name,
507
- dockerfile=dockerfile,
508
- tags=tags,
509
- build_args=build_args,
510
- )
511
 
512
- # Default: clone repo, create tar context (most reliable for multi-stage builds)
513
- tmpdir = None
514
- tar_path = None
515
- try:
516
- tmpdir = clone_repo(repo_url, branch, github_token=github_token)
517
- context_dir = str(tmpdir / context_path) if context_path != "." else str(tmpdir)
518
 
519
- tar_path = f"{context_dir}.tar.gz" # Track for cleanup
520
- return build_and_push(
521
- context_dir=context_dir,
522
- image_name=image_name,
523
- dockerfile=dockerfile,
524
- tags=tags,
525
- build_args=build_args,
526
- )
527
 
528
- finally:
529
- # Cleanup: remove cloned repo and tar file
530
- if tmpdir and tmpdir.exists():
531
- shutil.rmtree(tmpdir, ignore_errors=True)
532
- if tar_path and os.path.exists(tar_path):
533
- os.remove(tar_path)
534
 
 
 
 
 
 
 
 
 
 
 
 
 
 
535
 
536
- # =============================================================================
537
- # Queue Worker
538
- # =============================================================================
 
539
 
540
- def queue_worker():
541
- """Process builds from the queue."""
542
- log("Queue worker started")
543
 
544
- while True:
545
- if state["status"] == "idle":
546
- build = get_next_build()
547
- if build:
548
- log(f"Processing queued build: {build.get('id')}")
549
- run_build(build)
 
 
550
 
551
- time.sleep(5)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
552
 
553
 
554
  # =============================================================================
555
- # Web UI
556
  # =============================================================================
557
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
558
  HTML_TEMPLATE = """
559
  <!DOCTYPE html>
560
  <html lang="en">
@@ -573,6 +1145,7 @@ HTML_TEMPLATE = """
573
  --accent: #f59e0b;
574
  --green: #4ade80;
575
  --red: #f87171;
 
576
  }
577
  * { box-sizing: border-box; margin: 0; padding: 0; }
578
  body {
@@ -580,67 +1153,143 @@ HTML_TEMPLATE = """
580
  background: var(--bg);
581
  color: var(--text);
582
  min-height: 100vh;
583
- display: flex;
584
- flex-direction: column;
585
  }
586
- .main { flex: 1; padding: 2rem; max-width: 900px; margin: 0 auto; width: 100%; }
 
 
587
  .header { margin-bottom: 2rem; }
588
  .header h1 { font-size: 1.5rem; font-weight: 600; letter-spacing: -0.025em; }
589
- .header-meta { display: flex; flex-wrap: wrap; gap: 1rem; margin-top: 0.5rem; font-size: 0.8125rem; color: var(--text-muted); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
590
 
591
- .status-card {
 
 
592
  background: var(--surface);
593
  border: 1px solid var(--border);
594
  border-radius: 0.75rem;
595
- padding: 1.5rem;
596
- margin-bottom: 1rem;
597
  }
598
- .status-header {
 
599
  display: flex;
600
  justify-content: space-between;
601
  align-items: center;
602
- margin-bottom: 1.5rem;
 
603
  }
604
- .status-info { display: flex; align-items: center; gap: 0.75rem; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
605
  .dot {
606
- width: 10px;
607
- height: 10px;
608
  border-radius: 50%;
609
  flex-shrink: 0;
610
  }
611
  .dot.idle { background: var(--text-muted); }
612
- .dot.building { background: var(--accent); animation: pulse 1.5s infinite; }
 
 
613
  @keyframes pulse {
614
- 0%, 100% { opacity: 1; transform: scale(1); }
615
- 50% { opacity: 0.5; transform: scale(0.95); }
616
  }
617
- .status-text { font-size: 0.875rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.025em; }
618
 
619
- .stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
620
- .stat { text-align: center; padding: 1rem; background: var(--bg); border-radius: 0.5rem; }
621
- .stat-value { font-size: 1.75rem; font-weight: 600; font-variant-numeric: tabular-nums; }
622
- .stat-value.success { color: var(--green); }
623
- .stat-value.failed { color: var(--red); }
624
- .stat-label { font-size: 0.6875rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-top: 0.25rem; }
625
-
626
- .form-card {
627
- background: var(--surface);
628
- border: 1px solid var(--border);
629
- border-radius: 0.75rem;
630
- padding: 1.5rem;
631
- margin-bottom: 1rem;
632
  }
633
- .form-title { font-size: 0.875rem; font-weight: 500; margin-bottom: 1.25rem; }
634
- .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
635
- .form-group { margin-bottom: 1rem; }
636
- .form-group.full { grid-column: span 2; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
637
  .form-group label {
638
  display: block;
639
- margin-bottom: 0.375rem;
640
- color: var(--text-muted);
641
  font-size: 0.75rem;
 
642
  text-transform: uppercase;
643
- letter-spacing: 0.025em;
 
644
  }
645
  .form-group input {
646
  width: 100%;
@@ -649,32 +1298,16 @@ HTML_TEMPLATE = """
649
  border: 1px solid var(--border);
650
  border-radius: 0.5rem;
651
  color: var(--text);
 
652
  font-size: 0.875rem;
653
- font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
654
- transition: border-color 0.15s;
655
  }
656
  .form-group input:focus {
657
  outline: none;
658
  border-color: var(--accent);
659
  }
660
- .form-group input::placeholder { color: var(--text-muted); opacity: 0.5; }
661
-
662
- .btn {
663
- background: var(--accent);
664
- color: var(--bg);
665
- border: none;
666
- padding: 0.75rem 1.5rem;
667
- border-radius: 0.5rem;
668
- font-size: 0.875rem;
669
- font-weight: 500;
670
- cursor: pointer;
671
- transition: opacity 0.15s, transform 0.15s;
672
- width: 100%;
673
- }
674
- .btn:hover { opacity: 0.9; }
675
- .btn:active { transform: scale(0.98); }
676
- .btn:disabled { opacity: 0.4; cursor: not-allowed; }
677
 
 
678
  .logs-panel {
679
  background: var(--surface);
680
  border: 1px solid var(--border);
@@ -695,17 +1328,20 @@ HTML_TEMPLATE = """
695
  font-size: 0.75rem;
696
  line-height: 1.5;
697
  padding: 1rem;
698
- max-height: 300px;
699
  overflow-y: auto;
700
  background: var(--bg);
701
  }
702
- .log-line { color: var(--text-muted); white-space: pre-wrap; word-break: break-all; }
703
  .log-line:hover { color: var(--text); }
704
 
705
- @media (max-width: 640px) {
706
- .form-grid { grid-template-columns: 1fr; }
707
- .form-group.full { grid-column: span 1; }
708
- .stats { grid-template-columns: 1fr; }
 
 
 
709
  }
710
  </style>
711
  </head>
@@ -714,74 +1350,78 @@ HTML_TEMPLATE = """
714
  <div class="header">
715
  <h1>Builder</h1>
716
  <div class="header-meta">
717
- <span>{{ runner_id }}</span>
718
- <span>{{ registry_url }}</span>
719
- {% if redis_connected %}<span style="color: var(--green);">Redis</span>{% endif %}
 
720
  </div>
721
  </div>
722
 
723
- <div class="status-card" id="status-panel" hx-get="/status" hx-trigger="every 2s" hx-swap="innerHTML">
724
- <div class="status-header">
725
- <div class="status-info">
726
- <div class="dot {{ status }}"></div>
727
- <div class="status-text">{{ status }}</div>
 
 
 
728
  </div>
729
- </div>
730
- <div class="stats">
731
- <div class="stat">
732
- <div class="stat-value success">{{ builds_completed }}</div>
733
- <div class="stat-label">Completed</div>
734
  </div>
735
- <div class="stat">
736
- <div class="stat-value failed">{{ builds_failed }}</div>
737
- <div class="stat-label">Failed</div>
 
 
738
  </div>
739
- <div class="stat">
740
- <div class="stat-value">{{ 'Yes' if current_build else '-' }}</div>
741
- <div class="stat-label">Active</div>
742
  </div>
743
  </div>
744
- </div>
745
 
746
- <div class="form-card">
747
- <div class="form-title">Trigger Build</div>
748
- <form hx-post="/build" hx-swap="none">
749
- <div class="form-grid">
750
- <div class="form-group full">
751
- <label>Repository URL</label>
752
- <input type="text" name="repo_url" placeholder="https://github.com/user/repo" required>
753
- </div>
754
- <div class="form-group">
755
- <label>Branch</label>
756
- <input type="text" name="branch" value="main">
757
- </div>
758
- <div class="form-group">
759
- <label>Image Name</label>
760
- <input type="text" name="image_name" placeholder="username/repo" required>
761
- </div>
762
- <div class="form-group">
763
- <label>Tags</label>
764
- <input type="text" name="tags" value="latest" placeholder="latest, v1.0">
765
- </div>
766
- <div class="form-group">
767
- <label>Dockerfile</label>
768
- <input type="text" name="dockerfile" value="Dockerfile">
769
- </div>
770
- <div class="form-group full">
771
- <button type="submit" class="btn" {% if status == 'building' %}disabled{% endif %}>
772
- {% if status == 'building' %}Building...{% else %}Build & Push{% endif %}
773
- </button>
774
- </div>
 
 
 
775
  </div>
776
- </form>
777
  </div>
778
 
779
  <div class="logs-panel">
780
  <div class="logs-header">
781
  <span class="logs-title">Logs</span>
782
  </div>
783
- <div id="logs" class="logs" hx-get="/logs" hx-trigger="every 2s" hx-swap="innerHTML">
784
- {% for line in logs %}<div class="log-line">{{ line }}</div>{% endfor %}
785
  </div>
786
  </div>
787
  </div>
@@ -790,305 +1430,209 @@ HTML_TEMPLATE = """
790
  """
791
 
792
 
793
- @app.route("/")
794
- def index():
795
- with state_lock:
796
- return render_template_string(
797
- HTML_TEMPLATE,
798
- runner_name=RUNNER_NAME,
799
- runner_id=RUNNER_ID,
800
- registry_url=REGISTRY_URL,
801
- redis_connected=state["redis_connected"],
802
- status=state["status"],
803
- builds_completed=state["builds_completed"],
804
- builds_failed=state["builds_failed"],
805
- current_build=state["current_build"],
806
- logs=state["logs"][-50:],
807
- )
808
-
809
-
810
- @app.route("/status")
811
- def status():
812
- with state_lock:
813
- return render_template_string("""
814
- <div class="status-header">
815
- <div class="status-info">
816
- <div class="dot {{ status }}"></div>
817
- <div class="status-text">{{ status }}</div>
818
- </div>
819
- </div>
820
- <div class="stats">
821
- <div class="stat">
822
- <div class="stat-value success">{{ builds_completed }}</div>
823
- <div class="stat-label">Completed</div>
824
- </div>
825
- <div class="stat">
826
- <div class="stat-value failed">{{ builds_failed }}</div>
827
- <div class="stat-label">Failed</div>
828
- </div>
829
- <div class="stat">
830
- <div class="stat-value">{{ 'Yes' if current_build else '-' }}</div>
831
- <div class="stat-label">Active</div>
832
- </div>
833
- </div>
834
- """,
835
- status=state["status"],
836
- builds_completed=state["builds_completed"],
837
- builds_failed=state["builds_failed"],
838
- current_build=state["current_build"],
839
- )
840
-
841
-
842
- @app.route("/logs")
843
- def logs():
844
- with state_lock:
845
- return "".join(f'<div class="log-line">{line}</div>' for line in state["logs"][-50:])
846
-
847
-
848
- @app.route("/build", methods=["POST"])
849
- def trigger_build():
850
- """Trigger a build via API or form."""
851
- if state["status"] == "building":
852
- return jsonify({"error": "Build already in progress"}), 409
853
-
854
- # Get build config from form or JSON
855
- if request.is_json:
856
- config = request.json
857
- else:
858
- config = {
859
- "repo_url": request.form.get("repo_url"),
860
- "branch": request.form.get("branch", "main"),
861
- "image_name": request.form.get("image_name"),
862
- "dockerfile": request.form.get("dockerfile", "Dockerfile"),
863
- "tags": [t.strip() for t in request.form.get("tags", "latest").split(",")],
864
- }
865
-
866
- if not config.get("repo_url") or not config.get("image_name"):
867
- return jsonify({"error": "repo_url and image_name required"}), 400
868
-
869
- # Run build in background
870
- threading.Thread(target=run_build, args=(config,), daemon=True).start()
871
-
872
- return jsonify({"status": "started", "config": config}), 202
873
-
874
 
875
- @app.route("/api/status")
876
- def api_status():
877
- with state_lock:
878
- return jsonify({
879
- "runner_id": RUNNER_ID,
880
- "runner_name": RUNNER_NAME,
881
- "status": state["status"],
882
- "current_build": state["current_build"],
883
- "builds_completed": state["builds_completed"],
884
- "builds_failed": state["builds_failed"],
885
- "last_build": state["last_build"],
886
- "redis_connected": state["redis_connected"],
887
- })
888
-
889
-
890
- @app.route("/api/queue", methods=["POST"])
891
- def api_queue():
892
- """Queue a build for later processing."""
893
- if not request.is_json:
894
- return jsonify({"error": "JSON required"}), 400
895
-
896
- config = request.json
897
- build_id = queue_build(config)
898
- return jsonify({"status": "queued", "build_id": build_id}), 202
899
 
 
 
900
 
901
- # =============================================================================
902
- # GitHub Webhook
903
- # =============================================================================
 
 
 
 
 
 
904
 
905
- def verify_webhook_signature(payload: bytes, signature: str) -> bool:
906
- """Verify GitHub webhook signature using HMAC-SHA256."""
907
- if not GITHUB_WEBHOOK_SECRET:
908
- log("⚠️ GITHUB_WEBHOOK_SECRET not set - skipping signature verification")
909
- return True # Allow if no secret configured (not recommended for production)
910
 
911
- if not signature or not signature.startswith("sha256="):
912
- return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
913
 
914
- expected = hmac.new(
915
- GITHUB_WEBHOOK_SECRET.encode(),
916
- payload,
917
- hashlib.sha256
918
- ).hexdigest()
919
 
920
- return hmac.compare_digest(f"sha256={expected}", signature)
 
 
 
 
 
 
 
 
 
 
 
 
921
 
 
 
 
 
922
 
923
- def should_trigger_build(changed_files: list[str]) -> tuple[bool, list[str]]:
924
- """Check if any changed files match build trigger patterns.
 
 
 
 
 
 
 
 
 
 
 
925
 
926
- Returns:
927
- Tuple of (should_build, matching_files)
928
- """
929
- matching = []
930
- for filepath in changed_files:
931
- for pattern in BUILD_TRIGGER_PATTERNS:
932
- if fnmatch.fnmatch(filepath, pattern):
933
- matching.append(filepath)
934
- break
935
- return len(matching) > 0, matching
936
 
 
 
 
 
 
 
 
 
 
 
 
 
 
937
 
938
- def extract_changed_files(payload: dict) -> list[str]:
939
- """Extract list of changed files from GitHub push payload."""
940
- files = set()
941
- for commit in payload.get("commits", []):
942
- files.update(commit.get("added", []))
943
- files.update(commit.get("modified", []))
944
- files.update(commit.get("removed", []))
945
- return list(files)
946
 
 
 
 
 
 
 
 
 
 
 
 
 
 
947
 
948
- @app.route("/webhook/github", methods=["POST"])
949
- def github_webhook():
950
- """Handle GitHub webhook events from any repository.
 
951
 
952
- Triggers a build when:
953
- - Event is a push to the default branch (main/master)
954
- - Changed files match BUILD_TRIGGER_PATTERNS
 
955
 
956
- Works with any GitHub repository - extracts repo info from payload.
 
957
 
958
- Optional headers for per-repo configuration:
959
- - X-Builder-Token: GitHub token for cloning private repos (overrides env GITHUB_TOKEN)
960
- - X-Builder-Image: Override image name (default: uses repo full_name)
961
- - X-Builder-Tags: Comma-separated tags (default: "latest")
962
- """
963
- # Verify signature
964
- signature = request.headers.get("X-Hub-Signature-256", "")
965
- if not verify_webhook_signature(request.data, signature):
966
- log("✗ Webhook signature verification failed")
967
- abort(401, "Invalid signature")
968
 
969
- # Only handle push events
970
- event = request.headers.get("X-GitHub-Event", "")
971
- if event == "ping":
972
- log("✓ Webhook ping received")
973
- return jsonify({"status": "pong"}), 200
974
 
975
- if event != "push":
976
- log(f"Ignoring non-push event: {event}")
977
- return jsonify({"status": "ignored", "reason": f"event type: {event}"}), 200
 
 
 
 
 
 
 
 
 
978
 
979
- payload = request.json
980
- if not payload:
981
- abort(400, "Missing payload")
982
 
983
- # Extract repo info from webhook payload (works for any repo)
984
- repo = payload.get("repository", {})
985
- repo_url = repo.get("clone_url", "")
986
- repo_full_name = repo.get("full_name", "") # e.g., "owner/repo"
987
- default_branch = repo.get("default_branch", "main")
988
- is_private = repo.get("private", False)
989
-
990
- # Get the ref that was pushed
991
- ref = payload.get("ref", "")
992
- pushed_branch = ref.replace("refs/heads/", "") if ref.startswith("refs/heads/") else ""
993
-
994
- log(f"Webhook: push to {repo_full_name}/{pushed_branch} (private={is_private})")
995
-
996
- # Only build on pushes to default branch
997
- if pushed_branch != default_branch:
998
- log(f"Ignoring push to non-default branch: {pushed_branch} (default: {default_branch})")
999
- return jsonify({
1000
- "status": "ignored",
1001
- "reason": f"branch {pushed_branch} is not default branch {default_branch}"
1002
- }), 200
1003
-
1004
- # Check if any relevant files changed
1005
- changed_files = extract_changed_files(payload)
1006
- should_build, matching_files = should_trigger_build(changed_files)
1007
-
1008
- if not should_build:
1009
- log(f"No build-relevant files changed in {len(changed_files)} files")
1010
- return jsonify({
1011
- "status": "ignored",
1012
- "reason": "no build-relevant files changed",
1013
- "changed_files": changed_files[:20] # Limit for response size
1014
- }), 200
1015
-
1016
- log(f"Build triggered by {len(matching_files)} matching files: {matching_files[:5]}")
1017
-
1018
- # Per-repo overrides via headers
1019
- override_token = request.headers.get("X-Builder-Token", "")
1020
- override_image = request.headers.get("X-Builder-Image", "")
1021
- override_tags = request.headers.get("X-Builder-Tags", "")
1022
-
1023
- # Determine image name: header override > env default > repo name
1024
- image_name = override_image or DEFAULT_IMAGE_NAME or repo_full_name
1025
-
1026
- # Determine tags
1027
- if override_tags:
1028
- tags = [t.strip() for t in override_tags.split(",") if t.strip()]
1029
- else:
1030
- tags = ["latest"]
1031
-
1032
- # Build configuration
1033
- build_config = {
1034
- "repo_url": repo_url,
1035
- "branch": pushed_branch,
1036
- "image_name": image_name,
1037
- "tags": tags,
1038
- "trigger": "webhook",
1039
- "matching_files": matching_files[:10],
1040
- }
1041
-
1042
- # Add per-repo token if provided (for private repos)
1043
- if override_token:
1044
- build_config["github_token"] = override_token
1045
- log(f"Using per-repo token for {repo_full_name}")
1046
-
1047
- # Check if already building
1048
- if state["status"] == "building":
1049
- log("Build already in progress - queueing webhook build")
1050
- if redis_client:
1051
- build_id = queue_build(build_config)
1052
- return jsonify({"status": "queued", "build_id": build_id}), 202
1053
  else:
1054
- return jsonify({"status": "busy", "reason": "build in progress and no queue configured"}), 409
1055
 
1056
- threading.Thread(target=run_build, args=(build_config,), daemon=True).start()
1057
 
1058
- return jsonify({
1059
- "status": "started",
1060
- "repo": repo_full_name,
1061
- "branch": pushed_branch,
1062
- "image": f"{REGISTRY_URL}/{image_name}",
1063
- "tags": tags,
1064
- "matching_files": matching_files
1065
- }), 202
1066
 
 
 
 
 
 
 
 
1067
 
1068
- @app.route("/webhook/test", methods=["POST"])
1069
- def test_webhook():
1070
- """Test endpoint to simulate a webhook (no signature required)."""
1071
- if state["status"] == "building":
1072
- return jsonify({"error": "Build already in progress"}), 409
 
 
 
 
1073
 
1074
- # Allow testing with minimal payload
1075
- payload = request.json or {}
1076
- repo_url = payload.get("repo_url", "https://github.com/jonathanagustin/lawforge")
1077
- branch = payload.get("branch", "main")
1078
- image_name = payload.get("image_name", DEFAULT_IMAGE_NAME or "jonathanagustin/lawforge")
1079
 
1080
- build_config = {
1081
- "repo_url": repo_url,
1082
- "branch": branch,
1083
- "image_name": image_name,
1084
- "tags": ["latest"],
1085
- "trigger": "test",
1086
- }
1087
 
1088
- log(f"Test webhook triggered: {image_name}")
1089
- threading.Thread(target=run_build, args=(build_config,), daemon=True).start()
1090
 
1091
- return jsonify({"status": "started", "config": build_config}), 202
 
1092
 
1093
 
1094
  # =============================================================================
@@ -1096,33 +1640,27 @@ def test_webhook():
1096
  # =============================================================================
1097
 
1098
  def startup():
1099
- log(f"Builder started: {RUNNER_NAME} ({RUNNER_ID})")
1100
- log(f"Registry: {REGISTRY_URL}")
1101
 
1102
- # Initialize Redis
1103
- init_redis()
1104
 
1105
- # Setup registry auth
1106
- if REGISTRY_USER:
1107
- setup_registry_auth()
1108
- else:
1109
- log("⚠️ REGISTRY_USER not set - pushes will fail")
1110
-
1111
- # Webhook configuration status
1112
- if GITHUB_WEBHOOK_SECRET:
1113
- log("✓ GitHub webhook secret configured")
1114
- else:
1115
- log("⚠️ GITHUB_WEBHOOK_SECRET not set - webhooks will accept unsigned payloads")
1116
 
1117
- if DEFAULT_IMAGE_NAME:
1118
- log(f" Default image: {REGISTRY_URL}/{DEFAULT_IMAGE_NAME}")
 
 
1119
 
1120
- # Start queue worker if Redis is configured
1121
- if redis_client:
1122
- threading.Thread(target=queue_worker, daemon=True).start()
 
 
1123
 
1124
- log("Ready for builds!")
1125
- log(f"Webhook endpoint: /webhook/github")
1126
 
1127
 
1128
  threading.Thread(target=startup, daemon=True).start()
 
1
  #!/usr/bin/env python3
2
+ """HF Builder - Daemonless Docker image builder using Kaniko.
3
+
4
+ A HuggingFace Space that builds and pushes Docker images without requiring
5
+ Docker daemon access. Supports GitHub webhooks for automatic builds.
6
+
7
+ Features:
8
+ - Build tracking with unique IDs and history
9
+ - GitHub webhook integration with signature verification
10
+ - Notification webhooks for build completion
11
+ - Health/readiness endpoints for orchestration
12
+ - Configurable build timeouts
13
+ - Metrics tracking (duration, success rate)
14
+ - OpenTelemetry tracing support
15
+ - Slack/Discord notifications
16
+ - Status badges
17
+
18
+ Environment Variables:
19
+ # Registry
20
+ REGISTRY_URL: Container registry URL (default: ghcr.io)
21
+ REGISTRY_USER: Registry username
22
+ REGISTRY_PASSWORD: Registry password/token
23
+
24
+ # GitHub
25
+ GITHUB_TOKEN: Token for cloning private repositories
26
+ WEBHOOK_SECRET: Secret for validating GitHub webhook signatures
27
+
28
+ # Build
29
+ DEFAULT_IMAGE: Default image name for webhook builds
30
+ BUILD_TIMEOUT: Build timeout in seconds (default: 1800 = 30 min)
31
+ ENABLE_CACHE: Enable Kaniko cache (default: false)
32
+
33
+ # Notifications
34
+ NOTIFICATION_URL: URL to POST build results to
35
+ SLACK_WEBHOOK_URL: Slack webhook for notifications
36
+ DISCORD_WEBHOOK_URL: Discord webhook for notifications
37
+ NOTIFY_ON: When to notify: "all", "failure", "success" (default: failure)
38
+
39
+ # Observability (all open source)
40
+ LOG_FORMAT: Log format - "text" or "json" (default: text)
41
+ OTEL_EXPORTER_OTLP_ENDPOINT: OpenTelemetry collector endpoint (Jaeger, Tempo, etc.)
42
+ OTEL_SERVICE_NAME: Service name for traces (default: hf-builder)
43
  """
44
 
45
+ from __future__ import annotations
46
+
47
+ import base64
48
+ import contextvars
49
  import fnmatch
50
  import hashlib
51
  import hmac
52
  import json
53
  import os
54
+ import re
55
  import shutil
56
  import subprocess
57
  import tempfile
58
  import threading
59
  import time
60
+ import traceback
61
  import uuid
62
+ from collections import deque
63
+ from dataclasses import dataclass, field
64
  from datetime import datetime, timezone
65
+ from enum import Enum
66
  from pathlib import Path
67
+ from typing import Any, Callable
68
+ from urllib.parse import urlparse
69
+
70
+ from flask import Flask, abort, jsonify, render_template_string, request, Response, g
71
+ import requests as http_requests
72
+
73
+ # Trace ID context variable for request tracing
74
+ trace_id_var: contextvars.ContextVar[str] = contextvars.ContextVar("trace_id", default="")
75
+
76
+ # =============================================================================
77
+ # Optional: OpenTelemetry
78
+ # =============================================================================
79
+
80
+ _tracer = None
81
+ _meter = None
82
+
83
+ def init_telemetry():
84
+ """Initialize OpenTelemetry if configured."""
85
+ global _tracer, _meter
86
+
87
+ endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
88
+ if not endpoint:
89
+ return
90
+
91
+ try:
92
+ from opentelemetry import trace, metrics
93
+ from opentelemetry.sdk.trace import TracerProvider
94
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
95
+ from opentelemetry.sdk.metrics import MeterProvider
96
+ from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
97
+ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
98
+ from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
99
+ from opentelemetry.sdk.resources import Resource
100
+
101
+ service_name = os.getenv("OTEL_SERVICE_NAME", "hf-builder")
102
+ resource = Resource.create({"service.name": service_name})
103
+
104
+ # Tracing
105
+ trace_provider = TracerProvider(resource=resource)
106
+ trace_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint)))
107
+ trace.set_tracer_provider(trace_provider)
108
+ _tracer = trace.get_tracer(__name__)
109
+
110
+ # Metrics
111
+ metric_reader = PeriodicExportingMetricReader(OTLPMetricExporter(endpoint=endpoint))
112
+ metrics.set_meter_provider(MeterProvider(resource=resource, metric_readers=[metric_reader]))
113
+ _meter = metrics.get_meter(__name__)
114
+
115
+ print(f"[OTEL] Initialized: {endpoint}")
116
+ except ImportError:
117
+ print("[OTEL] opentelemetry packages not installed")
118
+ except Exception as e:
119
+ print(f"[OTEL] Failed to initialize: {e}")
120
+
121
+
122
+ def get_tracer():
123
+ return _tracer
124
+
125
+
126
+ def get_meter():
127
+ return _meter
128
 
 
 
129
 
130
  # =============================================================================
131
  # Configuration
132
  # =============================================================================
133
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
 
135
+ class LogFormat(Enum):
136
+ TEXT = "text"
137
+ JSON = "json"
138
+
139
+
140
+ class NotifyOn(Enum):
141
+ ALL = "all"
142
+ FAILURE = "failure"
143
+ SUCCESS = "success"
144
+
145
+
146
+ @dataclass
147
+ class Config:
148
+ """Application configuration from environment variables."""
149
+
150
+ registry_url: str = field(default_factory=lambda: os.getenv("REGISTRY_URL", "ghcr.io"))
151
+ registry_user: str = field(default_factory=lambda: os.getenv("REGISTRY_USER", ""))
152
+ registry_password: str = field(default_factory=lambda: os.getenv("REGISTRY_PASSWORD", ""))
153
+ github_token: str = field(default_factory=lambda: os.getenv("GITHUB_TOKEN", ""))
154
+ webhook_secret: str = field(default_factory=lambda: os.getenv("WEBHOOK_SECRET", ""))
155
+ default_image: str = field(default_factory=lambda: os.getenv("DEFAULT_IMAGE", ""))
156
+ runner_id: str = field(default_factory=lambda: os.getenv("RUNNER_ID", str(uuid.uuid4())[:8]))
157
+ build_timeout: int = field(default_factory=lambda: int(os.getenv("BUILD_TIMEOUT", "1800")))
158
+ enable_cache: bool = field(default_factory=lambda: os.getenv("ENABLE_CACHE", "").lower() == "true")
159
+ notification_url: str = field(default_factory=lambda: os.getenv("NOTIFICATION_URL", ""))
160
+ slack_webhook_url: str = field(default_factory=lambda: os.getenv("SLACK_WEBHOOK_URL", ""))
161
+ discord_webhook_url: str = field(default_factory=lambda: os.getenv("DISCORD_WEBHOOK_URL", ""))
162
+ notify_on: NotifyOn = field(default_factory=lambda: NotifyOn(os.getenv("NOTIFY_ON", "failure").lower()) if os.getenv("NOTIFY_ON", "failure").lower() in ("all", "failure", "success") else NotifyOn.FAILURE)
163
+ log_format: LogFormat = field(default_factory=lambda: LogFormat(os.getenv("LOG_FORMAT", "text").lower()) if os.getenv("LOG_FORMAT", "text").lower() in ("text", "json") else LogFormat.TEXT)
164
+ max_history: int = field(default_factory=lambda: int(os.getenv("MAX_HISTORY", "50")))
165
+
166
+ build_patterns: list[str] = field(default_factory=lambda: [
167
+ "Dockerfile", "Dockerfile.*", "docker/*", "docker/**/*",
168
+ "src/**/*.py", "pyproject.toml", "uv.lock", "requirements*.txt", ".dockerignore",
169
+ ])
170
+
171
+
172
+ # =============================================================================
173
+ # Build Models
174
+ # =============================================================================
175
 
176
 
177
+ class BuildStatus(Enum):
178
+ PENDING = "pending"
179
+ RUNNING = "running"
180
+ SUCCESS = "success"
181
+ FAILED = "failed"
182
+ CANCELLED = "cancelled"
183
+ TIMEOUT = "timeout"
184
+
185
+
186
+ @dataclass
187
+ class BuildConfig:
188
+ """Configuration for a single build."""
189
+ repo_url: str
190
+ image_name: str
191
+ branch: str = "main"
192
+ dockerfile: str = "Dockerfile"
193
+ context_path: str = "."
194
+ tags: list[str] = field(default_factory=lambda: ["latest"])
195
+ build_args: dict[str, str] = field(default_factory=dict)
196
+ github_token: str | None = None
197
+ platform: str | None = None
198
+ trigger: str = "api"
199
+ callback_url: str | None = None
200
+
201
+
202
+ @dataclass
203
+ class Build:
204
+ """A build with tracking information."""
205
+ id: str
206
+ config: BuildConfig
207
+ status: BuildStatus = BuildStatus.PENDING
208
+ started_at: str | None = None
209
+ completed_at: str | None = None
210
+ duration_seconds: float | None = None
211
+ exit_code: int | None = None
212
+ error: str | None = None
213
+ trace_id: str | None = None
214
+
215
+ def to_dict(self) -> dict[str, Any]:
216
+ return {
217
+ "id": self.id,
218
+ "status": self.status.value,
219
+ "image": f"{config.registry_url}/{self.config.image_name}",
220
+ "tags": self.config.tags,
221
+ "branch": self.config.branch,
222
+ "trigger": self.config.trigger,
223
+ "started_at": self.started_at,
224
+ "completed_at": self.completed_at,
225
+ "duration_seconds": self.duration_seconds,
226
+ "exit_code": self.exit_code,
227
+ "error": self.error,
228
+ "trace_id": self.trace_id,
229
+ }
230
 
231
 
232
  # =============================================================================
233
+ # State Management
234
  # =============================================================================
235
 
 
 
 
236
 
237
+ class BuildState:
238
+ """Thread-safe build state manager with history."""
239
+
240
+ def __init__(self, max_history: int = 50) -> None:
241
+ self._lock = threading.Lock()
242
+ self._current: Build | None = None
243
+ self._history: deque[Build] = deque(maxlen=max_history)
244
+ self._logs: deque[str] = deque(maxlen=500)
245
+ self._completed = 0
246
+ self._failed = 0
247
+ self._total_duration = 0.0
248
+ self._process: subprocess.Popen | None = None
249
+ self._ready = False
250
+ self._last_success_at: str | None = None
251
+ self._last_failure_at: str | None = None
252
+
253
+ def log(self, msg: str, level: str = "info", **extra: Any) -> None:
254
+ """Add a log message with optional trace ID."""
255
+ ts = datetime.now(timezone.utc)
256
+ ts_str = ts.strftime("%H:%M:%S")
257
+ trace_id = trace_id_var.get() or extra.get("trace_id", "")
258
+
259
+ if config.log_format == LogFormat.JSON:
260
+ log_entry = {
261
+ "ts": ts.isoformat(),
262
+ "level": level,
263
+ "msg": msg,
264
+ "runner": config.runner_id,
265
+ "trace_id": trace_id,
266
+ **{k: v for k, v in extra.items() if k != "trace_id"},
267
+ }
268
+ print(json.dumps(log_entry))
269
+ with self._lock:
270
+ self._logs.append(json.dumps(log_entry))
271
+ else:
272
+ prefix = f"[{ts_str}]"
273
+ if trace_id:
274
+ prefix += f" [{trace_id[:8]}]"
275
+ formatted = f"{prefix} {msg}"
276
+ print(formatted)
277
+ with self._lock:
278
+ self._logs.append(formatted)
279
+
280
+ def start_build(self, build: Build) -> None:
281
+ with self._lock:
282
+ build.status = BuildStatus.RUNNING
283
+ build.started_at = datetime.now(timezone.utc).isoformat()
284
+ build.trace_id = trace_id_var.get()
285
+ self._current = build
286
+
287
+ def finish_build(self, build: Build, status: BuildStatus, exit_code: int | None = None, error: str | None = None) -> None:
288
+ with self._lock:
289
+ build.status = status
290
+ build.completed_at = datetime.now(timezone.utc).isoformat()
291
+ build.exit_code = exit_code
292
+ build.error = error
293
+
294
+ if build.started_at:
295
+ start = datetime.fromisoformat(build.started_at)
296
+ end = datetime.fromisoformat(build.completed_at)
297
+ build.duration_seconds = (end - start).total_seconds()
298
+ self._total_duration += build.duration_seconds
299
+
300
+ if status == BuildStatus.SUCCESS:
301
+ self._completed += 1
302
+ self._last_success_at = build.completed_at
303
+ else:
304
+ self._failed += 1
305
+ self._last_failure_at = build.completed_at
306
+
307
+ self._history.appendleft(build)
308
+ self._current = None
309
+ self._process = None
310
+
311
+ def set_process(self, process: subprocess.Popen) -> None:
312
+ with self._lock:
313
+ self._process = process
314
+
315
+ def cancel_current(self) -> bool:
316
+ with self._lock:
317
+ if self._process and self._current:
318
+ try:
319
+ self._process.terminate()
320
+ return True
321
+ except Exception:
322
+ return False
323
+ return False
324
 
325
+ def set_ready(self, ready: bool = True) -> None:
326
+ with self._lock:
327
+ self._ready = ready
328
+
329
+ @property
330
+ def is_ready(self) -> bool:
331
+ with self._lock:
332
+ return self._ready
333
+
334
+ @property
335
+ def is_building(self) -> bool:
336
+ with self._lock:
337
+ return self._current is not None
338
+
339
+ @property
340
+ def current_build(self) -> Build | None:
341
+ with self._lock:
342
+ return self._current
343
+
344
+ @property
345
+ def logs(self) -> list[str]:
346
+ with self._lock:
347
+ return list(self._logs)[-100:]
348
+
349
+ def get_metrics(self) -> dict[str, Any]:
350
+ with self._lock:
351
+ total = self._completed + self._failed
352
+ return {
353
+ "builds_completed": self._completed,
354
+ "builds_failed": self._failed,
355
+ "builds_total": total,
356
+ "success_rate": self._completed / total if total > 0 else 0,
357
+ "avg_duration_seconds": self._total_duration / total if total > 0 else 0,
358
+ "last_success_at": self._last_success_at,
359
+ "last_failure_at": self._last_failure_at,
360
+ }
361
+
362
+ def get_history(self, limit: int = 10) -> list[dict]:
363
+ with self._lock:
364
+ return [b.to_dict() for b in list(self._history)[:limit]]
365
+
366
+ def to_dict(self) -> dict[str, Any]:
367
+ with self._lock:
368
+ return {
369
+ "status": "building" if self._current else "idle",
370
+ "current_build": self._current.to_dict() if self._current else None,
371
+ **self.get_metrics(),
372
+ }
373
 
 
 
 
 
 
 
 
 
 
 
 
374
 
375
+ # =============================================================================
376
+ # Notifications
377
+ # =============================================================================
378
 
 
 
 
 
379
 
380
+ class Notifier:
381
+ """Send notifications to various channels."""
 
 
 
 
 
 
382
 
383
+ def __init__(self, cfg: Config, state: BuildState) -> None:
384
+ self.config = cfg
385
+ self.state = state
386
 
387
+ def should_notify(self, status: BuildStatus) -> bool:
388
+ if self.config.notify_on == NotifyOn.ALL:
389
+ return True
390
+ if self.config.notify_on == NotifyOn.FAILURE and status not in (BuildStatus.SUCCESS,):
391
+ return True
392
+ if self.config.notify_on == NotifyOn.SUCCESS and status == BuildStatus.SUCCESS:
393
+ return True
394
+ return False
395
+
396
+ def notify(self, build: Build) -> None:
397
+ """Send notifications for a completed build."""
398
+ if not self.should_notify(build.status):
399
+ return
400
+
401
+ # Generic webhook
402
+ if self.config.notification_url or build.config.callback_url:
403
+ self._send_webhook(build)
404
 
405
+ # Slack
406
+ if self.config.slack_webhook_url:
407
+ self._send_slack(build)
408
+
409
+ # Discord
410
+ if self.config.discord_webhook_url:
411
+ self._send_discord(build)
412
+
413
+ def _send_webhook(self, build: Build) -> None:
414
+ url = build.config.callback_url or self.config.notification_url
415
+ if not url:
416
+ return
417
  try:
418
+ payload = {
419
+ "build": build.to_dict(),
420
+ "runner_id": self.config.runner_id,
421
+ "registry": self.config.registry_url,
422
+ }
423
+ http_requests.post(url, json=payload, timeout=10)
424
+ self.state.log(f"Notification sent to webhook", trace_id=build.trace_id)
425
  except Exception as e:
426
+ self.state.log(f"Webhook notification failed: {e}", level="warn")
427
+
428
+ def _send_slack(self, build: Build) -> None:
429
+ try:
430
+ color = "#22c55e" if build.status == BuildStatus.SUCCESS else "#ef4444"
431
+ status_emoji = ":white_check_mark:" if build.status == BuildStatus.SUCCESS else ":x:"
432
+
433
+ payload = {
434
+ "attachments": [{
435
+ "color": color,
436
+ "blocks": [
437
+ {
438
+ "type": "section",
439
+ "text": {
440
+ "type": "mrkdwn",
441
+ "text": f"{status_emoji} *Build {build.status.value.upper()}*\n`{build.config.image_name}:{build.config.tags[0]}`"
442
+ }
443
+ },
444
+ {
445
+ "type": "context",
446
+ "elements": [
447
+ {"type": "mrkdwn", "text": f"*ID:* {build.id}"},
448
+ {"type": "mrkdwn", "text": f"*Duration:* {build.duration_seconds:.1f}s" if build.duration_seconds else ""},
449
+ {"type": "mrkdwn", "text": f"*Branch:* {build.config.branch}"},
450
+ ]
451
+ }
452
+ ]
453
+ }]
454
+ }
455
+
456
+ if build.error:
457
+ payload["attachments"][0]["blocks"].append({
458
+ "type": "section",
459
+ "text": {"type": "mrkdwn", "text": f"```{build.error[:500]}```"}
460
+ })
461
+
462
+ http_requests.post(self.config.slack_webhook_url, json=payload, timeout=10)
463
+ self.state.log("Slack notification sent", trace_id=build.trace_id)
464
+ except Exception as e:
465
+ self.state.log(f"Slack notification failed: {e}", level="warn")
466
+
467
+ def _send_discord(self, build: Build) -> None:
468
+ try:
469
+ color = 0x22c55e if build.status == BuildStatus.SUCCESS else 0xef4444
470
+
471
+ embed = {
472
+ "title": f"Build {build.status.value.upper()}",
473
+ "color": color,
474
+ "fields": [
475
+ {"name": "Image", "value": f"`{build.config.image_name}:{build.config.tags[0]}`", "inline": True},
476
+ {"name": "Branch", "value": build.config.branch, "inline": True},
477
+ {"name": "Build ID", "value": build.id, "inline": True},
478
+ ],
479
+ "timestamp": build.completed_at,
480
+ }
481
+
482
+ if build.duration_seconds:
483
+ embed["fields"].append({"name": "Duration", "value": f"{build.duration_seconds:.1f}s", "inline": True})
484
+
485
+ if build.error:
486
+ embed["fields"].append({"name": "Error", "value": f"```{build.error[:500]}```", "inline": False})
487
+
488
+ http_requests.post(self.config.discord_webhook_url, json={"embeds": [embed]}, timeout=10)
489
+ self.state.log("Discord notification sent", trace_id=build.trace_id)
490
+ except Exception as e:
491
+ self.state.log(f"Discord notification failed: {e}", level="warn")
492
 
493
 
494
  # =============================================================================
495
+ # Validation
496
  # =============================================================================
497
 
 
 
 
 
 
498
 
499
+ def validate_url(url: str) -> tuple[bool, str]:
500
+ if not url:
501
+ return False, "URL is required"
502
+ try:
503
+ parsed = urlparse(url)
504
+ if parsed.scheme not in ("https", "http"):
505
+ return False, "URL must use https or http"
506
+ if not parsed.netloc:
507
+ return False, "Invalid URL format"
508
+ return True, ""
509
+ except Exception as e:
510
+ return False, f"Invalid URL: {e}"
511
 
 
 
512
 
513
+ def validate_image_name(name: str) -> tuple[bool, str]:
514
+ if not name:
515
+ return False, "Image name is required"
516
+ pattern = r"^[a-z0-9][a-z0-9._-]*/[a-z0-9][a-z0-9._-]*$"
517
+ if not re.match(pattern, name.lower()):
518
+ return False, "Image name must be in owner/repo format"
519
+ return True, ""
520
+
521
 
522
+ def validate_tags(tags: list[str]) -> tuple[bool, str]:
523
+ if not tags:
524
+ return False, "At least one tag is required"
525
+ pattern = r"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"
526
+ for tag in tags:
527
+ if not re.match(pattern, tag) or len(tag) > 128:
528
+ return False, f"Invalid tag: {tag}"
529
+ return True, ""
530
 
531
+
532
+ def mask_token(text: str, token: str | None) -> str:
533
+ if token and token in text:
534
+ return text.replace(token, "***")
535
+ return text
536
 
537
 
538
  # =============================================================================
539
+ # Builder
540
  # =============================================================================
541
 
 
 
 
 
542
 
543
+ class KanikoBuilder:
544
+ def __init__(self, cfg: Config, state: BuildState, notifier: Notifier) -> None:
545
+ self.config = cfg
546
+ self.state = state
547
+ self.notifier = notifier
548
 
549
+ def setup_registry_auth(self) -> bool:
550
+ if not self.config.registry_user or not self.config.registry_password:
551
+ self.state.log("Registry credentials not configured", level="warn")
552
+ return False
 
 
553
 
554
+ docker_config_dir = Path("/kaniko/.docker")
555
+ docker_config_dir.mkdir(parents=True, exist_ok=True)
 
 
 
 
 
 
 
 
 
 
 
556
 
557
+ auth = base64.b64encode(
558
+ f"{self.config.registry_user}:{self.config.registry_password}".encode()
559
+ ).decode()
560
 
561
+ auth_config = {
562
+ "auths": {
563
+ self.config.registry_url: {"auth": auth},
564
+ f"https://{self.config.registry_url}": {"auth": auth},
565
+ }
566
+ }
 
 
567
 
568
+ (docker_config_dir / "config.json").write_text(json.dumps(auth_config))
569
+ self.state.log(f"Registry auth configured for {self.config.registry_url}")
570
+ return True
571
 
572
+ def clone_repo(self, build_config: BuildConfig) -> Path:
573
+ target_dir = Path(tempfile.mkdtemp())
574
+ token = build_config.github_token or self.config.github_token
575
+ repo_url = build_config.repo_url
576
 
577
+ if token and "github.com" in repo_url:
578
+ repo_url = repo_url.replace("https://github.com", f"https://{token}@github.com")
579
+ self.state.log(f"Cloning {build_config.repo_url} ({build_config.branch}) [authenticated]")
580
+ else:
581
+ self.state.log(f"Cloning {build_config.repo_url} ({build_config.branch})")
582
 
583
+ try:
584
+ import git
585
+ git.Repo.clone_from(repo_url, target_dir, branch=build_config.branch, depth=1, single_branch=True)
586
+ self.state.log(f"Cloned to {target_dir}")
587
+ return target_dir
588
+ except Exception as e:
589
+ error_msg = mask_token(str(e), token)
590
+ self.state.log(f"Clone failed: {error_msg}", level="error")
591
+ raise RuntimeError(f"Clone failed: {error_msg}")
592
 
593
+ def build_and_push(self, build: Build) -> bool:
594
+ # Set trace ID for this build
595
+ trace_id_var.set(build.id)
 
 
 
 
596
 
597
+ tracer = get_tracer()
598
+ span = None
599
+ if tracer:
600
+ span = tracer.start_span("build_and_push")
601
+ span.set_attribute("build.id", build.id)
602
+ span.set_attribute("build.image", build.config.image_name)
603
+
604
+ build_config = build.config
605
+ full_image = f"{self.config.registry_url}/{build_config.image_name}"
606
+
607
+ self.state.log(f"Building {full_image}", build_id=build.id)
608
+ self.state.start_build(build)
609
+
610
+ tmpdir = None
611
+ tar_path = None
612
+
613
+ try:
614
+ tmpdir = self.clone_repo(build_config)
615
+ context_dir = str(tmpdir / build_config.context_path) if build_config.context_path != "." else str(tmpdir)
616
+
617
+ tar_path = f"{context_dir}.tar.gz"
618
+ self.state.log("Creating tar context")
619
+ subprocess.run(["tar", "-czf", tar_path, "-C", context_dir, "."], capture_output=True, check=True, timeout=120)
620
+
621
+ cmd = [
622
+ "/kaniko/executor",
623
+ f"--context=tar://{tar_path}",
624
+ f"--dockerfile={build_config.dockerfile}",
625
+ ]
626
+
627
+ for tag in build_config.tags:
628
+ cmd.append(f"--destination={full_image}:{tag}")
629
+
630
+ for key, value in build_config.build_args.items():
631
+ cmd.append(f"--build-arg={key}={value}")
632
+
633
+ if build_config.platform:
634
+ cmd.append(f"--custom-platform={build_config.platform}")
635
+
636
+ cmd.extend([
637
+ f"--cache={'true' if self.config.enable_cache else 'false'}",
638
+ "--reproducible",
639
+ "--ignore-path=/product_uuid",
640
+ "--ignore-path=/sys",
641
+ "--log-format=text",
642
+ "--verbosity=info",
643
+ ])
644
+
645
+ self.state.log("Running Kaniko")
646
+
647
+ process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
648
+ self.state.set_process(process)
649
+
650
+ def read_output():
651
+ for line in process.stdout:
652
+ line = line.strip()
653
+ if line:
654
+ line = mask_token(line, self.config.github_token)
655
+ line = mask_token(line, build_config.github_token)
656
+ self.state.log(f" {line[:150]}")
657
+
658
+ output_thread = threading.Thread(target=read_output, daemon=True)
659
+ output_thread.start()
660
+
661
+ try:
662
+ exit_code = process.wait(timeout=self.config.build_timeout)
663
+ except subprocess.TimeoutExpired:
664
+ process.kill()
665
+ self.state.log(f"Build timed out after {self.config.build_timeout}s", level="error")
666
+ self.state.finish_build(build, BuildStatus.TIMEOUT, error="Build timeout")
667
+ self.notifier.notify(build)
668
+ if span:
669
+ span.set_attribute("build.status", "timeout")
670
+ span.end()
671
+ return False
672
+
673
+ output_thread.join(timeout=5)
674
+
675
+ if exit_code == 0:
676
+ self.state.log(f"Build successful: {full_image}")
677
+ self.state.finish_build(build, BuildStatus.SUCCESS, exit_code=0)
678
+ if span:
679
+ span.set_attribute("build.status", "success")
680
+ else:
681
+ self.state.log(f"Build failed with exit code {exit_code}", level="error")
682
+ self.state.finish_build(build, BuildStatus.FAILED, exit_code=exit_code)
683
+ if span:
684
+ span.set_attribute("build.status", "failed")
685
+
686
+ self.notifier.notify(build)
687
+ if span:
688
+ span.end()
689
+ return exit_code == 0
690
+
691
+ except Exception as e:
692
+ error_msg = mask_token(str(e), self.config.github_token)
693
+ error_msg = mask_token(error_msg, build_config.github_token)
694
+ self.state.log(f"Build error: {error_msg}", level="error")
695
+ self.state.finish_build(build, BuildStatus.FAILED, error=error_msg)
696
+ self.notifier.notify(build)
697
+ if span:
698
+ span.set_attribute("build.status", "error")
699
+ span.record_exception(e)
700
+ span.end()
701
  return False
 
 
 
 
702
 
703
+ finally:
704
+ if tmpdir and tmpdir.exists():
705
+ shutil.rmtree(tmpdir, ignore_errors=True)
706
+ if tar_path and os.path.exists(tar_path):
707
+ os.remove(tar_path)
 
708
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
709
 
710
+ # =============================================================================
711
+ # Webhook Handler
712
+ # =============================================================================
713
 
 
 
 
 
 
 
 
714
 
715
+ class WebhookHandler:
716
+ def __init__(self, cfg: Config, state: BuildState) -> None:
717
+ self.config = cfg
718
+ self.state = state
719
+
720
+ def verify_signature(self, payload: bytes, signature: str) -> bool:
721
+ if not self.config.webhook_secret:
722
+ self.state.log("WEBHOOK_SECRET not set - skipping verification", level="warn")
 
 
 
 
 
 
 
 
 
 
723
  return True
724
+ if not signature or not signature.startswith("sha256="):
 
 
 
 
 
 
 
 
 
 
725
  return False
726
+ expected = hmac.new(self.config.webhook_secret.encode(), payload, hashlib.sha256).hexdigest()
727
+ return hmac.compare_digest(f"sha256={expected}", signature)
728
+
729
+ def extract_changed_files(self, payload: dict) -> list[str]:
730
+ files = set()
731
+ for commit in payload.get("commits", []):
732
+ files.update(commit.get("added", []))
733
+ files.update(commit.get("modified", []))
734
+ files.update(commit.get("removed", []))
735
+ return list(files)
736
+
737
+ def should_trigger_build(self, changed_files: list[str]) -> tuple[bool, list[str]]:
738
+ matching = []
739
+ for filepath in changed_files:
740
+ for pattern in self.config.build_patterns:
741
+ if fnmatch.fnmatch(filepath, pattern):
742
+ matching.append(filepath)
743
+ break
744
+ return len(matching) > 0, matching
745
+
746
+ def parse_push_event(self, payload: dict, headers: dict) -> dict[str, Any]:
747
+ repo = payload.get("repository", {})
748
+ repo_url = repo.get("clone_url", "")
749
+ repo_name = repo.get("full_name", "")
750
+ default_branch = repo.get("default_branch", "main")
751
+
752
+ ref = payload.get("ref", "")
753
+ pushed_branch = ref.replace("refs/heads/", "") if ref.startswith("refs/heads/") else ""
754
+
755
+ self.state.log(f"Webhook: push to {repo_name}/{pushed_branch}")
756
+
757
+ if pushed_branch != default_branch:
758
+ return {"status": "ignored", "reason": f"not default branch ({default_branch})"}
759
+
760
+ changed_files = self.extract_changed_files(payload)
761
+ should_build, matching_files = self.should_trigger_build(changed_files)
762
+
763
+ if not should_build:
764
+ return {"status": "ignored", "reason": "no build-relevant files changed"}
765
+
766
+ image_name = headers.get("X-Builder-Image") or self.config.default_image or repo_name
767
+ tags_header = headers.get("X-Builder-Tags", "")
768
+ tags = [t.strip() for t in tags_header.split(",") if t.strip()] if tags_header else ["latest"]
769
+
770
+ build_args = {}
771
+ args_header = headers.get("X-Builder-Args", "")
772
+ if args_header:
773
+ for pair in args_header.split(","):
774
+ if "=" in pair:
775
+ k, v = pair.split("=", 1)
776
+ build_args[k.strip()] = v.strip()
777
+
778
+ return {
779
+ "status": "trigger",
780
+ "repo_url": repo_url,
781
+ "branch": pushed_branch,
782
+ "image_name": image_name,
783
  "tags": tags,
784
+ "github_token": headers.get("X-Builder-Token"),
785
+ "platform": headers.get("X-Builder-Platform"),
786
+ "build_args": build_args,
787
+ "callback_url": headers.get("X-Builder-Callback"),
788
+ "matching_files": matching_files,
789
  }
790
 
791
+
792
+ # =============================================================================
793
+ # Flask App
794
+ # =============================================================================
795
+
796
+ config = Config()
797
+ state = BuildState(max_history=config.max_history)
798
+ notifier = Notifier(config, state)
799
+ builder = KanikoBuilder(config, state, notifier)
800
+ webhook_handler = WebhookHandler(config, state)
801
+
802
+ app = Flask(__name__)
803
+
804
+
805
+ @app.before_request
806
+ def before_request():
807
+ """Set trace ID for each request."""
808
+ trace_id = request.headers.get("X-Request-ID") or request.headers.get("X-Trace-ID") or str(uuid.uuid4())[:8]
809
+ trace_id_var.set(trace_id)
810
+ g.trace_id = trace_id
811
+
812
+
813
+ @app.after_request
814
+ def after_request(response):
815
+ """Add trace ID to response headers."""
816
+ response.headers["X-Trace-ID"] = g.get("trace_id", "")
817
+ return response
818
+
819
+
820
+ def create_build(build_config: BuildConfig) -> Build:
821
+ return Build(id=str(uuid.uuid4())[:8], config=build_config)
822
+
823
+
824
+ # Health endpoints
825
+ @app.route("/health")
826
+ def health():
827
+ return jsonify({"status": "healthy", "runner_id": config.runner_id})
828
+
829
+
830
+ @app.route("/ready")
831
+ def ready():
832
+ if state.is_ready:
833
+ return jsonify({"status": "ready"})
834
+ return jsonify({"status": "not_ready"}), 503
835
+
836
+
837
+ # Status badge
838
+ @app.route("/badge")
839
+ def badge():
840
+ """SVG status badge for READMEs."""
841
+ metrics = state.get_metrics()
842
+ if state.is_building:
843
+ color, text = "#f59e0b", "building"
844
+ elif metrics["builds_total"] == 0:
845
+ color, text = "#737373", "no builds"
846
+ elif metrics["last_failure_at"] and (not metrics["last_success_at"] or metrics["last_failure_at"] > metrics["last_success_at"]):
847
+ color, text = "#ef4444", "failing"
848
+ else:
849
+ color, text = "#22c55e", "passing"
850
+
851
+ svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="90" height="20">
852
+ <rect width="90" height="20" rx="3" fill="#555"/>
853
+ <rect x="45" width="45" height="20" rx="3" fill="{color}"/>
854
+ <rect x="45" width="4" height="20" fill="{color}"/>
855
+ <text x="22" y="14" fill="#fff" font-family="sans-serif" font-size="11" text-anchor="middle">build</text>
856
+ <text x="67" y="14" fill="#fff" font-family="sans-serif" font-size="11" text-anchor="middle">{text}</text>
857
+ </svg>'''
858
+ return Response(svg, mimetype="image/svg+xml", headers={"Cache-Control": "no-cache"})
859
+
860
+
861
+ # API endpoints
862
+ @app.route("/api/status")
863
+ def api_status():
864
+ return jsonify({"runner_id": config.runner_id, "registry": config.registry_url, "trace_id": g.get("trace_id"), **state.to_dict()})
865
+
866
+
867
+ @app.route("/api/metrics")
868
+ def api_metrics():
869
+ metrics = state.get_metrics()
870
+ lines = [
871
+ f'# HELP hf_builder_builds_total Total builds',
872
+ f'# TYPE hf_builder_builds_total counter',
873
+ f'hf_builder_builds_total{{status="success"}} {metrics["builds_completed"]}',
874
+ f'hf_builder_builds_total{{status="failed"}} {metrics["builds_failed"]}',
875
+ f'# HELP hf_builder_build_duration_seconds Avg build duration',
876
+ f'# TYPE hf_builder_build_duration_seconds gauge',
877
+ f'hf_builder_build_duration_seconds {metrics["avg_duration_seconds"]:.2f}',
878
+ f'# HELP hf_builder_success_rate Success rate',
879
+ f'# TYPE hf_builder_success_rate gauge',
880
+ f'hf_builder_success_rate {metrics["success_rate"]:.4f}',
881
  ]
882
+ return Response("\n".join(lines), mimetype="text/plain")
883
 
 
 
884
 
885
+ @app.route("/api/history")
886
+ def api_history():
887
+ limit = request.args.get("limit", 10, type=int)
888
+ return jsonify({"builds": state.get_history(limit)})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
889
 
 
890
 
891
+ @app.route("/api/logs")
892
+ def api_logs():
893
+ return jsonify({"logs": state.logs})
 
 
 
 
894
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
895
 
896
+ @app.route("/api/build", methods=["POST"])
897
+ def api_build():
898
+ if state.is_building:
899
+ return jsonify({"error": "Build in progress", "current": state.current_build.to_dict()}), 409
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
900
 
901
+ data = request.json or {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
902
 
903
+ valid, err = validate_url(data.get("repo_url", ""))
904
+ if not valid:
905
+ return jsonify({"error": err}), 400
 
 
 
906
 
907
+ valid, err = validate_image_name(data.get("image_name", ""))
908
+ if not valid:
909
+ return jsonify({"error": err}), 400
 
 
 
 
 
910
 
911
+ tags = data.get("tags", ["latest"])
912
+ valid, err = validate_tags(tags)
913
+ if not valid:
914
+ return jsonify({"error": err}), 400
 
 
915
 
916
+ build_config = BuildConfig(
917
+ repo_url=data["repo_url"],
918
+ image_name=data["image_name"],
919
+ branch=data.get("branch", "main"),
920
+ dockerfile=data.get("dockerfile", "Dockerfile"),
921
+ context_path=data.get("context_path", "."),
922
+ tags=tags,
923
+ build_args=data.get("build_args", {}),
924
+ github_token=data.get("github_token"),
925
+ platform=data.get("platform"),
926
+ trigger="api",
927
+ callback_url=data.get("callback_url"),
928
+ )
929
 
930
+ build = create_build(build_config)
931
+ threading.Thread(target=builder.build_and_push, args=(build,), daemon=True).start()
932
+
933
+ return jsonify({"status": "started", "build_id": build.id, "trace_id": g.get("trace_id"), "image": f"{config.registry_url}/{build_config.image_name}"}), 202
934
 
 
 
 
935
 
936
+ @app.route("/api/build/<build_id>/cancel", methods=["POST"])
937
+ def api_cancel(build_id: str):
938
+ current = state.current_build
939
+ if not current or current.id != build_id:
940
+ return jsonify({"error": "Build not found or not running"}), 404
941
+ if state.cancel_current():
942
+ return jsonify({"status": "cancelled", "build_id": build_id})
943
+ return jsonify({"error": "Failed to cancel"}), 500
944
 
945
+
946
+ # Webhook endpoints
947
+ @app.route("/webhook/github", methods=["POST"])
948
+ def webhook_github():
949
+ signature = request.headers.get("X-Hub-Signature-256", "")
950
+ if not webhook_handler.verify_signature(request.data, signature):
951
+ abort(401, "Invalid signature")
952
+
953
+ event = request.headers.get("X-GitHub-Event", "")
954
+ if event == "ping":
955
+ return jsonify({"status": "pong"})
956
+ if event != "push":
957
+ return jsonify({"status": "ignored", "reason": f"event: {event}"})
958
+
959
+ payload = request.json
960
+ if not payload:
961
+ abort(400, "Missing payload")
962
+
963
+ headers = {k: request.headers.get(k, "") for k in ["X-Builder-Image", "X-Builder-Tags", "X-Builder-Token", "X-Builder-Platform", "X-Builder-Args", "X-Builder-Callback"]}
964
+ result = webhook_handler.parse_push_event(payload, headers)
965
+
966
+ if result["status"] != "trigger":
967
+ return jsonify(result)
968
+ if state.is_building:
969
+ return jsonify({"status": "busy"}), 409
970
+
971
+ build_config = BuildConfig(
972
+ repo_url=result["repo_url"], image_name=result["image_name"], branch=result["branch"],
973
+ tags=result["tags"], github_token=result.get("github_token"), platform=result.get("platform"),
974
+ build_args=result.get("build_args", {}), trigger="webhook", callback_url=result.get("callback_url"),
975
+ )
976
+
977
+ build = create_build(build_config)
978
+ threading.Thread(target=builder.build_and_push, args=(build,), daemon=True).start()
979
+
980
+ return jsonify({"status": "started", "build_id": build.id, "trace_id": g.get("trace_id")}), 202
981
+
982
+
983
+ @app.route("/webhook/test", methods=["POST"])
984
+ def webhook_test():
985
+ if state.is_building:
986
+ return jsonify({"error": "Build in progress"}), 409
987
+
988
+ data = request.json or {}
989
+ if not data.get("repo_url") or not data.get("image_name"):
990
+ return jsonify({"error": "repo_url and image_name required"}), 400
991
+
992
+ build_config = BuildConfig(repo_url=data["repo_url"], image_name=data["image_name"], branch=data.get("branch", "main"), tags=data.get("tags", ["latest"]), trigger="test")
993
+ build = create_build(build_config)
994
+ threading.Thread(target=builder.build_and_push, args=(build,), daemon=True).start()
995
+
996
+ return jsonify({"status": "started", "build_id": build.id, "trace_id": g.get("trace_id")}), 202
997
 
998
 
999
  # =============================================================================
1000
+ # Web UI - HTMX Interface
1001
  # =============================================================================
1002
 
1003
+
1004
+ def render_stats_html() -> str:
1005
+ """Render stats cards."""
1006
+ metrics = state.get_metrics()
1007
+ status = "building" if state.is_building else "idle"
1008
+ return f"""
1009
+ <div class="stat-card">
1010
+ <div class="stat-label">Status</div>
1011
+ <div class="stat-value" style="display: flex; align-items: center; gap: 0.5rem;">
1012
+ <span class="dot {'building' if status == 'building' else 'idle'}"></span>
1013
+ {status.upper()}
1014
+ </div>
1015
+ </div>
1016
+ <div class="stat-card">
1017
+ <div class="stat-label">Completed</div>
1018
+ <div class="stat-value success">{metrics['builds_completed']}</div>
1019
+ </div>
1020
+ <div class="stat-card">
1021
+ <div class="stat-label">Failed</div>
1022
+ <div class="stat-value failed">{metrics['builds_failed']}</div>
1023
+ </div>
1024
+ <div class="stat-card">
1025
+ <div class="stat-label">Success Rate</div>
1026
+ <div class="stat-value">{metrics['success_rate']*100:.0f}%</div>
1027
+ </div>
1028
+ """
1029
+
1030
+
1031
+ def render_current_build_html() -> str:
1032
+ """Render current build status."""
1033
+ current = state.current_build
1034
+ if not current:
1035
+ return '<div class="empty">No active build</div>'
1036
+
1037
+ elapsed = ""
1038
+ if current.started_at:
1039
+ start = datetime.fromisoformat(current.started_at)
1040
+ elapsed = f"{(datetime.now(timezone.utc) - start).total_seconds():.0f}s"
1041
+
1042
+ return f"""
1043
+ <div class="build-item active">
1044
+ <div class="build-status">
1045
+ <span class="dot building"></span>
1046
+ <span class="build-id">{current.id}</span>
1047
+ </div>
1048
+ <div class="build-info">
1049
+ <div class="build-image">{current.config.image_name}:{current.config.tags[0]}</div>
1050
+ <div class="build-meta">
1051
+ <span>{current.config.branch}</span>
1052
+ <span>{current.config.trigger}</span>
1053
+ {f'<span>{elapsed}</span>' if elapsed else ''}
1054
+ </div>
1055
+ </div>
1056
+ <button class="btn btn-sm btn-danger" hx-post="/api/build/{current.id}/cancel" hx-swap="none">Cancel</button>
1057
+ </div>
1058
+ """
1059
+
1060
+
1061
+ def render_history_html() -> str:
1062
+ """Render build history."""
1063
+ history = state.get_history(8)
1064
+ if not history:
1065
+ return '<div class="empty">No builds yet</div>'
1066
+
1067
+ html = ""
1068
+ for build in history:
1069
+ status = build["status"]
1070
+ status_class = "success" if status == "success" else "failed" if status in ("failed", "timeout", "cancelled") else ""
1071
+ duration = f'{build["duration_seconds"]:.1f}s' if build.get("duration_seconds") else "-"
1072
+ image_short = build["image"].split("/")[-1] if build.get("image") else "-"
1073
+
1074
+ html += f"""
1075
+ <div class="build-item">
1076
+ <div class="build-status">
1077
+ <span class="badge {status_class}">{status}</span>
1078
+ <span class="build-id">{build['id']}</span>
1079
+ </div>
1080
+ <div class="build-info">
1081
+ <div class="build-image">{image_short}</div>
1082
+ <div class="build-meta">
1083
+ <span>{build.get('branch', '-')}</span>
1084
+ <span>{build.get('trigger', '-')}</span>
1085
+ <span>{duration}</span>
1086
+ </div>
1087
+ </div>
1088
+ </div>
1089
+ """
1090
+ return html
1091
+
1092
+
1093
+ def render_logs_html() -> str:
1094
+ """Render logs."""
1095
+ logs = state.logs
1096
+ return "".join(f'<div class="log-line">{line}</div>' for line in logs[-60:])
1097
+
1098
+
1099
+ @app.route("/")
1100
+ def index():
1101
+ return render_template_string(HTML_TEMPLATE,
1102
+ config=config,
1103
+ stats_html=render_stats_html(),
1104
+ current_html=render_current_build_html(),
1105
+ history_html=render_history_html(),
1106
+ logs_html=render_logs_html(),
1107
+ )
1108
+
1109
+
1110
+ @app.route("/stats-partial")
1111
+ def stats_partial():
1112
+ return render_stats_html()
1113
+
1114
+
1115
+ @app.route("/current-partial")
1116
+ def current_partial():
1117
+ return render_current_build_html()
1118
+
1119
+
1120
+ @app.route("/history-partial")
1121
+ def history_partial():
1122
+ return render_history_html()
1123
+
1124
+
1125
+ @app.route("/logs-partial")
1126
+ def logs_partial():
1127
+ return render_logs_html()
1128
+
1129
+
1130
  HTML_TEMPLATE = """
1131
  <!DOCTYPE html>
1132
  <html lang="en">
 
1145
  --accent: #f59e0b;
1146
  --green: #4ade80;
1147
  --red: #f87171;
1148
+ --blue: #60a5fa;
1149
  }
1150
  * { box-sizing: border-box; margin: 0; padding: 0; }
1151
  body {
 
1153
  background: var(--bg);
1154
  color: var(--text);
1155
  min-height: 100vh;
 
 
1156
  }
1157
+ .main { max-width: 1200px; margin: 0 auto; padding: 2rem; }
1158
+
1159
+ /* Header */
1160
  .header { margin-bottom: 2rem; }
1161
  .header h1 { font-size: 1.5rem; font-weight: 600; letter-spacing: -0.025em; }
1162
+ .header-meta {
1163
+ display: flex;
1164
+ gap: 1.5rem;
1165
+ margin-top: 0.5rem;
1166
+ font-size: 0.8125rem;
1167
+ color: var(--text-muted);
1168
+ }
1169
+ .header-meta a { color: var(--accent); text-decoration: none; }
1170
+ .header-meta a:hover { text-decoration: underline; }
1171
+
1172
+ /* Stats */
1173
+ .stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin-bottom: 2rem; }
1174
+ .stat-card {
1175
+ background: var(--surface);
1176
+ border: 1px solid var(--border);
1177
+ border-radius: 0.75rem;
1178
+ padding: 1.25rem;
1179
+ }
1180
+ .stat-label {
1181
+ font-size: 0.75rem;
1182
+ color: var(--text-muted);
1183
+ text-transform: uppercase;
1184
+ letter-spacing: 0.05em;
1185
+ margin-bottom: 0.5rem;
1186
+ }
1187
+ .stat-value {
1188
+ font-size: 1.75rem;
1189
+ font-weight: 600;
1190
+ font-variant-numeric: tabular-nums;
1191
+ }
1192
+ .stat-value.success { color: var(--green); }
1193
+ .stat-value.failed { color: var(--red); }
1194
 
1195
+ /* Panels */
1196
+ .panels { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem; }
1197
+ .panel {
1198
  background: var(--surface);
1199
  border: 1px solid var(--border);
1200
  border-radius: 0.75rem;
1201
+ overflow: hidden;
 
1202
  }
1203
+ .panel.full { grid-column: span 2; }
1204
+ .panel-header {
1205
  display: flex;
1206
  justify-content: space-between;
1207
  align-items: center;
1208
+ padding: 1rem 1.25rem;
1209
+ border-bottom: 1px solid var(--border);
1210
  }
1211
+ .panel-title { font-size: 0.875rem; font-weight: 500; }
1212
+ .panel-body { padding: 1rem 1.25rem; max-height: 320px; overflow-y: auto; }
1213
+
1214
+ /* Buttons */
1215
+ .btn {
1216
+ background: var(--accent);
1217
+ color: var(--bg);
1218
+ border: none;
1219
+ padding: 0.5rem 1rem;
1220
+ border-radius: 0.5rem;
1221
+ font-size: 0.8125rem;
1222
+ font-weight: 500;
1223
+ cursor: pointer;
1224
+ transition: opacity 0.15s;
1225
+ }
1226
+ .btn:hover { opacity: 0.9; }
1227
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
1228
+ .btn-sm { padding: 0.25rem 0.75rem; font-size: 0.75rem; }
1229
+ .btn-danger { background: var(--red); }
1230
+ .btn-ghost {
1231
+ background: transparent;
1232
+ color: var(--text-muted);
1233
+ border: 1px solid var(--border);
1234
+ }
1235
+ .btn-ghost:hover { background: var(--border); color: var(--text); }
1236
+
1237
+ /* Dot */
1238
  .dot {
1239
+ width: 8px;
1240
+ height: 8px;
1241
  border-radius: 50%;
1242
  flex-shrink: 0;
1243
  }
1244
  .dot.idle { background: var(--text-muted); }
1245
+ .dot.building { background: var(--accent); animation: pulse 2s infinite; }
1246
+ .dot.success { background: var(--green); }
1247
+ .dot.failed { background: var(--red); }
1248
  @keyframes pulse {
1249
+ 0%, 100% { opacity: 1; }
1250
+ 50% { opacity: 0.5; }
1251
  }
 
1252
 
1253
+ /* Build items */
1254
+ .build-item {
1255
+ display: flex;
1256
+ align-items: center;
1257
+ gap: 1rem;
1258
+ padding: 0.75rem 0;
1259
+ border-bottom: 1px solid var(--border);
 
 
 
 
 
 
1260
  }
1261
+ .build-item:last-child { border-bottom: none; }
1262
+ .build-item.active { background: rgba(245, 158, 11, 0.05); margin: -0.5rem -0.25rem; padding: 0.75rem; border-radius: 0.5rem; }
1263
+ .build-status { display: flex; align-items: center; gap: 0.5rem; min-width: 100px; }
1264
+ .build-id { font-family: ui-monospace, monospace; font-size: 0.75rem; color: var(--text-muted); }
1265
+ .build-info { flex: 1; min-width: 0; }
1266
+ .build-image { font-size: 0.875rem; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1267
+ .build-meta { display: flex; gap: 1rem; font-size: 0.75rem; color: var(--text-muted); margin-top: 0.125rem; }
1268
+
1269
+ /* Badge */
1270
+ .badge {
1271
+ font-size: 0.6875rem;
1272
+ font-weight: 600;
1273
+ text-transform: uppercase;
1274
+ letter-spacing: 0.05em;
1275
+ padding: 0.125rem 0.5rem;
1276
+ border-radius: 0.25rem;
1277
+ background: var(--border);
1278
+ color: var(--text-muted);
1279
+ }
1280
+ .badge.success { background: rgba(74, 222, 128, 0.15); color: var(--green); }
1281
+ .badge.failed { background: rgba(248, 113, 113, 0.15); color: var(--red); }
1282
+
1283
+ /* Form */
1284
+ .form-grid { display: grid; gap: 1rem; }
1285
+ .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
1286
  .form-group label {
1287
  display: block;
 
 
1288
  font-size: 0.75rem;
1289
+ color: var(--text-muted);
1290
  text-transform: uppercase;
1291
+ letter-spacing: 0.05em;
1292
+ margin-bottom: 0.375rem;
1293
  }
1294
  .form-group input {
1295
  width: 100%;
 
1298
  border: 1px solid var(--border);
1299
  border-radius: 0.5rem;
1300
  color: var(--text);
1301
+ font-family: ui-monospace, monospace;
1302
  font-size: 0.875rem;
 
 
1303
  }
1304
  .form-group input:focus {
1305
  outline: none;
1306
  border-color: var(--accent);
1307
  }
1308
+ .form-group input::placeholder { color: var(--text-muted); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1309
 
1310
+ /* Logs */
1311
  .logs-panel {
1312
  background: var(--surface);
1313
  border: 1px solid var(--border);
 
1328
  font-size: 0.75rem;
1329
  line-height: 1.5;
1330
  padding: 1rem;
1331
+ max-height: 220px;
1332
  overflow-y: auto;
1333
  background: var(--bg);
1334
  }
1335
+ .log-line { color: var(--text-muted); }
1336
  .log-line:hover { color: var(--text); }
1337
 
1338
+ .empty { color: var(--text-muted); font-size: 0.875rem; padding: 1rem 0; text-align: center; }
1339
+
1340
+ @media (max-width: 768px) {
1341
+ .stats { grid-template-columns: repeat(2, 1fr); }
1342
+ .panels { grid-template-columns: 1fr; }
1343
+ .panel.full { grid-column: span 1; }
1344
+ .form-row { grid-template-columns: 1fr; }
1345
  }
1346
  </style>
1347
  </head>
 
1350
  <div class="header">
1351
  <h1>Builder</h1>
1352
  <div class="header-meta">
1353
+ <span>{{ config.runner_id }}</span>
1354
+ <span>{{ config.registry_url }}</span>
1355
+ <a href="/badge">badge</a>
1356
+ <a href="/api/metrics">metrics</a>
1357
  </div>
1358
  </div>
1359
 
1360
+ <div class="stats" id="stats" hx-get="/stats-partial" hx-trigger="every 3s" hx-swap="innerHTML">
1361
+ {{ stats_html | safe }}
1362
+ </div>
1363
+
1364
+ <div class="panels">
1365
+ <div class="panel">
1366
+ <div class="panel-header">
1367
+ <span class="panel-title">Current Build</span>
1368
  </div>
1369
+ <div class="panel-body" id="current" hx-get="/current-partial" hx-trigger="every 2s" hx-swap="innerHTML">
1370
+ {{ current_html | safe }}
 
 
 
1371
  </div>
1372
+ </div>
1373
+
1374
+ <div class="panel">
1375
+ <div class="panel-header">
1376
+ <span class="panel-title">Build History</span>
1377
  </div>
1378
+ <div class="panel-body" id="history" hx-get="/history-partial" hx-trigger="every 5s" hx-swap="innerHTML">
1379
+ {{ history_html | safe }}
 
1380
  </div>
1381
  </div>
 
1382
 
1383
+ <div class="panel full">
1384
+ <div class="panel-header">
1385
+ <span class="panel-title">New Build</span>
1386
+ </div>
1387
+ <div class="panel-body">
1388
+ <form hx-post="/api/build" hx-swap="none" class="form-grid">
1389
+ <div class="form-group">
1390
+ <label>Repository URL</label>
1391
+ <input type="text" name="repo_url" placeholder="https://github.com/owner/repo" required>
1392
+ </div>
1393
+ <div class="form-row">
1394
+ <div class="form-group">
1395
+ <label>Image Name</label>
1396
+ <input type="text" name="image_name" placeholder="owner/repo" required>
1397
+ </div>
1398
+ <div class="form-group">
1399
+ <label>Branch</label>
1400
+ <input type="text" name="branch" value="main">
1401
+ </div>
1402
+ </div>
1403
+ <div class="form-row">
1404
+ <div class="form-group">
1405
+ <label>Tags (comma-separated)</label>
1406
+ <input type="text" name="tags" value="latest">
1407
+ </div>
1408
+ <div class="form-group">
1409
+ <label>Dockerfile</label>
1410
+ <input type="text" name="dockerfile" value="Dockerfile">
1411
+ </div>
1412
+ </div>
1413
+ <button type="submit" class="btn">Build & Push</button>
1414
+ </form>
1415
  </div>
1416
+ </div>
1417
  </div>
1418
 
1419
  <div class="logs-panel">
1420
  <div class="logs-header">
1421
  <span class="logs-title">Logs</span>
1422
  </div>
1423
+ <div id="logs" class="logs" hx-get="/logs-partial" hx-trigger="every 2s" hx-swap="innerHTML">
1424
+ {{ logs_html | safe }}
1425
  </div>
1426
  </div>
1427
  </div>
 
1430
  """
1431
 
1432
 
1433
+ # =============================================================================
1434
+ # Hivemind Integration
1435
+ # =============================================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1436
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1437
 
1438
+ class HivemindClient:
1439
+ """Client for connecting to the Hivemind controller."""
1440
 
1441
+ def __init__(self, cfg: Config, build_state: BuildState, kaniko_builder: KanikoBuilder) -> None:
1442
+ self.config = cfg
1443
+ self.state = build_state
1444
+ self.builder = kaniko_builder
1445
+ self.controller_url = os.getenv("HIVEMIND_CONTROLLER_URL", "")
1446
+ self.worker_id = cfg.runner_id
1447
+ self.poll_interval = int(os.getenv("HIVEMIND_POLL_INTERVAL", "30"))
1448
+ self.enabled = bool(self.controller_url)
1449
+ self._running = False
1450
 
1451
+ def register(self) -> bool:
1452
+ """Register this builder with the hivemind controller."""
1453
+ if not self.enabled:
1454
+ return False
 
1455
 
1456
+ try:
1457
+ resp = http_requests.post(
1458
+ f"{self.controller_url}/api/register",
1459
+ json={
1460
+ "worker_id": self.worker_id,
1461
+ "name": f"Builder {self.worker_id}",
1462
+ "capabilities": ["build"],
1463
+ },
1464
+ timeout=10,
1465
+ )
1466
+ if resp.ok:
1467
+ self.state.log(f"Registered with hivemind: {self.controller_url}")
1468
+ return True
1469
+ else:
1470
+ self.state.log(f"Hivemind registration failed: {resp.status_code}", level="warn")
1471
+ return False
1472
+ except Exception as e:
1473
+ self.state.log(f"Hivemind registration error: {e}", level="error")
1474
+ return False
1475
 
1476
+ def heartbeat(self, status: str = "idle", work_id: str | None = None, progress: float | None = None) -> None:
1477
+ """Send heartbeat to controller."""
1478
+ if not self.enabled:
1479
+ return
 
1480
 
1481
+ try:
1482
+ http_requests.post(
1483
+ f"{self.controller_url}/api/heartbeat",
1484
+ json={
1485
+ "worker_id": self.worker_id,
1486
+ "status": status,
1487
+ "work_id": work_id,
1488
+ "progress": progress,
1489
+ },
1490
+ timeout=5,
1491
+ )
1492
+ except Exception:
1493
+ pass # Heartbeat failures are not critical
1494
 
1495
+ def get_work(self) -> dict | None:
1496
+ """Poll controller for available work."""
1497
+ if not self.enabled:
1498
+ return None
1499
 
1500
+ try:
1501
+ resp = http_requests.get(
1502
+ f"{self.controller_url}/api/work",
1503
+ params={"worker_id": self.worker_id, "capabilities": "build"},
1504
+ timeout=10,
1505
+ )
1506
+ if resp.ok:
1507
+ data = resp.json()
1508
+ return data.get("work")
1509
+ return None
1510
+ except Exception as e:
1511
+ self.state.log(f"Hivemind work poll error: {e}", level="warn")
1512
+ return None
1513
 
1514
+ def complete_work(self, work_id: str, result: dict | None = None) -> None:
1515
+ """Report work completion to controller."""
1516
+ if not self.enabled:
1517
+ return
 
 
 
 
 
 
1518
 
1519
+ try:
1520
+ http_requests.post(
1521
+ f"{self.controller_url}/api/complete",
1522
+ json={
1523
+ "worker_id": self.worker_id,
1524
+ "work_id": work_id,
1525
+ "result": result or {},
1526
+ },
1527
+ timeout=10,
1528
+ )
1529
+ self.state.log(f"Reported completion: {work_id}")
1530
+ except Exception as e:
1531
+ self.state.log(f"Hivemind complete error: {e}", level="warn")
1532
 
1533
+ def fail_work(self, work_id: str, error: str) -> None:
1534
+ """Report work failure to controller."""
1535
+ if not self.enabled:
1536
+ return
 
 
 
 
1537
 
1538
+ try:
1539
+ http_requests.post(
1540
+ f"{self.controller_url}/api/fail",
1541
+ json={
1542
+ "worker_id": self.worker_id,
1543
+ "work_id": work_id,
1544
+ "error": error,
1545
+ },
1546
+ timeout=10,
1547
+ )
1548
+ self.state.log(f"Reported failure: {work_id}")
1549
+ except Exception as e:
1550
+ self.state.log(f"Hivemind fail error: {e}", level="warn")
1551
 
1552
+ def process_work(self, work: dict) -> bool:
1553
+ """Process a work item from the controller."""
1554
+ work_id = work.get("work_id", "unknown")
1555
+ work_type = work.get("type", "")
1556
 
1557
+ if work_type != "build":
1558
+ self.state.log(f"Unknown work type: {work_type}", level="warn")
1559
+ self.fail_work(work_id, f"Unknown work type: {work_type}")
1560
+ return False
1561
 
1562
+ self.state.log(f"Processing hivemind work: {work_id}")
1563
+ self.heartbeat(status="working", work_id=work_id)
1564
 
1565
+ # Extract build config from work item
1566
+ repo_url = work.get("repo_url", "")
1567
+ image_name = work.get("image_name", "") or work.get("image", "")
1568
+ branch = work.get("branch", "main")
1569
+ tags = work.get("tags", ["latest"])
 
 
 
 
 
1570
 
1571
+ if not repo_url or not image_name:
1572
+ self.fail_work(work_id, "Missing repo_url or image_name")
1573
+ return False
 
 
1574
 
1575
+ build_config = BuildConfig(
1576
+ repo_url=repo_url,
1577
+ image_name=image_name,
1578
+ branch=branch,
1579
+ tags=tags if isinstance(tags, list) else [tags],
1580
+ dockerfile=work.get("dockerfile", "Dockerfile"),
1581
+ context_path=work.get("context_path", "."),
1582
+ build_args=work.get("build_args", {}),
1583
+ github_token=work.get("github_token"),
1584
+ platform=work.get("platform"),
1585
+ trigger="hivemind",
1586
+ )
1587
 
1588
+ build = Build(id=work_id, config=build_config)
1589
+ success = self.builder.build_and_push(build)
 
1590
 
1591
+ if success:
1592
+ self.complete_work(work_id, {
1593
+ "image": f"{self.config.registry_url}/{image_name}",
1594
+ "tags": tags,
1595
+ "duration": build.duration_seconds,
1596
+ })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1597
  else:
1598
+ self.fail_work(work_id, build.error or "Build failed")
1599
 
1600
+ return success
1601
 
1602
+ def work_loop(self) -> None:
1603
+ """Main loop for polling and processing work from hivemind."""
1604
+ self._running = True
1605
+ self.state.log("Hivemind work loop started")
 
 
 
 
1606
 
1607
+ while self._running:
1608
+ try:
1609
+ # Don't poll if already building
1610
+ if self.state.is_building:
1611
+ self.heartbeat(status="working")
1612
+ time.sleep(self.poll_interval)
1613
+ continue
1614
 
1615
+ # Send idle heartbeat
1616
+ self.heartbeat(status="idle")
1617
+
1618
+ # Poll for work
1619
+ work = self.get_work()
1620
+ if work:
1621
+ self.process_work(work)
1622
+ else:
1623
+ time.sleep(self.poll_interval)
1624
 
1625
+ except Exception as e:
1626
+ self.state.log(f"Hivemind loop error: {e}", level="error")
1627
+ time.sleep(self.poll_interval)
 
 
1628
 
1629
+ def stop(self) -> None:
1630
+ """Stop the work loop."""
1631
+ self._running = False
 
 
 
 
1632
 
 
 
1633
 
1634
+ # Initialize hivemind client
1635
+ hivemind = HivemindClient(config, state, builder)
1636
 
1637
 
1638
  # =============================================================================
 
1640
  # =============================================================================
1641
 
1642
  def startup():
1643
+ init_telemetry()
 
1644
 
1645
+ state.log(f"HF Builder starting ({config.runner_id})")
1646
+ state.log(f"Registry: {config.registry_url}")
1647
 
1648
+ if config.registry_user:
1649
+ builder.setup_registry_auth()
 
 
 
 
 
 
 
 
 
1650
 
1651
+ if config.slack_webhook_url:
1652
+ state.log("Slack notifications enabled")
1653
+ if config.discord_webhook_url:
1654
+ state.log("Discord notifications enabled")
1655
 
1656
+ # Hivemind integration
1657
+ if hivemind.enabled:
1658
+ state.log(f"Hivemind controller: {hivemind.controller_url}")
1659
+ if hivemind.register():
1660
+ threading.Thread(target=hivemind.work_loop, daemon=True).start()
1661
 
1662
+ state.set_ready(True)
1663
+ state.log("Ready")
1664
 
1665
 
1666
  threading.Thread(target=startup, daemon=True).start()
requirements.txt CHANGED
@@ -1,5 +1,8 @@
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
 
 
 
 
 
 
1
  flask>=3.0.0
 
 
2
  gitpython>=3.1.0
3
+ requests>=2.31.0
4
+
5
+ # Optional: OpenTelemetry (uncomment to enable)
6
+ # opentelemetry-api>=1.20.0
7
+ # opentelemetry-sdk>=1.20.0
8
+ # opentelemetry-exporter-otlp>=1.20.0