Buildbot shield generator
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* Buildbot 0.9.x shield generator | |
* Copyright (c) 2016 Leandro Pereira <leandro@tia.mat.br> | |
* | |
* Permission is hereby granted, free of charge, to any person obtaining a copy of | |
* this software and associated documentation files (the "Software"), to deal in the | |
* Software without restriction, including without limitation the rights to use, copy, | |
* modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, | |
* and to permit persons to whom the Software is furnished to do so, subject to the | |
* following conditions: | |
* | |
* The above copyright notice and this permission notice shall be included in all | |
* copies or substantial portions of the Software. | |
* | |
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, | |
* INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A | |
* PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT | |
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION | |
* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE | |
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |
*/ | |
package main | |
import ( | |
"database/sql" | |
"encoding/json" | |
"fmt" | |
"github.com/golang/freetype" | |
_ "github.com/mattn/go-sqlite3" | |
"golang.org/x/image/math/fixed" | |
"io/ioutil" | |
"log" | |
"math/rand" | |
"net/http" | |
"strconv" | |
"strings" | |
"text/template" | |
"time" | |
) | |
var database *sql.DB | |
var badgeTemplate *template.Template | |
func fetch(url string) ([]byte, error) { | |
log.Printf("Fetching %s", url) | |
resp, err := http.Get(url) | |
if err != nil { | |
log.Printf("Error while fetching %s: %v", url, err) | |
return nil, err | |
} | |
body, err := ioutil.ReadAll(resp.Body) | |
resp.Body.Close() | |
if err != nil { | |
log.Printf("Error while reading %s body: %v", url, err) | |
return nil, err | |
} | |
log.Printf("Got %d bytes of response", len(body)) | |
return body, nil | |
} | |
type BuildbotBuilds struct { | |
Builds []BuildbotBuildsBuild `json:"builds"` | |
} | |
type BuildbotBuildsBuild struct { | |
BuilderId int `json:"builderid"` | |
BuildId int `json:"buildid"` | |
BuildRequestId int `json:"buildrequestid"` | |
Complete bool `json:"complete"` | |
CompleteAt int64 `json:"complete_at"` | |
StartedAt int64 `json:"started_at"` | |
MasterId int `json:"masterid"` | |
Number int `json:"number"` | |
Results int `json:"results"` | |
WorkerId int `json:"workerid"` | |
StateString string `json:"state_string"` | |
} | |
func updateBuilderStatus(botId, builderName string, build BuildbotBuildsBuild) error { | |
stmt, err := database.Prepare("update builders set status=?, last_update=strftime('%s', 'now') where bot_id=? and name=?") | |
if err != nil { | |
return fmt.Errorf("Could not prepare status update stmt: %v", err) | |
} | |
defer stmt.Close() | |
_, err = stmt.Exec(resultsToString(build.Results), botId, builderName) | |
if err != nil { | |
return fmt.Errorf("Could not execute statement: %v", err) | |
} | |
return nil | |
} | |
func resultsToString(results int) string { | |
switch results { | |
case 0: | |
return "success" | |
case 1: | |
return "warnings" | |
case 2: | |
return "failure" | |
case 3: | |
return "skipped" | |
case 4: | |
return "exception" | |
case 5: | |
return "cancelled" | |
default: | |
return "unknown" | |
} | |
} | |
func getBuilderId(botId, builderName string) int { | |
stmt, err := database.Prepare("select id from builders where bot_id=? and name=?") | |
if err != nil { | |
log.Printf("Could not prepare statement: %v", err) | |
return -1 | |
} | |
defer stmt.Close() | |
var id int | |
err = stmt.QueryRow(botId, builderName).Scan(&id) | |
if err != nil { | |
log.Printf("Could not query: %v", err) | |
return -1 | |
} | |
return id | |
} | |
func updateStatus(botId, builderName string) (string, error) { | |
base := findBaseById(botId) | |
if base == "" { | |
return "", fmt.Errorf("Could not find bot ID: %s", botId) | |
} | |
b, err := fetch(endpoint(base, "/api/v2/builds?complete=true")) | |
if err != nil { | |
return "", fmt.Errorf("Could not fetch build status: %v", err) | |
} | |
builds := BuildbotBuilds{} | |
err = json.Unmarshal(b, &builds) | |
if err != nil { | |
return "", fmt.Errorf("Could not unmarshal JSON: %v", err) | |
} | |
builderId := getBuilderId(botId, builderName) | |
if builderId < 0 { | |
return "", fmt.Errorf("Could not find builder") | |
} | |
for _, build := range builds.Builds { | |
if build.BuilderId != builderId { | |
continue | |
} | |
err := updateBuilderStatus(botId, builderName, build) | |
if err != nil { | |
return "exception", err | |
} | |
return resultsToString(build.Results), nil | |
} | |
return "unknown", nil | |
} | |
var ftCtx *freetype.Context | |
func measureTextWidth(s string) int { | |
if ftCtx == nil { | |
fontData, err := ioutil.ReadFile("./DejaVuSans.ttf") | |
if err != nil { | |
panic(fmt.Sprintf("Could not load file: %v", err)) | |
} | |
font, err := freetype.ParseFont(fontData) | |
if err != nil { | |
panic(fmt.Sprintf("Could not parse font: %v", err)) | |
} | |
ftCtx = freetype.NewContext() | |
ftCtx.SetFont(font) | |
ftCtx.SetDPI(72) | |
ftCtx.SetFontSize(11) | |
} | |
size, err := ftCtx.DrawString(s, fixed.Point26_6{0, 0}) | |
if err != nil { | |
log.Printf("Could not draw string using font: %v", err) | |
return 6 * len(s) | |
} | |
return size.X.Round() | |
} | |
func writeBadge(w http.ResponseWriter, label, status string) { | |
type Badge struct { | |
Label, Status, Color string | |
Width, HalfWidth int | |
} | |
w.Header().Set("Content-Type", "image/svg+xml") | |
var color string | |
switch status { | |
case "success": | |
color = "#4c1" | |
case "warnings": | |
color = "#f40" | |
case "failure": | |
color = "#c41" | |
case "skipped": | |
color = "#14c" | |
case "exception": | |
color = "#c1c" | |
case "cancelled": | |
color = "#4cc" | |
default: | |
color = "#cc1" | |
} | |
labelWidth := measureTextWidth(label) | |
width := labelWidth + measureTextWidth(status) + 4*2*2 | |
halfWidth := labelWidth + 4*2 | |
badgeTemplate.Execute(w, Badge{label, status, color, width, halfWidth}) | |
} | |
func parseTimeUnix(s string) time.Time { | |
val, err := strconv.ParseInt(s, 10, 64) | |
if err != nil { | |
val = 0 | |
} | |
return time.Unix(val, 0) | |
} | |
func imgHandler(w http.ResponseWriter, r *http.Request) { | |
elements := strings.Split(r.URL.Path, "/") | |
if len(elements) != 4 { | |
writeBadge(w, "error", "invalid-request") | |
return | |
} | |
botId := elements[2] | |
if len(botId) != 6 { | |
writeBadge(w, "error", "invalid-bot") | |
return | |
} | |
builderName := elements[3] | |
stmt, err := database.Prepare("select status,last_update from builders where bot_id=? and name=?") | |
if err != nil { | |
writeBadge(w, "error", "internal-stmt") | |
log.Printf("Could not prepare stmt: %v", err) | |
return | |
} | |
defer stmt.Close() | |
var status string | |
var lastUpdateStr string | |
err = stmt.QueryRow(botId, builderName).Scan(&status, &lastUpdateStr) | |
if err != nil { | |
writeBadge(w, "error", "query") | |
log.Printf("Could not query: %v", err) | |
return | |
} | |
lastUpdate := parseTimeUnix(lastUpdateStr) | |
if lastUpdate.IsZero() || time.Now().Sub(lastUpdate) > 30*time.Minute { | |
status, err = updateStatus(botId, builderName) | |
if err != nil { | |
writeBadge(w, "error", "update") | |
log.Printf("Couldn't update status for builder <%s,%s>: %v", botId, builderName, err) | |
} | |
if lastUpdate.IsZero() { | |
status = "unknown" | |
} | |
} else { | |
log.Printf("Status <%s,%s> still fresh <%v>", botId, builderName, time.Now().Sub(lastUpdate)) | |
} | |
writeBadge(w, builderName, status) | |
} | |
type RegResponse struct { | |
Id string `json:"id"` | |
Error string `json:"error"` | |
} | |
type BuildbotBuilders struct { | |
Builders []BuildbotBuildersBuilder `json:"builders"` | |
} | |
type BuildbotBuildersBuilder struct { | |
BuilderId int `json:"builderid"` | |
Description string `json:"description"` | |
Name string `json:"name"` | |
} | |
func findIdByBase(base string) string { | |
stmt, err := database.Prepare("select id from bots where base=?") | |
if err != nil { | |
log.Printf("Could not prepare SQL: %v", err) | |
return "" | |
} | |
defer stmt.Close() | |
var id string | |
err = stmt.QueryRow(base).Scan(&id) | |
if err != nil { | |
log.Printf("Could not query SQL db: %v", err) | |
return "" | |
} | |
return id | |
} | |
func findBaseById(id string) string { | |
stmt, err := database.Prepare("select base from bots where id=?") | |
if err != nil { | |
log.Printf("Could not prepare SQL: %v", err) | |
return "" | |
} | |
defer stmt.Close() | |
var base string | |
err = stmt.QueryRow(id).Scan(&base) | |
if err != nil { | |
log.Printf("Could not query SQL db: %v", err) | |
return "" | |
} | |
return base | |
} | |
func endpoint(url, endpoint string) string { | |
return strings.TrimSuffix(url, "/") + endpoint | |
} | |
func regHandler(w http.ResponseWriter, r *http.Request) { | |
w.Header().Set("Content-Type", "application/json") | |
encoder := json.NewEncoder(w) | |
base := r.FormValue("base") | |
if base == "" { | |
encoder.Encode(RegResponse{"", "invalid-api-use"}) | |
return | |
} | |
baseId := findIdByBase(base) | |
if baseId != "" { | |
encoder.Encode(RegResponse{baseId, "success"}) | |
return | |
} | |
b, err := fetch(endpoint(base, "/api/v2/builders")) | |
if err != nil { | |
encoder.Encode(RegResponse{"", "not-buildbot-v2"}) | |
return | |
} | |
builders := BuildbotBuilders{} | |
err = json.Unmarshal(b, &builders) | |
if err != nil { | |
encoder.Encode(RegResponse{"", "not-buildbot-v2"}) | |
return | |
} | |
id, err := registerBase(base, builders) | |
if err != nil { | |
encoder.Encode(RegResponse{"", "could-not-register"}) | |
return | |
} | |
encoder.Encode(RegResponse{id, "success"}) | |
} | |
func registerBase(base string, builders BuildbotBuilders) (string, error) { | |
for { | |
id := generateNewId() | |
tx, err := database.Begin() | |
if err != nil { | |
log.Printf("Could not create transaction: %v", err) | |
return "", err | |
} | |
botStmt, err := tx.Prepare("insert into bots(id, base) values (?, ?)") | |
if err != nil { | |
log.Printf("Could not prepare bot insertion stmt: %v", err) | |
return "", err | |
} | |
defer botStmt.Close() | |
_, err = botStmt.Exec(id, base) | |
if err != nil { | |
log.Printf("Could not register %s: %v", id, err) | |
continue | |
} | |
builderStmt, err := tx.Prepare("insert into builders(bot_id, name, id) values (?, ?, ?)") | |
if err != nil { | |
log.Printf("Could not prepare builder insertion stmt: %v", err) | |
return "", err | |
} | |
defer builderStmt.Close() | |
for _, builder := range builders.Builders { | |
_, err = builderStmt.Exec(id, builder.Name, builder.BuilderId) | |
if err != nil { | |
log.Printf("Could not insert builder: %v", err) | |
return "", err | |
} | |
} | |
tx.Commit() | |
return id, nil | |
} | |
} | |
var runes = []rune("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") | |
func generateNewId() string { | |
b := make([]rune, 6) | |
for i := range b { | |
b[i] = runes[rand.Intn(len(runes))] | |
} | |
return string(b) | |
} | |
func main() { | |
badgeTemplate = template.Must(template.New("badge").Parse(`<svg xmlns="http://www.w3.org/2000/svg" width="{{.Width}}" height="20"> | |
<linearGradient id="b" x2="0" y2="100%"> | |
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/> | |
<stop offset="1" stop-opacity=".1"/> | |
</linearGradient> | |
<mask id="a"> | |
<rect width="{{.Width}}" height="20" rx="3" fill="#fff"/> | |
</mask> | |
<g mask="url(#a)"> | |
<path fill="#555" d="M0 0h{{.HalfWidth}}v20H0z"/> | |
<path fill="{{.Color}}" d="M{{.HalfWidth}} 0h{{.Width}}v20H{{.HalfWidth}}z"/> | |
<path fill="url(#b)" d="M0 0h{{.Width}}v20H0z"/> | |
</g> | |
<g fill="#fff" text-anchor="left" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11"> | |
<text x="4" y="15" fill="#010101" fill-opacity=".3">{{.Label}}</text> | |
<text x="4" y="14">{{.Label}}</text> | |
<text x="{{.HalfWidth}}" dx="4" y="15" fill="#010101" fill-opacity=".3">{{.Status}}</text> | |
<text x="{{.HalfWidth}}" dx="4" y="14">{{.Status}}</text> | |
</g> | |
</svg>`)) | |
log.Printf("Initializing random seed") | |
rand.Seed(time.Now().UnixNano()) | |
log.Printf("Opening database") | |
var err error | |
database, err = sql.Open("sqlite3", "./bbshield.db") | |
if err != nil { | |
log.Fatal(err) | |
} | |
defer database.Close() | |
err = database.Ping() | |
if err != nil { | |
log.Fatal(err) | |
} | |
log.Printf("Registering handlers") | |
http.HandleFunc("/reg", regHandler) | |
http.HandleFunc("/img/", imgHandler) | |
http.Handle("/", http.FileServer(http.Dir("./public"))) | |
log.Printf("Listening") | |
http.ListenAndServe("127.0.0.1:9999", nil) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
CREATE TABLE bots (id string unique primary key, base string); | |
CREATE TABLE builders (bot_id string, name string, last_update timedate default 0, status string default 'unknown', id int, foreign key(bot_id) references bots(id)); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<html> <head> <title>Buildbot 0.9.x status shield service</title> | |
</head> | |
<body> <h1>Buildbot 0.9.x status shield service</h1> <h2>API</h2> | |
<h3>Register</h3> <blockquote> <pre>$ curl https://shield.lwan.ws/reg?base=https://buildbot-www-instance/</pre> | |
</blockquote> | |
<p>Response is a JSON object with two string fields: <tt>id</tt> and <tt>error</tt>. If <tt>error</tt> is <tt>"success"</tt>, the | |
value of the <tt>id</tt> can be used with the <tt>/img</tt> endpoint.</p> <p>Please register just once. Registering multiple times for the | |
same buildbot instance will return the same ID.</p> | |
<h3>Generating shields</h3> | |
<p>Just obtain <tt>https://shield.lwan.ws/img/<b>$id</b>/<b>$builder-name</b></tt>, | |
where <tt>builder-name</tt> is the builder name that appears in the | |
Buildbot dashboard.</p> <p>The output is an SVG file. Example: <img | |
src="https://shield.lwan.ws/img/gycKbr/release">.</p> | |
<p>Status will be cached for half an hour. If expired, the service | |
will fetch the status. There's no way to purge the cache or force | |
the status to be re-fetched other than waiting.</p> | |
<h2>Credits & usage license</h2> | |
<p><a href="https://github.com/lpereira">@lpereira</a> wrote and | |
maintains the service. This service can be used by any project that | |
is maintained by an individual or non-profit organization to serve | |
Free and Open Source projects.</p> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment