From 2783ec1d0b4d93b03bb0dde9cc5c23caad8774ae Mon Sep 17 00:00:00 2001 From: Hamza Ali Date: Tue, 30 Mar 2021 22:17:14 +0700 Subject: [PATCH] feat: add server side of things --- .gitignore | 1 + cmd/server/Dockerfile | 10 ++++ cmd/server/main.go | 38 ++++++++++++++ db/connection.go | 33 ++++++++++++ db/query.go | 75 +++++++++++++++++++++++++++ db/upload.go | 66 ++++++++++++++++++++++++ go.mod | 8 +++ go.sum | 4 ++ pill/badge.go | 1 + pill/pill.go | 79 ++++++++++++++++++++++++++++ pill/pill.svg | 11 ++++ server/query.go | 116 ++++++++++++++++++++++++++++++++++++++++++ server/server.go | 53 +++++++++++++++++++ server/upload.go | 76 +++++++++++++++++++++++++++ 14 files changed, 571 insertions(+) create mode 100644 .gitignore create mode 100644 cmd/server/Dockerfile create mode 100644 cmd/server/main.go create mode 100644 db/connection.go create mode 100644 db/query.go create mode 100644 db/upload.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 pill/badge.go create mode 100644 pill/pill.go create mode 100644 pill/pill.svg create mode 100644 server/query.go create mode 100644 server/server.go create mode 100644 server/upload.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5c4176f --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +coverage.db \ No newline at end of file diff --git a/cmd/server/Dockerfile b/cmd/server/Dockerfile new file mode 100644 index 0000000..422b10a --- /dev/null +++ b/cmd/server/Dockerfile @@ -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"] diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..0fadeda --- /dev/null +++ b/cmd/server/main.go @@ -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...") +} diff --git a/db/connection.go b/db/connection.go new file mode 100644 index 0000000..835b046 --- /dev/null +++ b/db/connection.go @@ -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) + } +} diff --git a/db/query.go b/db/query.go new file mode 100644 index 0000000..4c988d1 --- /dev/null +++ b/db/query.go @@ -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 +} diff --git a/db/upload.go b/db/upload.go new file mode 100644 index 0000000..73189be --- /dev/null +++ b/db/upload.go @@ -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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..573805c --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..27294bc --- /dev/null +++ b/go.sum @@ -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= diff --git a/pill/badge.go b/pill/badge.go new file mode 100644 index 0000000..bfcec92 --- /dev/null +++ b/pill/badge.go @@ -0,0 +1 @@ +package pill diff --git a/pill/pill.go b/pill/pill.go new file mode 100644 index 0000000..3a57ef1 --- /dev/null +++ b/pill/pill.go @@ -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 = " " + 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 + } +} diff --git a/pill/pill.svg b/pill/pill.svg new file mode 100644 index 0000000..ff9012c --- /dev/null +++ b/pill/pill.svg @@ -0,0 +1,11 @@ + + coverage: {{.Percentage}}% + + + + + + coverage + {{.Percentage}}% + + \ No newline at end of file diff --git a/server/query.go b/server/query.go new file mode 100644 index 0000000..7d57172 --- /dev/null +++ b/server/query.go @@ -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 +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..daae28e --- /dev/null +++ b/server/server.go @@ -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 + } +} diff --git a/server/upload.go b/server/upload.go new file mode 100644 index 0000000..75b26fd --- /dev/null +++ b/server/upload.go @@ -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") +}