package main
/*
* This file is part of Autogits.
*
* Copyright © 2024 SUSE LLC
*
* Autogits is free software: you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free Software
* Foundation, either version 2 of the License, or (at your option) any later
* version.
*
* Autogits is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* Foobar. If not, see .
*/
import (
"encoding/json"
"encoding/xml"
"flag"
"fmt"
"html"
"io"
"log"
"maps"
"net/http"
"net/url"
"os"
"slices"
"strings"
"time"
"src.opensuse.org/autogits/common"
)
const (
AppName = "obs-status-service"
)
var obs *common.ObsClient
type RepoBuildCounters struct {
Repository, Arch string
Status string
BuildStatusCounter map[string]int
}
func ProjectStatusSummarySvg(res []*common.BuildResult) []byte {
if len(res) == 0 {
return nil
}
list := common.BuildResultList{
Result: res,
}
package_names := list.GetPackageList()
maxLen := 0
for _, p := range package_names {
maxLen = max(maxLen, len(p))
}
// width := float32(len(list.Result))*1.5 + float32(maxLen)*0.8
// height := 1.5*float32(maxLen) + 30
ret := NewSvg(SvgType_Project)
status := make([]RepoBuildCounters, len(res))
for i, repo := range res {
status[i].Arch = repo.Arch
status[i].Repository = repo.Repository
status[i].Status = repo.Code
status[i].BuildStatusCounter = make(map[string]int)
for _, pkg := range repo.Status {
status[i].BuildStatusCounter[pkg.Code]++
}
}
slices.SortFunc(status, func(a, b RepoBuildCounters) int {
if r := strings.Compare(a.Repository, b.Repository); r != 0 {
return r
}
return strings.Compare(a.Arch, b.Arch)
})
repoName := ""
ret.ypos = 3.0
for _, repo := range status {
if repo.Repository != repoName {
repoName = repo.Repository
ret.WriteTitle(repoName)
}
ret.WriteSubtitle(repo.Arch)
statuses := slices.Sorted(maps.Keys(repo.BuildStatusCounter))
for _, status := range statuses {
ret.WriteProjectStatus(res[0].Project, repo.Repository, repo.Arch, status, repo.BuildStatusCounter[status])
}
}
return ret.GenerateSvg()
}
func LinkToBuildlog(R *common.BuildResult, S *common.PackageBuildStatus) string {
if R != nil && S != nil {
switch S.Code {
case "succeeded", "failed", "building":
return "/buildlog/" + url.PathEscape(R.Project) + "/" + url.PathEscape(S.Package) + "/" + url.PathEscape(R.Repository) + "/" + url.PathEscape(R.Arch)
}
}
return ""
}
func DeleteExceptPkg(pkg string) func(*common.PackageBuildStatus) bool {
return func(item *common.PackageBuildStatus) bool {
multibuild_prefix := pkg + ":"
return item.Package != pkg && !strings.HasPrefix(item.Package, multibuild_prefix)
}
}
func PackageStatusSummarySvg(pkg string, res []*common.BuildResult) []byte {
// per repo, per arch status bins
repo_names := []string{}
package_names := []string{}
multibuild_prefix := pkg + ":"
for _, r := range res {
if pos, found := slices.BinarySearchFunc(repo_names, r.Repository, strings.Compare); !found {
repo_names = slices.Insert(repo_names, pos, r.Repository)
}
for _, p := range r.Status {
if p.Package == pkg || strings.HasPrefix(p.Package, multibuild_prefix) {
if pos, found := slices.BinarySearchFunc(package_names, p.Package, strings.Compare); !found {
package_names = slices.Insert(package_names, pos, p.Package)
}
}
}
}
ret := NewSvg(SvgType_Package)
for _, pkg = range package_names {
// if len(package_names) > 1 {
ret.WriteTitle(pkg)
// }
for _, name := range repo_names {
ret.WriteSubtitle(name)
// print all repo arches here and build results
for _, r := range res {
if r.Repository != name {
continue
}
for _, s := range r.Status {
if s.Package == pkg {
link := LinkToBuildlog(r, s)
ret.WritePackageStatus(link, r.Arch, s.Code, s.Details)
}
}
}
}
}
return ret.GenerateSvg()
}
func BuildStatusSvg(repo *common.BuildResult, status *common.PackageBuildStatus) []byte {
buildStatus, ok := common.ObsBuildStatusDetails[status.Code]
if !ok {
buildStatus = common.ObsBuildStatusDetails["error"]
}
fillColor := "#480" // orange
textColor := "#888"
if buildStatus.Finished {
textColor = "#fff"
if buildStatus.Success {
fillColor = "#080"
} else {
fillColor = "#800"
}
}
buildlog := LinkToBuildlog(repo, status)
startTag := ""
endTag := ""
if len(buildlog) > 0 {
startTag = ""
endTag = ""
}
return []byte(``)
}
func WriteJson(data any, res http.ResponseWriter) {
if jsonArray, err := json.MarshalIndent(data, "", " "); err != nil {
res.WriteHeader(500)
} else {
res.Header().Add("size", fmt.Sprint(len(jsonArray)))
res.Write(jsonArray)
}
}
func WriteXml(data any, res http.ResponseWriter) {
if xmlData, err := xml.MarshalIndent(data, "", " "); err != nil {
res.WriteHeader(500)
} else {
res.Header().Add("size", fmt.Sprint(len(xmlData)))
res.Write([]byte(""))
res.Write(xmlData)
res.Write([]byte(""))
}
}
var ObsUrl *string
func main() {
obsUrlDef := os.Getenv("OBS_STATUS_SERVICE_OBS_URL")
if len(obsUrlDef) == 0 {
obsUrlDef = "https://build.opensuse.org"
}
listenDef := os.Getenv("OBS_STATUS_SERVICE_LISTEN")
if len(listenDef) == 0 {
listenDef = "[::1]:8080"
}
certDef := os.Getenv("OBS_STATUS_SERVICE_CERT")
if len(certDef) == 0 {
certDef = "/run/obs-status-service.pem"
}
keyDef := os.Getenv("OBS_STATUS_SERVICE_KEY")
if len(keyDef) == 0 {
keyDef = certDef
}
cert := flag.String("cert-file", certDef, "TLS certificates file")
key := flag.String("key-file", keyDef, "Private key for the TLS certificate")
listen := flag.String("listen", listenDef, "Listening string")
disableTls := flag.Bool("no-tls", false, "Disable TLS")
ObsUrl = flag.String("obs-url", obsUrlDef, "OBS API endpoint for package buildlog information")
debug := flag.Bool("debug", false, "Enable debug logging")
flag.Parse()
if *debug {
common.SetLoggingLevel(common.LogLevelDebug)
}
if redisUrl := os.Getenv("REDIS"); len(redisUrl) > 0 {
RedisConnect(redisUrl)
} else {
common.LogError("REDIS needs to contains URL of the OBS Redis instance with login information")
return
}
var rescanRepoError error
go func() {
for {
if rescanRepoError = RescanRepositories(); rescanRepoError != nil {
common.LogError("Failed to rescan repositories.", rescanRepoError)
}
time.Sleep(time.Minute * 5)
}
}()
http.HandleFunc("GET /", func(res http.ResponseWriter, req *http.Request) {
if rescanRepoError != nil {
res.WriteHeader(500)
return
}
res.WriteHeader(404)
res.Write([]byte("404 page not found\n"))
})
http.HandleFunc("GET /status/{Project}", func(res http.ResponseWriter, req *http.Request) {
mime := ParseMimeHeader(req)
obsPrj := req.PathValue("Project")
common.LogInfo(" GET /status/"+obsPrj, "["+mime.MimeType()+"]")
status := FindAndUpdateProjectResults(obsPrj)
if len(status) == 0 {
res.WriteHeader(404)
return
}
res.Header().Add("content-type", mime.MimeHeader)
if mime.IsSvg() {
svg := ProjectStatusSummarySvg(status)
res.Header().Add("size", fmt.Sprint(len(svg)))
res.Write(svg)
} else if mime.IsJson() {
WriteJson(status, res)
} else if mime.IsXml() {
WriteXml(status, res)
}
})
http.HandleFunc("GET /status/{Project}/{Package}", func(res http.ResponseWriter, req *http.Request) {
mime := ParseMimeHeader(req)
obsPrj := req.PathValue("Project")
obsPkg := req.PathValue("Package")
common.LogInfo(" GET /status/"+obsPrj+"/"+obsPkg, "["+mime.MimeType()+"]")
status := slices.Clone(FindAndUpdateProjectResults(obsPrj))
for i, s := range status {
f := *s
f.Status = slices.DeleteFunc(slices.Clone(s.Status), DeleteExceptPkg(obsPkg))
status[i] = &f
}
if len(status) == 0 {
res.WriteHeader(404)
return
}
res.Header().Add("content-type", mime.MimeHeader)
if mime.IsSvg() {
svg := PackageStatusSummarySvg(obsPkg, status)
res.Header().Add("size", fmt.Sprint(len(svg)))
res.Write(svg)
} else if mime.IsJson() {
WriteJson(status, res)
} else if mime.IsXml() {
WriteXml(status, res)
}
})
http.HandleFunc("GET /status/{Project}/{Package}/{Repository}", func(res http.ResponseWriter, req *http.Request) {
mime := ParseMimeHeader(req)
obsPrj := req.PathValue("Project")
obsPkg := req.PathValue("Package")
repo := req.PathValue("Repository")
common.LogInfo(" GET /status/"+obsPrj+"/"+obsPkg, "["+mime.MimeType()+"]")
status := slices.Clone(FindAndUpdateRepoResults(obsPrj, repo))
for _, s := range status {
s.Status = slices.DeleteFunc(slices.Clone(s.Status), DeleteExceptPkg(obsPkg))
}
if len(status) == 0 {
res.WriteHeader(404)
return
}
if mime.IsSvg() {
svg := PackageStatusSummarySvg(obsPkg, status)
res.Header().Add("content-type", mime.MimeHeader)
res.Header().Add("size", fmt.Sprint(len(svg)))
res.Write(svg)
} else if mime.IsJson() {
WriteJson(status, res)
} else if mime.IsXml() {
WriteXml(status, res)
}
})
http.HandleFunc("GET /status/{Project}/{Package}/{Repository}/{Arch}", func(res http.ResponseWriter, req *http.Request) {
mime := ParseMimeHeader(req)
prj := req.PathValue("Project")
pkg := req.PathValue("Package")
repo := req.PathValue("Repository")
arch := req.PathValue("Arch")
common.LogInfo(" GET /status/"+prj+"/"+pkg+"/"+repo+"/"+arch, "["+mime.MimeType()+"]")
res.Header().Add("content-type", mime.MimeHeader)
for _, r := range FindAndUpdateRepoResults(prj, repo) {
if r.Arch == arch {
if idx, found := slices.BinarySearchFunc(r.Status, &common.PackageBuildStatus{Package: pkg}, common.PackageBuildStatusComp); found {
status := r.Status[idx]
if mime.IsSvg() {
res.Write(BuildStatusSvg(r, status))
} else if mime.IsJson() {
WriteJson(status, res)
} else if mime.IsXml() {
WriteXml(status, res)
}
return
}
break
}
}
if mime.IsSvg() {
res.Write(BuildStatusSvg(nil, &common.PackageBuildStatus{Package: pkg, Code: "unknown"}))
}
})
http.HandleFunc("GET /search", func(res http.ResponseWriter, req *http.Request) {
common.LogInfo("GET /search?" + req.URL.RawQuery)
queries := req.URL.Query()
if !queries.Has("q") {
res.WriteHeader(400)
return
}
names := queries["q"]
if len(names) != 1 {
res.WriteHeader(400)
return
}
packages := FindPackages(names[0])
data, err := json.MarshalIndent(packages, "", " ")
if err != nil {
res.WriteHeader(500)
common.LogError("Error in marshalling data.", err)
return
}
res.Write(data)
res.Header().Add("content-type", "application/json")
res.WriteHeader(200)
})
http.HandleFunc("GET /buildlog/{Project}/{Package}/{Repository}/{Arch}", func(res http.ResponseWriter, req *http.Request) {
prj := req.PathValue("Project")
pkg := req.PathValue("Package")
repo := req.PathValue("Repository")
arch := req.PathValue("Arch")
res.Header().Add("location", *ObsUrl+"/package/live_build_log/"+url.PathEscape(prj)+"/"+url.PathEscape(pkg)+"/"+url.PathEscape(repo)+"/"+url.PathEscape(arch))
res.WriteHeader(307)
return
// status := GetDetailedBuildStatus(prj, pkg, repo, arch)
data, err := obs.BuildLog(prj, pkg, repo, arch)
if err != nil {
res.WriteHeader(http.StatusInternalServerError)
common.LogError("Failed to fetch build log for:", prj, pkg, repo, arch, err)
return
}
defer data.Close()
io.Copy(res, data)
})
if *disableTls {
log.Fatal(http.ListenAndServe(*listen, nil))
} else {
log.Fatal(http.ListenAndServeTLS(*listen, *cert, *key, nil))
}
}