Spaces:
Running
Running
Claude
commited on
Commit
·
8029b17
1
Parent(s):
9e22678
feat(builder): add HTMX dashboard, hivemind integration, and robust enum parsing
Browse files- README.md +217 -73
- Taskfile.yml +210 -0
- app.py +1400 -862
- requirements.txt +6 -3
README.md
CHANGED
|
@@ -1,108 +1,252 @@
|
|
| 1 |
---
|
| 2 |
-
title: Builder
|
| 3 |
sdk: docker
|
| 4 |
pinned: false
|
| 5 |
---
|
| 6 |
|
| 7 |
-
#
|
| 8 |
|
| 9 |
-
Daemonless Docker image builder
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
## Configuration
|
| 12 |
|
| 13 |
-
|
| 14 |
|
| 15 |
-
| Secret | Required | Description |
|
| 16 |
-
|
| 17 |
-
| `
|
| 18 |
-
| `
|
| 19 |
-
| `
|
| 20 |
-
| `
|
| 21 |
-
| `
|
| 22 |
-
| `
|
| 23 |
-
| `
|
|
|
|
| 24 |
|
| 25 |
-
|
| 26 |
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
-
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
|
| 37 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
```
|
| 47 |
|
| 48 |
-
|
| 49 |
|
| 50 |
-
|
| 51 |
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
|
| 58 |
-
|
| 59 |
|
| 60 |
-
|
| 61 |
|
| 62 |
-
|
| 63 |
|
| 64 |
-
```
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
"tags": ["latest"],
|
| 72 |
-
"
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
```
|
| 75 |
|
| 76 |
-
|
| 77 |
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
-
```
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
```
|
| 88 |
|
| 89 |
-
## Endpoints
|
| 90 |
|
| 91 |
| Endpoint | Method | Description |
|
| 92 |
|----------|--------|-------------|
|
| 93 |
-
| `/` | GET | Web
|
| 94 |
-
| `/
|
| 95 |
-
| `/
|
| 96 |
-
| `/
|
| 97 |
-
| `/
|
| 98 |
-
| `/api/
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+

|
| 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
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
|
| 85 |
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
|
| 96 |
# =============================================================================
|
| 97 |
-
#
|
| 98 |
# =============================================================================
|
| 99 |
|
| 100 |
-
def init_redis():
|
| 101 |
-
"""Initialize Redis for build queue."""
|
| 102 |
-
global redis_client
|
| 103 |
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 130 |
-
|
| 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
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
try:
|
| 147 |
-
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
except Exception as e:
|
| 150 |
-
log(f"
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
|
| 154 |
# =============================================================================
|
| 155 |
-
#
|
| 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 |
-
|
| 165 |
-
|
| 166 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
|
| 168 |
-
import base64
|
| 169 |
-
auth = base64.b64encode(f"{REGISTRY_USER}:{REGISTRY_PASSWORD}".encode()).decode()
|
| 170 |
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
|
|
|
|
|
|
| 177 |
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
|
| 182 |
-
|
| 183 |
-
|
|
|
|
|
|
|
|
|
|
| 184 |
|
| 185 |
|
| 186 |
# =============================================================================
|
| 187 |
-
#
|
| 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 |
-
|
| 196 |
-
|
| 197 |
-
|
|
|
|
|
|
|
| 198 |
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
else:
|
| 204 |
-
log(f"Cloning {repo_url} ({branch})...")
|
| 205 |
|
| 206 |
-
|
| 207 |
-
|
| 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 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
) -> bool:
|
| 228 |
-
"""Build Docker image using Kaniko and push to registry.
|
| 229 |
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
|
| 234 |
-
|
| 235 |
-
|
|
|
|
|
|
|
| 236 |
|
| 237 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
"image": full_image,
|
| 248 |
-
"tags": tags,
|
| 249 |
-
"started_at": datetime.now(timezone.utc).isoformat(),
|
| 250 |
-
}
|
| 251 |
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
)
|
| 263 |
-
|
| 264 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
return False
|
| 266 |
-
log(f"✓ Tar created")
|
| 267 |
-
except Exception as e:
|
| 268 |
-
log(f"✗ Tar error: {e}")
|
| 269 |
-
return False
|
| 270 |
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 298 |
|
| 299 |
-
try:
|
| 300 |
-
process = subprocess.Popen(
|
| 301 |
-
cmd,
|
| 302 |
-
stdout=subprocess.PIPE,
|
| 303 |
-
stderr=subprocess.STDOUT,
|
| 304 |
-
text=True,
|
| 305 |
-
)
|
| 306 |
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 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 |
-
|
| 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 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
"tags": tags,
|
| 380 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
}
|
| 382 |
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 388 |
]
|
|
|
|
| 389 |
|
| 390 |
-
if context_subpath:
|
| 391 |
-
cmd.append(f"--context-sub-path={context_subpath}")
|
| 392 |
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 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 |
-
|
| 415 |
-
|
| 416 |
-
|
| 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 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 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 |
-
|
| 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 |
-
|
| 513 |
-
|
| 514 |
-
|
| 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 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
image_name=image_name,
|
| 523 |
-
dockerfile=dockerfile,
|
| 524 |
-
tags=tags,
|
| 525 |
-
build_args=build_args,
|
| 526 |
-
)
|
| 527 |
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
if tar_path and os.path.exists(tar_path):
|
| 533 |
-
os.remove(tar_path)
|
| 534 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 535 |
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
|
|
|
| 539 |
|
| 540 |
-
def queue_worker():
|
| 541 |
-
"""Process builds from the queue."""
|
| 542 |
-
log("Queue worker started")
|
| 543 |
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
|
|
|
|
|
|
| 550 |
|
| 551 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
|
|
|
|
|
|
| 587 |
.header { margin-bottom: 2rem; }
|
| 588 |
.header h1 { font-size: 1.5rem; font-weight: 600; letter-spacing: -0.025em; }
|
| 589 |
-
.header-meta {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 590 |
|
| 591 |
-
|
|
|
|
|
|
|
| 592 |
background: var(--surface);
|
| 593 |
border: 1px solid var(--border);
|
| 594 |
border-radius: 0.75rem;
|
| 595 |
-
|
| 596 |
-
margin-bottom: 1rem;
|
| 597 |
}
|
| 598 |
-
.
|
|
|
|
| 599 |
display: flex;
|
| 600 |
justify-content: space-between;
|
| 601 |
align-items: center;
|
| 602 |
-
|
|
|
|
| 603 |
}
|
| 604 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 605 |
.dot {
|
| 606 |
-
width:
|
| 607 |
-
height:
|
| 608 |
border-radius: 50%;
|
| 609 |
flex-shrink: 0;
|
| 610 |
}
|
| 611 |
.dot.idle { background: var(--text-muted); }
|
| 612 |
-
.dot.building { background: var(--accent); animation: pulse
|
|
|
|
|
|
|
| 613 |
@keyframes pulse {
|
| 614 |
-
0%, 100% { opacity: 1;
|
| 615 |
-
50% { opacity: 0.5;
|
| 616 |
}
|
| 617 |
-
.status-text { font-size: 0.875rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.025em; }
|
| 618 |
|
| 619 |
-
|
| 620 |
-
.
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 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 |
-
.
|
| 634 |
-
.
|
| 635 |
-
.
|
| 636 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
| 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);
|
| 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:
|
| 699 |
overflow-y: auto;
|
| 700 |
background: var(--bg);
|
| 701 |
}
|
| 702 |
-
.log-line { color: var(--text-muted);
|
| 703 |
.log-line:hover { color: var(--text); }
|
| 704 |
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 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 |
-
|
|
|
|
| 720 |
</div>
|
| 721 |
</div>
|
| 722 |
|
| 723 |
-
<div class="
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
|
|
|
|
|
|
|
|
|
| 728 |
</div>
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
<div class="stat">
|
| 732 |
-
<div class="stat-value success">{{ builds_completed }}</div>
|
| 733 |
-
<div class="stat-label">Completed</div>
|
| 734 |
</div>
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
|
|
|
|
|
|
| 738 |
</div>
|
| 739 |
-
<div class="
|
| 740 |
-
|
| 741 |
-
<div class="stat-label">Active</div>
|
| 742 |
</div>
|
| 743 |
</div>
|
| 744 |
-
</div>
|
| 745 |
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
<
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
<
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
|
|
|
|
|
|
|
|
|
| 775 |
</div>
|
| 776 |
-
</
|
| 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 |
-
{
|
| 785 |
</div>
|
| 786 |
</div>
|
| 787 |
</div>
|
|
@@ -790,305 +1430,209 @@ HTML_TEMPLATE = """
|
|
| 790 |
"""
|
| 791 |
|
| 792 |
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
| 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 |
-
|
| 903 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 904 |
|
| 905 |
-
def
|
| 906 |
-
|
| 907 |
-
|
| 908 |
-
|
| 909 |
-
return True # Allow if no secret configured (not recommended for production)
|
| 910 |
|
| 911 |
-
|
| 912 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 913 |
|
| 914 |
-
|
| 915 |
-
|
| 916 |
-
|
| 917 |
-
|
| 918 |
-
).hexdigest()
|
| 919 |
|
| 920 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 921 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 922 |
|
| 923 |
-
|
| 924 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 925 |
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
| 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
|
| 939 |
-
|
| 940 |
-
|
| 941 |
-
|
| 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 |
-
|
| 949 |
-
|
| 950 |
-
|
|
|
|
| 951 |
|
| 952 |
-
|
| 953 |
-
|
| 954 |
-
|
|
|
|
| 955 |
|
| 956 |
-
|
|
|
|
| 957 |
|
| 958 |
-
|
| 959 |
-
|
| 960 |
-
|
| 961 |
-
|
| 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 |
-
|
| 970 |
-
|
| 971 |
-
|
| 972 |
-
log("✓ Webhook ping received")
|
| 973 |
-
return jsonify({"status": "pong"}), 200
|
| 974 |
|
| 975 |
-
|
| 976 |
-
|
| 977 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 978 |
|
| 979 |
-
|
| 980 |
-
|
| 981 |
-
abort(400, "Missing payload")
|
| 982 |
|
| 983 |
-
|
| 984 |
-
|
| 985 |
-
|
| 986 |
-
|
| 987 |
-
|
| 988 |
-
|
| 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 |
-
|
| 1055 |
|
| 1056 |
-
|
| 1057 |
|
| 1058 |
-
|
| 1059 |
-
"
|
| 1060 |
-
|
| 1061 |
-
"
|
| 1062 |
-
"image": f"{REGISTRY_URL}/{image_name}",
|
| 1063 |
-
"tags": tags,
|
| 1064 |
-
"matching_files": matching_files
|
| 1065 |
-
}), 202
|
| 1066 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1067 |
|
| 1068 |
-
|
| 1069 |
-
|
| 1070 |
-
|
| 1071 |
-
|
| 1072 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1073 |
|
| 1074 |
-
|
| 1075 |
-
|
| 1076 |
-
|
| 1077 |
-
branch = payload.get("branch", "main")
|
| 1078 |
-
image_name = payload.get("image_name", DEFAULT_IMAGE_NAME or "jonathanagustin/lawforge")
|
| 1079 |
|
| 1080 |
-
|
| 1081 |
-
"
|
| 1082 |
-
|
| 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 |
-
|
|
|
|
| 1092 |
|
| 1093 |
|
| 1094 |
# =============================================================================
|
|
@@ -1096,33 +1640,27 @@ def test_webhook():
|
|
| 1096 |
# =============================================================================
|
| 1097 |
|
| 1098 |
def startup():
|
| 1099 |
-
|
| 1100 |
-
log(f"Registry: {REGISTRY_URL}")
|
| 1101 |
|
| 1102 |
-
|
| 1103 |
-
|
| 1104 |
|
| 1105 |
-
|
| 1106 |
-
|
| 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
|
| 1118 |
-
log(
|
|
|
|
|
|
|
| 1119 |
|
| 1120 |
-
#
|
| 1121 |
-
if
|
| 1122 |
-
|
|
|
|
|
|
|
| 1123 |
|
| 1124 |
-
|
| 1125 |
-
log(
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|