gitdeploy/internal/api/api.go

381 lines
9.8 KiB
Go

package api
import (
"encoding/json"
"fmt"
"math"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"git.rootprojects.org/root/gitdeploy/internal/jobs"
"git.rootprojects.org/root/gitdeploy/internal/log"
"git.rootprojects.org/root/gitdeploy/internal/options"
"git.rootprojects.org/root/gitdeploy/internal/webhooks"
"github.com/go-chi/chi"
)
// HTTPError for http errors
type HTTPError struct {
Success bool `json:"success"`
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
Detail string `json:"detail,omitempty"`
}
// WrappedReport wraps results
type WrappedReport struct {
Report jobs.Result `json:"report"`
}
// Route will set up the API and such
func Route(r chi.Router, runOpts *options.ServerConfig) {
jobs.Start(runOpts)
RouteStopped(r, runOpts)
}
// RouteStopped is for testing
func RouteStopped(r chi.Router, runOpts *options.ServerConfig) {
webhooks.RouteHandlers(r)
r.Route("/api", func(r chi.Router) {
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// r.Body is always .Close()ed by Go's http server
r.Body = http.MaxBytesReader(w, r.Body, options.DefaultMaxBodySize)
next.ServeHTTP(w, r)
})
})
r.Route("/admin", func(r chi.Router) {
r.Get("/repos", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
repos := []Repo{}
for _, id := range strings.Fields(runOpts.RepoList) {
repos = append(repos, Repo{
ID: id,
CloneURL: fmt.Sprintf("https://%s.git", id),
Promotions: runOpts.Promotions,
})
}
err := filepath.Walk(runOpts.ScriptsPath, func(path string, info os.FileInfo, err error) error {
if nil != err {
fmt.Printf("error walking %q: %v\n", path, err)
return nil
}
// "scripts/github.com/org/repo"
parts := strings.Split(filepath.ToSlash(path), "/")
if len(parts) < 3 {
return nil
}
path = strings.Join(parts[1:], "/")
if info.Mode().IsRegular() && "deploy.sh" == info.Name() && runOpts.ScriptsPath != path {
id := filepath.Dir(path)
repos = append(repos, Repo{
ID: id,
CloneURL: fmt.Sprintf("https://%s.git", id),
Promotions: runOpts.Promotions,
})
}
return nil
})
if nil != err {
http.Error(w, "the scripts directory disappeared", http.StatusInternalServerError)
return
}
b, _ := json.MarshalIndent(ReposResponse{
Success: true,
Repos: repos,
}, "", " ")
w.Write(append(b, '\n'))
})
r.Get("/logs/{oldID}", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
since, httpErr := ParseSince(r.URL.Query().Get("since"))
if nil != httpErr {
w.WriteHeader(http.StatusBadRequest)
writeError(w, httpErr)
return
}
oldID := webhooks.URLSafeGitID(chi.URLParam(r, "oldID"))
// TODO add `since`
j, err := jobs.LoadLogs(runOpts, oldID)
if nil != err {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(
`{ "success": false, "error": "job log does not exist" }` + "\n",
))
return
}
// copies unused lock value
jobCopy := *j
logs := []jobs.Log{}
for _, log := range j.Logs {
if log.Timestamp.Sub(since) > 0 {
logs = append(logs, log)
}
}
jobCopy.Logs = logs
// TODO admin auth middleware
log.Printf("[TODO] handle AUTH (logs could be sensitive)")
b, _ := json.MarshalIndent(struct {
Success bool `json:"success"`
jobs.Job
}{
Success: true,
Job: jobCopy,
}, "", " ")
w.Write(append(b, '\n'))
})
/*
r.Get("/logs/*", func(w http.ResponseWriter, r *http.Request) {
// TODO add ?since=
// TODO JSON logs
logPath := chi.URLParam(r, "*")
f, err := os.Open(filepath.Join(os.Getenv("LOG_DIR"), logPath))
if nil != err {
w.WriteHeader(404)
w.Write([]byte(
`{ "success": false, "error": "job log does not exist" }` + "\n",
))
return
}
io.Copy(w, f)
})
*/
r.Get("/jobs", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
since, httpErr := ParseSince(r.URL.Query().Get("since"))
if nil != httpErr {
w.WriteHeader(http.StatusBadRequest)
writeError(w, httpErr)
return
}
all := jobs.All(since)
b, _ := json.Marshal(struct {
Success bool `json:"success"`
Jobs []*jobs.Job `json:"jobs"`
}{
Success: true,
Jobs: all,
})
w.Write(append(b, '\n'))
})
r.Post("/jobs", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
decoder := json.NewDecoder(r.Body)
msg := &KillMsg{}
if err := decoder.Decode(msg); nil != err {
log.Printf("kill job invalid json:\n%v", err)
http.Error(w, "invalid json body", http.StatusBadRequest)
return
}
if _, ok := jobs.Actives.Load(webhooks.URLSafeRefID(msg.JobID)); !ok {
if _, ok := jobs.Pending.Load(webhooks.URLSafeRefID(msg.JobID)); !ok {
w.Write([]byte(
`{ "success": false, "error": "job does not exist" }` + "\n",
))
return
}
}
// killing a job *should* always succeed ...right?
jobs.Remove(webhooks.URLSafeRefID(msg.JobID))
w.Write([]byte(
`{ "success": true }` + "\n",
))
})
r.Post("/promote", func(w http.ResponseWriter, r *http.Request) {
decoder := json.NewDecoder(r.Body)
msg := webhooks.Ref{}
if err := decoder.Decode(&msg); nil != err {
log.Printf("promotion job invalid json:\n%v", err)
http.Error(w, "invalid json body", http.StatusBadRequest)
return
}
if "" == msg.HTTPSURL || "" == msg.RefName {
log.Printf("promotion job incomplete json %s", msg)
http.Error(w, "incomplete json body", http.StatusBadRequest)
return
}
n := -2
for i := range runOpts.Promotions {
if runOpts.Promotions[i] == msg.RefName {
n = i - 1
break
}
}
if n < 0 {
log.Printf("promotion job invalid: cannot promote: %d", n)
http.Error(w, "invalid promotion", http.StatusBadRequest)
return
}
promoteTo := runOpts.Promotions[n]
jobs.Promote(msg, promoteTo)
b, _ := json.Marshal(struct {
Success bool `json:"success"`
PromoteTo string `json:"promote_to"`
}{
Success: true,
PromoteTo: promoteTo,
})
w.Write(append(b, '\n'))
})
})
r.Route("/local", func(r chi.Router) {
r.Post("/jobs/{jobID}", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
report := WrappedReport{}
decoder := json.NewDecoder(r.Body)
format := r.URL.Query().Get("format")
switch format {
case "pytest":
pyresult := PyResult{}
if err := decoder.Decode(&pyresult); nil != err {
w.WriteHeader(http.StatusBadRequest)
writeError(w, &HTTPError{
Code: "E_PARSE",
Message: "could not parse request body",
Detail: err.Error(),
})
return
}
report = PyResultToReport(pyresult)
case "gitdeploy":
fallthrough
case "":
if err := decoder.Decode(&report); nil != err {
w.WriteHeader(http.StatusBadRequest)
writeError(w, &HTTPError{
Code: "E_PARSE",
Message: "could not parse request body",
Detail: err.Error(),
})
return
}
}
jobID := webhooks.URLSafeRefID(chi.URLParam(r, "jobID"))
if err := jobs.SetReport(jobID, &report.Report); nil != err {
w.WriteHeader(http.StatusInternalServerError)
writeError(w, &HTTPError{
Code: "E_SERVER",
Message: "could not update report",
Detail: err.Error(),
})
return
}
// Attach additional logs / reports to running job
w.Write([]byte(`{ "success": true }` + "\n"))
})
})
})
}
// ParseSince will parse the query string into time.Time
func ParseSince(sinceStr string) (time.Time, *HTTPError) {
if 0 == len(sinceStr) {
return time.Time{}, &HTTPError{
Code: "E_QUERY",
Message: "missing query parameter '?since=' (Unix epoch seconds as float64)",
}
}
t, err := ParseUnixTime(sinceStr)
if nil != err {
return time.Time{}, &HTTPError{
Code: "E_QUERY",
Message: "invalid query parameter '?since=' (Unix epoch seconds as float64)",
Detail: err.Error(),
}
}
return t, nil
}
// ParseUnixTime turns a string of fractional seconds into time.Time
func ParseUnixTime(seconds string) (time.Time, error) {
secs, nano, err := ParseSeconds(seconds)
if nil != err {
return time.Time{}, err
}
return time.Unix(secs, nano), nil
}
// ParseSeconds turns a string of fractional seconds into Seconds and Nanoseconds
func ParseSeconds(s string) (int64, int64, error) {
seconds, err := strconv.ParseFloat(s, 64)
if nil != err {
return 0.0, 0.0, err
}
secs, nanos := SecondsToInts(seconds)
return secs, nanos, nil
}
// SecondsToInts turns a float64 Second into Seconds and Nanoseconds
func SecondsToInts(seconds float64) (int64, int64) {
secs := math.Floor(seconds)
nanos := math.Round((seconds - secs) * 1000000000)
return int64(secs), int64(nanos)
}
func writeError(w http.ResponseWriter, err *HTTPError) {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(err)
w.Write([]byte("\n"))
}
// ReposResponse is the successful response to /api/repos
type ReposResponse struct {
Success bool `json:"success"`
Repos []Repo `json:"repos"`
}
// Repo is one of the elements of /api/repos
type Repo struct {
ID string `json:"id"`
CloneURL string `json:"clone_url"`
Promotions []string `json:"_promotions"`
}
// KillMsg describes which job to kill
type KillMsg struct {
JobID string `json:"job_id"`
Kill bool `json:"kill"`
}