2020-11-21 12:41:01 +00:00
|
|
|
package api
|
|
|
|
|
|
|
|
import (
|
|
|
|
"encoding/json"
|
|
|
|
"fmt"
|
2021-02-25 09:49:57 +00:00
|
|
|
"math"
|
2020-11-21 12:41:01 +00:00
|
|
|
"net/http"
|
|
|
|
"os"
|
|
|
|
"path/filepath"
|
2021-02-25 09:49:57 +00:00
|
|
|
"strconv"
|
2020-11-21 12:41:01 +00:00
|
|
|
"strings"
|
2021-02-25 09:49:57 +00:00
|
|
|
"time"
|
2020-11-21 12:41:01 +00:00
|
|
|
|
2021-02-21 11:25:42 +00:00
|
|
|
"git.rootprojects.org/root/gitdeploy/internal/jobs"
|
2020-12-01 00:48:09 +00:00
|
|
|
"git.rootprojects.org/root/gitdeploy/internal/log"
|
2020-11-21 12:41:01 +00:00
|
|
|
"git.rootprojects.org/root/gitdeploy/internal/options"
|
|
|
|
"git.rootprojects.org/root/gitdeploy/internal/webhooks"
|
|
|
|
|
|
|
|
"github.com/go-chi/chi"
|
|
|
|
)
|
|
|
|
|
2021-02-26 01:41:35 +00:00
|
|
|
// HTTPError for http errors
|
2021-02-24 02:34:33 +00:00
|
|
|
type HTTPError struct {
|
2020-12-01 00:49:20 +00:00
|
|
|
Success bool `json:"success"`
|
2021-02-24 02:34:33 +00:00
|
|
|
Code string `json:"code,omitempty"`
|
|
|
|
Message string `json:"message,omitempty"`
|
|
|
|
Detail string `json:"detail,omitempty"`
|
2020-12-01 00:49:20 +00:00
|
|
|
}
|
|
|
|
|
2021-02-26 01:41:35 +00:00
|
|
|
// WrappedReport wraps results
|
|
|
|
type WrappedReport struct {
|
|
|
|
Report jobs.Result `json:"report"`
|
2020-12-01 00:49:20 +00:00
|
|
|
}
|
|
|
|
|
2020-11-21 12:41:01 +00:00
|
|
|
// Route will set up the API and such
|
|
|
|
func Route(r chi.Router, runOpts *options.ServerConfig) {
|
2021-02-21 11:25:42 +00:00
|
|
|
jobs.Start(runOpts)
|
2021-02-24 02:34:33 +00:00
|
|
|
RouteStopped(r, runOpts)
|
|
|
|
}
|
2020-11-21 12:41:01 +00:00
|
|
|
|
2021-02-24 02:34:33 +00:00
|
|
|
// RouteStopped is for testing
|
|
|
|
func RouteStopped(r chi.Router, runOpts *options.ServerConfig) {
|
2020-11-21 12:41:01 +00:00
|
|
|
webhooks.RouteHandlers(r)
|
|
|
|
|
2021-02-24 02:34:33 +00:00
|
|
|
r.Route("/api", func(r chi.Router) {
|
2020-12-01 00:49:20 +00:00
|
|
|
|
2020-11-21 12:41:01 +00:00
|
|
|
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)
|
|
|
|
})
|
|
|
|
})
|
|
|
|
|
2021-02-24 02:34:33 +00:00
|
|
|
r.Route("/admin", func(r chi.Router) {
|
|
|
|
r.Get("/repos", func(w http.ResponseWriter, r *http.Request) {
|
2021-02-25 09:49:57 +00:00
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
2021-02-24 02:34:33 +00:00
|
|
|
repos := []Repo{}
|
2020-11-21 12:41:01 +00:00
|
|
|
|
2021-02-24 02:34:33 +00:00
|
|
|
for _, id := range strings.Fields(runOpts.RepoList) {
|
2020-11-21 12:41:01 +00:00
|
|
|
repos = append(repos, Repo{
|
|
|
|
ID: id,
|
|
|
|
CloneURL: fmt.Sprintf("https://%s.git", id),
|
|
|
|
Promotions: runOpts.Promotions,
|
|
|
|
})
|
|
|
|
}
|
2021-02-24 02:34:33 +00:00
|
|
|
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'))
|
2020-11-21 12:41:01 +00:00
|
|
|
})
|
2021-02-21 11:25:42 +00:00
|
|
|
|
2021-02-24 02:34:33 +00:00
|
|
|
r.Get("/logs/{oldID}", func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
2021-02-21 11:25:42 +00:00
|
|
|
|
2021-02-25 09:49:57 +00:00
|
|
|
since, httpErr := ParseSince(r.URL.Query().Get("since"))
|
|
|
|
if nil != httpErr {
|
|
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
writeError(w, httpErr)
|
|
|
|
return
|
|
|
|
}
|
2021-02-24 08:13:36 +00:00
|
|
|
|
2021-02-24 02:34:33 +00:00
|
|
|
oldID := webhooks.URLSafeGitID(chi.URLParam(r, "oldID"))
|
|
|
|
// TODO add `since`
|
|
|
|
j, err := jobs.LoadLogs(runOpts, oldID)
|
2021-02-21 11:25:42 +00:00
|
|
|
if nil != err {
|
2021-02-25 09:49:57 +00:00
|
|
|
w.WriteHeader(http.StatusNotFound)
|
2021-02-21 11:25:42 +00:00
|
|
|
w.Write([]byte(
|
|
|
|
`{ "success": false, "error": "job log does not exist" }` + "\n",
|
|
|
|
))
|
|
|
|
return
|
|
|
|
}
|
2021-02-24 02:34:33 +00:00
|
|
|
|
2021-02-26 01:41:35 +00:00
|
|
|
// copies unused lock value
|
2021-02-25 09:49:57 +00:00
|
|
|
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)")
|
|
|
|
|
2021-02-24 02:34:33 +00:00
|
|
|
b, _ := json.MarshalIndent(struct {
|
|
|
|
Success bool `json:"success"`
|
|
|
|
jobs.Job
|
|
|
|
}{
|
|
|
|
Success: true,
|
2021-02-25 09:49:57 +00:00
|
|
|
Job: jobCopy,
|
2021-02-24 02:34:33 +00:00
|
|
|
}, "", " ")
|
|
|
|
w.Write(append(b, '\n'))
|
2021-02-21 11:25:42 +00:00
|
|
|
})
|
|
|
|
|
2021-02-24 02:34:33 +00:00
|
|
|
/*
|
|
|
|
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) {
|
2021-02-25 09:49:57 +00:00
|
|
|
w.Header().Set("Content-Type", "application/json")
|
2021-02-21 11:25:42 +00:00
|
|
|
|
2021-02-25 09:49:57 +00:00
|
|
|
since, httpErr := ParseSince(r.URL.Query().Get("since"))
|
|
|
|
if nil != httpErr {
|
|
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
writeError(w, httpErr)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
all := jobs.All(since)
|
2021-02-24 02:34:33 +00:00
|
|
|
b, _ := json.Marshal(struct {
|
|
|
|
Success bool `json:"success"`
|
|
|
|
Jobs []*jobs.Job `json:"jobs"`
|
|
|
|
}{
|
|
|
|
Success: true,
|
|
|
|
Jobs: all,
|
|
|
|
})
|
|
|
|
w.Write(append(b, '\n'))
|
2020-11-21 12:41:01 +00:00
|
|
|
})
|
2020-12-01 00:49:20 +00:00
|
|
|
|
2021-02-24 02:34:33 +00:00
|
|
|
r.Post("/jobs", func(w http.ResponseWriter, r *http.Request) {
|
2021-02-25 09:49:57 +00:00
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
2021-02-24 02:34:33 +00:00
|
|
|
decoder := json.NewDecoder(r.Body)
|
2021-02-24 08:13:36 +00:00
|
|
|
msg := &KillMsg{}
|
2021-02-24 02:34:33 +00:00
|
|
|
if err := decoder.Decode(msg); nil != err {
|
|
|
|
log.Printf("kill job invalid json:\n%v", err)
|
|
|
|
http.Error(w, "invalid json body", http.StatusBadRequest)
|
2021-02-21 11:25:42 +00:00
|
|
|
return
|
|
|
|
}
|
2020-11-21 12:41:01 +00:00
|
|
|
|
2021-02-24 02:34:33 +00:00
|
|
|
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)
|
2021-02-21 11:25:42 +00:00
|
|
|
|
2021-02-24 02:34:33 +00:00
|
|
|
b, _ := json.Marshal(struct {
|
|
|
|
Success bool `json:"success"`
|
|
|
|
PromoteTo string `json:"promote_to"`
|
|
|
|
}{
|
|
|
|
Success: true,
|
|
|
|
PromoteTo: promoteTo,
|
|
|
|
})
|
|
|
|
w.Write(append(b, '\n'))
|
|
|
|
|
|
|
|
})
|
2020-11-21 12:41:01 +00:00
|
|
|
})
|
|
|
|
|
2021-02-24 02:34:33 +00:00
|
|
|
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")
|
|
|
|
|
2021-02-26 01:41:35 +00:00
|
|
|
report := WrappedReport{}
|
2021-02-24 02:34:33 +00:00
|
|
|
decoder := json.NewDecoder(r.Body)
|
2021-02-26 01:41:35 +00:00
|
|
|
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
|
|
|
|
}
|
2021-02-24 02:34:33 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
jobID := webhooks.URLSafeRefID(chi.URLParam(r, "jobID"))
|
2021-02-26 01:41:35 +00:00
|
|
|
if err := jobs.SetReport(jobID, &report.Report); nil != err {
|
2021-02-24 02:34:33 +00:00
|
|
|
w.WriteHeader(http.StatusInternalServerError)
|
2021-02-25 09:49:57 +00:00
|
|
|
writeError(w, &HTTPError{
|
2021-02-24 02:34:33 +00:00
|
|
|
Code: "E_SERVER",
|
|
|
|
Message: "could not update report",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
2020-11-21 12:41:01 +00:00
|
|
|
}
|
2021-02-24 02:34:33 +00:00
|
|
|
|
|
|
|
// Attach additional logs / reports to running job
|
|
|
|
w.Write([]byte(`{ "success": true }` + "\n"))
|
2020-11-21 12:41:01 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
})
|
2021-02-24 02:34:33 +00:00
|
|
|
|
2020-11-21 12:41:01 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
}
|
2021-02-24 02:34:33 +00:00
|
|
|
|
2021-02-26 01:41:35 +00:00
|
|
|
// ParseSince will parse the query string into time.Time
|
2021-02-25 09:49:57 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2021-02-26 01:41:35 +00:00
|
|
|
// ParseUnixTime turns a string of fractional seconds into time.Time
|
2021-02-25 09:49:57 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2021-02-26 01:41:35 +00:00
|
|
|
// ParseSeconds turns a string of fractional seconds into Seconds and Nanoseconds
|
2021-02-25 09:49:57 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2021-02-26 01:41:35 +00:00
|
|
|
// SecondsToInts turns a float64 Second into Seconds and Nanoseconds
|
2021-02-25 09:49:57 +00:00
|
|
|
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) {
|
2021-02-24 02:34:33 +00:00
|
|
|
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"`
|
|
|
|
}
|
2021-02-24 08:13:36 +00:00
|
|
|
|
|
|
|
// KillMsg describes which job to kill
|
|
|
|
type KillMsg struct {
|
|
|
|
JobID string `json:"job_id"`
|
|
|
|
Kill bool `json:"kill"`
|
|
|
|
}
|