feat: add server side of things

master
ALI Hamza 2021-03-30 22:17:14 +07:00
commit 2783ec1d0b
Signed by: hamza
GPG Key ID: 22473A32291F8CB6
14 changed files with 571 additions and 0 deletions

1
.gitignore vendored

@ -0,0 +1 @@
coverage.db

@ -0,0 +1,10 @@
FROM golang:1.16 AS build
WORKDIR /root/
COPY . .
RUN go build -o coverage .
FROM golang:1.16
WORKDIR /root/
COPY --from=build /root/coverage .
EXPOSE 8080
CMD ["./coverage"]

@ -0,0 +1,38 @@
package main
import (
"fmt"
"log"
"os"
"os/signal"
"gitea.teamortix.com/team-ortix/coverage/db"
"gitea.teamortix.com/team-ortix/coverage/server"
)
var (
port = os.Getenv("COVERAGE_PORT")
dbFile = os.Getenv("DB_FILE")
)
func main() {
if port == "" {
port = "8080"
}
if dbFile == "" {
dbFile = "coverage.db"
}
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt)
go func() {
db.OpenDatabase(dbFile)
server.StartServer(port)
}()
log.Printf("server starting. at http://0.0.0.0:%s", port)
<-c
fmt.Println("\nAborting...")
}

@ -0,0 +1,33 @@
package db
import (
"database/sql"
"log"
// Required to connect to database.
_ "github.com/mattn/go-sqlite3"
)
var db *sql.DB
func OpenDatabase(fileLocation string) {
var err error
db, err = sql.Open("sqlite3", fileLocation)
if err != nil {
log.Fatalf("could not open database: %v", err)
}
// Initializes the table.
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS badge (
namespace VARCHAR(64) NOT NULL,
project_name VARCHAR(64) NOT NULL,
coverage INTEGER NOT NULL,
html TEXT NOT NULL,
branch VARCHAR(64),
pull INTEGER
)`)
if err != nil {
log.Fatalf("Could not create table: %v", err)
}
}

@ -0,0 +1,75 @@
package db
import (
"database/sql"
"errors"
"strconv"
)
var ErrNoData = errors.New("no badge found with the provided data")
type CoverageData struct {
Namespace string
Project string
Coverage float64
HTML string
}
// QueryByBranch returns CoverageData for the provided branch for the project.
// If there is no such data, it returns ErrNoData.
func QueryByBranch(namespace, project, branch string) (CoverageData, error) {
result, err := db.Query(`SELECT coverage,html FROM badge WHERE
namespace=? AND
project_name=? AND
branch=?`,
namespace, project, branch)
if err != nil {
return CoverageData{}, err
}
return parseRows(result, namespace, project)
}
// QueryByBranch returns CoverageData for the provided pull request of the project.
// If there is no such data, it returns ErrNoData.
func QueryByPull(namespace, project, pullStr string) (CoverageData, error) {
pull, err := strconv.Atoi(pullStr)
if err != nil {
return CoverageData{}, ErrNoData
}
result, err := db.Query(`SELECT coverage, html FROM badge WHERE
namespace=? AND
project_name=? AND
pull=?`,
namespace, project, pull)
if err != nil {
return CoverageData{}, err
}
return parseRows(result, namespace, project)
}
func parseRows(result *sql.Rows, namespace, project string) (CoverageData, error) {
if !result.Next() {
return CoverageData{}, ErrNoData
}
var coverage float64
var html string
err := result.Scan(&coverage, &html)
if err != nil {
return CoverageData{}, err
}
if err = result.Close(); err != nil {
return CoverageData{}, ErrNoData
}
return CoverageData{
Namespace: namespace,
Project: project,
Coverage: coverage,
HTML: html,
}, nil
}

@ -0,0 +1,66 @@
package db
import (
"fmt"
"strconv"
)
func UploadBranchData(namespace, project, branch, coverageStr, html string) error {
coverage, err := strconv.ParseFloat(coverageStr, 64)
if err != nil {
return fmt.Errorf("coverage param must be valid float, instead got %s", coverageStr)
}
// Because we don't have have a primary key, we must do the upsert manually.
_, err = QueryByBranch(namespace, project, branch)
if err == ErrNoData {
_, err = db.Exec("INSERT INTO badge (namespace, project_name, branch, coverage, html) VALUES (?, ?, ?, ?, ?)",
namespace, project, branch, coverage, html)
if err != nil {
return fmt.Errorf("could not update database: %v", err)
}
}
if err != nil {
return err
}
_, err = db.Exec("UPDATE badge SET coverage=?, html=? WHERE namespace=? AND project_name=? AND branch=?",
coverage, html, namespace, project, branch)
if err != nil {
return fmt.Errorf("could not update database: %v", err)
}
return nil
}
func UploadPullData(namespace, project, pullStr, coverageStr, html string) error {
pull, err := strconv.Atoi(pullStr)
if err != nil {
return fmt.Errorf("pull param must be valid int, instead got %s", pullStr)
}
coverage, err := strconv.ParseFloat(coverageStr, 64)
if err != nil {
return fmt.Errorf("coverage param must be valid float, instead got %s", coverageStr)
}
// Because we don't have have a primary key, we must do the upsert manually.
_, err = QueryByPull(namespace, project, pullStr)
if err == ErrNoData {
_, err = db.Exec("INSERT INTO badge (namespace, project_name, pull, coverage, html) VALUES (?, ?, ?, ?, ?)",
namespace, project, pull, coverage, html)
if err != nil {
return fmt.Errorf("could not update database: %v", err)
}
}
if err != nil {
return err
}
_, err = db.Exec("UPDATE badge SET coverage=?, html=? WHERE namespace=? AND project_name=? AND pull=?",
coverage, html, namespace, project, pull)
if err != nil {
return fmt.Errorf("could not update database: %v", err)
}
return nil
}

@ -0,0 +1,8 @@
module gitea.teamortix.com/team-ortix/coverage
go 1.16
require (
github.com/gorilla/mux v1.8.0
github.com/mattn/go-sqlite3 v1.14.6
)

@ -0,0 +1,4 @@
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=

@ -0,0 +1 @@
package pill

@ -0,0 +1,79 @@
package pill
import (
// Used for embedding pill.
_ "embed"
"fmt"
"io"
"math"
"text/template"
)
var (
//go:embed pill.svg
pill string
tmpl = template.New("svgTemplate")
)
const (
modifier = 0.2
max = 190
blue = 55
)
// Pill is used to execute the pill.svg template.
// Width is dependent on whether the percentage is 2 digits or 3 digits.
type Pill struct {
Percentage string
Colour string
Width string
}
// NewPill creates creates a Pill, automatically calculating the colour, width, and formatting the percentage.
// Note: %02.0f gives us a float with 0 decimals and 0 padding for 2 numbers less than 10.
func NewPill(percentDecimal float64) Pill {
square := math.Pow(percentDecimal, 2)
red := modifier + clamp(2-2*square, 0, 1)*(1-modifier)
green := modifier + clamp(2*square, 0, 1)*(1-modifier)
percentage := fmt.Sprintf("%.0f", percentDecimal*100)
colour := fmt.Sprintf("rgb(%.0f, %.0f, %d)", red*max, green*max, blue)
width := "250"
if len(percentage) == 1 {
percentage = "&#160;" + percentage
width = "220"
}
if len(percentage) == 3 {
width = "300"
}
return Pill{
Percentage: percentage,
Colour: colour,
Width: width,
}
}
// Execute runs the pill template with the data and writes the result to the writer given.
func (p Pill) Execute(w io.Writer) error {
var err error
tmpl, err = tmpl.Parse(pill)
if err != nil {
return err
}
err = tmpl.Execute(w, p)
return err
}
func clamp(n, min, max float64) float64 {
switch {
case n > max:
return max
case n < min:
return min
default:
return n
}
}

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="96" height="20" role="img" aria-label="coverage: {{.Percentage}}%">
<title>coverage: {{.Percentage}}%</title>
<g shape-rendering="crispEdges">
<rect width="61" height="20" fill="#555"/>
<rect x="61" width="35" height="20" fill="{{.Colour}}"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110">
<text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text>
<text x="780" y="140" transform="scale(.1)" fill="#fff" textLength="{{.Width}}">{{.Percentage}}%</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 735 B

@ -0,0 +1,116 @@
package server
import (
"fmt"
"log"
"net/http"
"gitea.teamortix.com/team-ortix/coverage/db"
"gitea.teamortix.com/team-ortix/coverage/pill"
"github.com/gorilla/mux"
)
type endpointData struct {
thirdParam string
queryFunc func(string, string, string) (db.CoverageData, error)
handle func(db.CoverageData)
}
func (d endpointData) run(w http.ResponseWriter, r *http.Request) {
params, ok := getAllParams(w, r, d.thirdParam)
if !ok {
return
}
data, err := d.queryFunc(params[0], params[1], params[2])
if err == db.ErrNoData {
http.Error(w, "not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, fmt.Sprintf("could not query from db: %v", err), http.StatusInternalServerError)
log.Printf("error occurred while querying data from db: %v", err)
return
}
d.handle(data)
}
func handleBranchReport(w http.ResponseWriter, r *http.Request) {
endpointData{
thirdParam: "branch",
queryFunc: db.QueryByBranch,
handle: func(cd db.CoverageData) {
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, cd.HTML)
},
}.run(w, r)
}
func handleBadgeBranch(w http.ResponseWriter, r *http.Request) {
endpointData{
thirdParam: "branch",
queryFunc: db.QueryByBranch,
handle: func(cd db.CoverageData) {
p := pill.NewPill(cd.Coverage)
w.Header().Set("Content-Type", "image/svg+xml")
err := p.Execute(w)
if err != nil {
http.Error(w, fmt.Sprintf("could not write pill: %v", err), http.StatusInternalServerError)
return
}
},
}.run(w, r)
}
func handlePullReport(w http.ResponseWriter, r *http.Request) {
endpointData{
thirdParam: "pull",
queryFunc: db.QueryByPull,
handle: func(cd db.CoverageData) {
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, cd.HTML)
},
}.run(w, r)
}
func handlePullBadge(w http.ResponseWriter, r *http.Request) {
endpointData{
thirdParam: "pull",
queryFunc: db.QueryByPull,
handle: func(cd db.CoverageData) {
p := pill.NewPill(cd.Coverage)
w.Header().Set("Content-Type", "image/svg+xml")
err := p.Execute(w)
if err != nil {
http.Error(w, fmt.Sprintf("could not write pill: %v", err), http.StatusInternalServerError)
return
}
},
}.run(w, r)
}
func getAllParams(w http.ResponseWriter, r *http.Request, thirdParam string) ([3]string, bool) {
vars := mux.Vars(r)
namespace, ok := vars["namespace"]
if !ok {
http.Error(w, "Path 'namespace' not found", http.StatusBadRequest)
return [3]string{}, false
}
project, ok := vars["project"]
if !ok {
http.Error(w, "Path 'project' not found", http.StatusBadRequest)
return [3]string{}, false
}
third, ok := vars[thirdParam]
if !ok {
http.Error(w, fmt.Sprintf("Path '%s' not found", thirdParam), http.StatusBadRequest)
return [3]string{}, false
}
return [3]string{namespace, project, third}, true
}

@ -0,0 +1,53 @@
package server
import (
"fmt"
"log"
"net/http"
"strconv"
"gitea.teamortix.com/team-ortix/coverage/pill"
"github.com/gorilla/mux"
)
func StartServer(port string) {
r := mux.NewRouter()
r.HandleFunc("/badge/{percentage}", handleBadge)
r.HandleFunc("/report/branch/{namespace}/{project}/{branch}", handleBranchReport)
r.HandleFunc("/report/pulls/{namespace}/{project}/{pull}", handlePullReport)
r.HandleFunc("/badge/branch/{namespace}/{project}/{branch}", handleBadgeBranch)
r.HandleFunc("/badge/pulls/{namespace}/{project}/{pull}", handlePullBadge)
r.HandleFunc("/upload/branch/{namespace}/{project}/{branch}", uploadBranch)
r.HandleFunc("/upload/pulls/{namespace}/{project}/{branch}", uploadPull)
portStr := fmt.Sprintf(":%s", port)
err := http.ListenAndServe(portStr, r)
if err != nil {
log.Fatalf("could not start server: %v", err)
}
}
func handleBadge(w http.ResponseWriter, r *http.Request) {
percentString, ok := mux.Vars(r)["percentage"]
if !ok {
http.Error(w, "could not find percentage in path", http.StatusBadRequest)
return
}
percent, err := strconv.ParseFloat(percentString, 64)
if err != nil {
http.Error(w, fmt.Sprintf("could not convert %s to a number", percentString), http.StatusBadRequest)
return
}
p := pill.NewPill(percent / 100)
w.Header().Set("Content-Type", "image/svg+xml")
err = p.Execute(w)
if err != nil {
http.Error(w, fmt.Sprintf("could not write pill: %v", err), http.StatusInternalServerError)
return
}
}

@ -0,0 +1,76 @@
package server
import (
"fmt"
"io"
"net/http"
"os"
"gitea.teamortix.com/team-ortix/coverage/db"
)
const coverageSecret = "COVERAGE_SECRET"
var secretEnv = os.Getenv(coverageSecret)
func uploadBranch(w http.ResponseWriter, r *http.Request) {
upload(w, r, "branch", db.UploadBranchData)
}
func uploadPull(w http.ResponseWriter, r *http.Request) {
upload(w, r, "pull", db.UploadPullData)
}
func upload(w http.ResponseWriter, r *http.Request,
uploadType string, uploadFunc func(string, string, string, string, string) error) {
if r.Method != "POST" {
return
}
err := r.ParseMultipartForm(5 << 20)
if err != nil {
http.Error(w, "file too large", http.StatusRequestEntityTooLarge)
return
}
err = r.ParseForm()
if err != nil {
http.Error(w, "could not parse form", http.StatusBadRequest)
return
}
secret := r.Form.Get("secret")
namespace := r.Form.Get("namespace")
project := r.Form.Get("project")
uploadValue := r.Form.Get(uploadType)
coverage := r.Form.Get("coverage")
if secretEnv != secret && secretEnv != "" {
http.Error(w, "invalid secret provided", http.StatusUnauthorized)
return
}
if namespace == "" || project == "" || uploadValue == "" || coverage == "" {
http.Error(w, fmt.Sprintf("request params [namespace project, %s, coverage] must all be present", uploadType),
http.StatusBadRequest)
return
}
report, _, err := r.FormFile("report")
if err != nil {
http.Error(w, "could not parse report parameter", http.StatusBadRequest)
return
}
bytes, err := io.ReadAll(report)
if err != nil {
http.Error(w, fmt.Sprintf("could not read file: %v", err), http.StatusBadRequest)
return
}
err = uploadFunc(namespace, project, uploadValue, coverage, string(bytes))
if err != nil {
http.Error(w, fmt.Sprintf("could not upload data to database: %v", err), http.StatusInternalServerError)
return
}
fmt.Fprintln(w, "uploaded")
}