set and get job reports

This commit is contained in:
AJ ONeal 2021-02-23 19:34:33 -07:00
parent 70291c3bce
commit 2873457cf1
8 changed files with 461 additions and 258 deletions

View File

@ -7,7 +7,6 @@ import (
"os"
"path/filepath"
"strings"
"time"
"git.rootprojects.org/root/gitdeploy/internal/jobs"
"git.rootprojects.org/root/gitdeploy/internal/log"
@ -17,37 +16,28 @@ import (
"github.com/go-chi/chi"
)
// HookResponse is a GitRef but with a little extra as HTTP response
type HookResponse struct {
RepoID string `json:"repo_id"`
CreatedAt time.Time `json:"created_at"`
EndedAt time.Time `json:"ended_at"`
ExitCode *int `json:"exit_code,omitempty"`
Log string `json:"log"`
LogURL string `json:"log_url"`
}
// ReposResponse is the successful response to /api/repos
type ReposResponse struct {
type HTTPError struct {
Success bool `json:"success"`
Repos []Repo `json:"repos"`
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
Detail string `json:"detail,omitempty"`
}
// 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"`
type Report struct {
Report *jobs.Report `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/admin", func(r chi.Router) {
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) {
@ -59,6 +49,7 @@ func Route(r chi.Router, runOpts *options.ServerConfig) {
})
})
r.Route("/admin", func(r chi.Router) {
r.Get("/repos", func(w http.ResponseWriter, r *http.Request) {
repos := []Repo{}
@ -116,13 +107,13 @@ func Route(r chi.Router, runOpts *options.ServerConfig) {
return
}
b, _ := json.Marshal(struct {
b, _ := json.MarshalIndent(struct {
Success bool `json:"success"`
jobs.Job
}{
Success: true,
Job: *j,
})
}, "", " ")
w.Write(append(b, '\n'))
})
@ -157,10 +148,6 @@ func Route(r chi.Router, runOpts *options.ServerConfig) {
})
r.Post("/jobs", func(w http.ResponseWriter, r *http.Request) {
defer func() {
_ = r.Body.Close()
}()
decoder := json.NewDecoder(r.Body)
msg := &jobs.KillMsg{}
if err := decoder.Decode(msg); nil != err {
@ -186,13 +173,6 @@ func Route(r chi.Router, runOpts *options.ServerConfig) {
))
})
r.Post("/jobs/{jobID}", func(w http.ResponseWriter, r *http.Request) {
// Attach additional logs / reports to running job
w.Write([]byte(
`{ "success": true }` + "\n",
))
})
r.Post("/promote", func(w http.ResponseWriter, r *http.Request) {
decoder := json.NewDecoder(r.Body)
msg := webhooks.Ref{}
@ -235,4 +215,60 @@ func Route(r chi.Router, runOpts *options.ServerConfig) {
})
})
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")
decoder := json.NewDecoder(r.Body)
report := &Report{}
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"))
})
})
})
}
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"`
}

107
internal/api/api_test.go Normal file
View File

@ -0,0 +1,107 @@
package api
import (
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"git.rootprojects.org/root/gitdeploy/internal/jobs"
"git.rootprojects.org/root/gitdeploy/internal/options"
"git.rootprojects.org/root/gitdeploy/internal/webhooks"
"github.com/go-chi/chi"
)
var server *httptest.Server
var runOpts *options.ServerConfig
var debounceDelay time.Duration
var jobDelay time.Duration
var logDir string
func init() {
tmpDir, _ := ioutil.TempDir("", "gitdeploy-*")
runOpts = &options.ServerConfig{
//Addr: "localhost:4483",
ScriptsPath: "./testdata",
LogDir: "./test-logs/debounce",
TmpDir: tmpDir,
DebounceDelay: 25 * time.Millisecond,
StaleJobAge: 5 * time.Minute,
StaleLogAge: 5 * time.Minute,
ExpiredLogAge: 10 * time.Minute,
}
logDir, _ = filepath.Abs(runOpts.LogDir)
r := chi.NewRouter()
server = httptest.NewServer(r)
runOpts.Addr = server.Listener.Addr().String()
RouteStopped(r, runOpts)
os.Setenv("GIT_DEPLOY_TEST_WAIT", "0.1")
debounceDelay = 50 * time.Millisecond
jobDelay = 250 * time.Millisecond
jobs.Start(runOpts)
//server.Close()
}
func TestCallback(t *testing.T) {
// TODO use full API request with local webhook
t7 := time.Now().Add(-40 * time.Second)
r7 := "ef1234abcd"
// skip debounce
hook := webhooks.Ref{
Timestamp: t7,
RepoID: "git.example.com/owner/repo",
HTTPSURL: "https://git.example.com/owner/repo.git",
Rev: r7,
RefName: "master",
RefType: "branch",
Owner: "owner",
Repo: "repo",
}
// , _ := json.MarshallIndent(&hook , "", " ")
jobs.Debounce(hook)
/*
body := bytes.NewReader(hook)
r := httptest.NewRequest("POST", "/api/local/webhook", body)
//dec := json.NewDecoder(r.Body)
//dec.Decode()
*/
t.Log("sleep so job can debounce, start, and finish")
time.Sleep(debounceDelay)
time.Sleep(jobDelay)
// TODO test that the API gives this back to us
urlRevID := hook.GetURLSafeRevID()
// TODO needs auth
reqURL := fmt.Sprintf("http://%s/api/admin/logs/%s",
runOpts.Addr,
string(urlRevID),
)
resp, err := http.Get(reqURL)
if nil != err {
t.Logf("[DEBUG] Response Error: %v", err)
return
}
t.Logf("[DEBUG] Request URL: %s", reqURL)
t.Logf("[DEBUG] Response Headers: %d %#v", resp.StatusCode, resp.Header)
b, err := ioutil.ReadAll(resp.Body)
if nil != err {
t.Logf("[DEBUG] Response Error: %v", err)
return
}
t.Logf("[DEBUG] Response Body: %v", string(b))
}

25
internal/api/testdata/deploy.sh vendored Normal file
View File

@ -0,0 +1,25 @@
#!/bin/bash
set -e
set -u
#set -x
echo "[${GIT_REPO_ID:-}#${GIT_REF_NAME:-}] Started at ${GIT_DEPLOY_TIMESTAMP:-}"
sleep ${GIT_DEPLOY_TEST_WAIT:-0.1}
echo "[${GIT_REPO_ID:-}#${GIT_REF_NAME:-}] Finished"
echo "Reporting to ${GIT_DEPLOY_CALLBACK_URL} ..."
curl -fsSL "${GIT_DEPLOY_CALLBACK_URL}" \
-H 'Content-Type: application/json' \
-d '
{ "report":
{ "name": "sleep test",
"status": "PASS",
"message": "a top level result group",
"results": [
{ "name": "sub test", "status": "PASS", "message": "a sub group", "detail": "logs or smth" }
]
}
}
'
echo "[${GIT_REPO_ID:-}#${GIT_REF_NAME:-}] Generated Report"

View File

@ -42,13 +42,11 @@ func debounce(hook *webhooks.Ref, runOpts *options.ServerConfig) {
}
// this will not cause a mutual lock because it is async
debounceTimers[refID] = time.AfterFunc(runOpts.DebounceDelay, func() {
//fmt.Println("DEBUG [1] wait for jobs and timers")
jobsTimersMux.Lock()
delete(debounceTimers, refID)
jobsTimersMux.Unlock()
debounced <- hook
//fmt.Println("DEBUG [1] release jobs and timers")
})
}
@ -181,7 +179,7 @@ func run(curHook *webhooks.Ref, runOpts *options.ServerConfig) {
}
// TODO jobs.New()
// Sets cmd.Stdout and cmd.Stderr
f := setOutput(runOpts.LogDir, j)
txtFile := setOutput(runOpts.LogDir, j)
if err := cmd.Start(); nil != err {
log.Printf("gitdeploy exec error: %s\n", err)
@ -197,28 +195,32 @@ func run(curHook *webhooks.Ref, runOpts *options.ServerConfig) {
} else {
log.Printf("gitdeploy job for %s#%s finished\n", hook.HTTPSURL, hook.RefName)
}
if nil != f {
_ = f.Close()
if nil != txtFile {
_ = txtFile.Close()
}
// TODO move to deathRow only?
updateExitStatus(j)
// Switch ID to the more specific RevID
j.ID = string(j.GitRef.GetRevID())
// replace the text log with a json log
if f, err := getJobFile(runOpts.LogDir, j.GitRef, ".json"); nil != err {
// f.Name() should be the full path
if jsonFile, err := getJobFile(runOpts.LogDir, j.GitRef, ".json"); nil != err {
// jsonFile.Name() should be the full path
log.Printf("[warn] could not create log file '%s': %v", runOpts.LogDir, err)
} else {
enc := json.NewEncoder(f)
enc := json.NewEncoder(jsonFile)
enc.SetIndent("", " ")
if err := enc.Encode(j); nil != err {
log.Printf("[warn] could not encode json log '%s': %v", f.Name(), err)
log.Printf("[warn] could not encode json log '%s': %v", jsonFile.Name(), err)
} else {
logdir, logname, _ := getJobFilePath(runOpts.LogDir, j.GitRef, ".log")
_ = os.Remove(filepath.Join(logdir, logname))
}
_ = f.Close()
log.Printf("[DEBUG] wrote log to %s", f.Name())
_ = jsonFile.Close()
}
// TODO move to deathRow only?
j.Logs = []Log{}
// this will completely clear the finished job

View File

@ -19,6 +19,30 @@ import (
var initialized = false
var done = make(chan struct{})
// Promotions channel
var Promotions = make(chan Promotion)
// Pending is the map of backlog jobs
// map[webhooks.RefID]*webhooks.GitRef
var Pending sync.Map
// Actives is the map of jobs
// map[webhooks.RefID]*Job
var Actives sync.Map
// Recents are jobs that are dead, but recent
// map[webhooks.RevID]*Job
var Recents sync.Map
// deathRow is for jobs to be killed
var deathRow = make(chan webhooks.RefID)
// debounced is for jobs that are ready to run
var debounced = make(chan *webhooks.Ref)
// debacklog is for debouncing without saving in the backlog
var debacklog = make(chan *webhooks.Ref)
// Start starts the job loop, channels, and cleanup routines
func Start(runOpts *options.ServerConfig) {
go Run(runOpts)
@ -82,36 +106,12 @@ func Stop() {
initialized = false
}
// Promotions channel
var Promotions = make(chan Promotion)
// Promotion is a channel message
type Promotion struct {
PromoteTo string
GitRef *webhooks.Ref
}
// Pending is the map of backlog jobs
// map[webhooks.RefID]*webhooks.GitRef
var Pending sync.Map
// Actives is the map of jobs
// map[webhooks.RefID]*Job
var Actives sync.Map
// Recents are jobs that are dead, but recent
// map[webhooks.RevID]*Job
var Recents sync.Map
// deathRow is for jobs to be killed
var deathRow = make(chan webhooks.RefID)
// debounced is for jobs that are ready to run
var debounced = make(chan *webhooks.Ref)
// debacklog is for debouncing without saving in the backlog
var debacklog = make(chan *webhooks.Ref)
// KillMsg describes which job to kill
type KillMsg struct {
JobID string `json:"job_id"`
@ -130,14 +130,18 @@ type Job struct {
EndedAt time.Time `json:"ended_at,omitempty"` // empty when running
// extra
Logs []Log `json:"logs"` // exist when requested
Report Report `json:"report,omitempty"` // empty unless given
Report *Report `json:"report,omitempty"` // empty unless given
Cmd *exec.Cmd `json:"-"`
mux sync.Mutex `json:"-"`
}
// Report should have many items
type Report struct {
Results []string `json:"results"`
Name string `json:"name"`
Status string `json:"status,omitempty"`
Message string `json:"message,omitempty"`
Detail string `json:"detail,omitempty"`
Results []Report `json:"results,omitempty"`
}
// All returns all jobs, including active, recent, and (TODO) historical
@ -195,6 +199,26 @@ func All() []*Job {
return jobsCopy
}
// SetReport will update jobs' logs
func SetReport(urlRefID webhooks.URLSafeRefID, report *Report) error {
b, err := base64.RawURLEncoding.DecodeString(string(urlRefID))
if nil != err {
return err
}
refID := webhooks.RefID(b)
value, ok := Actives.Load(refID)
if !ok {
return errors.New("active job not found by " + string(refID))
}
job := value.(*Job)
job.Report = report
Actives.Store(refID, job)
return nil
}
// Remove will put a job on death row
func Remove(gitID webhooks.URLSafeRefID /*, nokill bool*/) {
activeID, err :=
@ -213,7 +237,7 @@ func getEnvs(addr, activeID string, repoList string, hook *webhooks.Ref) []strin
envs := []string{
"GIT_DEPLOY_JOB_ID=" + activeID,
"GIT_DEPLOY_TIMESTAMP=" + hook.Timestamp.Format(time.RFC3339),
"GIT_DEPLOY_CALLBACK_URL=" + "http://localhost:" + port + "/api/jobs/" + activeID,
"GIT_DEPLOY_CALLBACK_URL=" + "http://localhost:" + port + "/api/local/jobs/" + string(hook.GetURLSafeRefID()),
"GIT_REF_NAME=" + hook.RefName,
"GIT_REF_TYPE=" + hook.RefType,
"GIT_REPO_ID=" + hook.RepoID,
@ -321,25 +345,32 @@ func remove(activeID webhooks.RefID /*, nokill bool*/) {
value, ok := Actives.Load(activeID)
if !ok {
log.Printf("[warn] could not find job to kill by RefID %s", activeID)
return
}
job := value.(*Job)
Actives.Delete(activeID)
// JSON should have been written to disk by this point
job.Logs = []Log{}
// transition to RevID for non-active, non-pending jobs
job.ID = string(job.GitRef.GetRevID())
Recents.Store(job.GitRef.GetRevID(), job)
updateExitStatus(job)
// JSON should have been written to disk by this point
job.Logs = []Log{}
}
func updateExitStatus(job *Job) {
if nil == job.Cmd.ProcessState {
// is not yet finished
if nil != job.Cmd.Process {
// but definitely was started
err := job.Cmd.Process.Kill()
if err := job.Cmd.Process.Kill(); nil != err {
log.Printf("error killing job:\n%v", err)
}
}
}
if nil != job.Cmd.ProcessState {
//*job.ExitCode = job.Cmd.ProcessState.ExitCode()
exitCode := job.Cmd.ProcessState.ExitCode()

View File

@ -90,7 +90,6 @@ func TestDebounce(t *testing.T) {
Repo: "repo",
})
// TODO make debounce time configurable
t.Log("sleep so job can debounce and start")
time.Sleep(debounceDelay)
@ -227,7 +226,6 @@ func TestRecents(t *testing.T) {
}
Debounce(hook)
// TODO make debounce time configurable
t.Log("sleep so job can debounce and start")
time.Sleep(debounceDelay)
time.Sleep(jobDelay)
@ -254,6 +252,14 @@ func TestRecents(t *testing.T) {
return
}
if nil == j.ExitCode || 0 != *j.ExitCode {
t.Errorf("should zero exit status")
t.Fail()
return
}
t.Logf("[DEBUG] Report:\n%#v", j.Report)
t.Logf("Logs:\n%v", err)
//Stop()

View File

@ -85,7 +85,7 @@ func WalkLogs(runOpts *options.ServerConfig) ([]*Job, error) {
// ExpiredLogAge can be 0 for testing,
// even when StaleLogAge is > 0
if age >= runOpts.ExpiredLogAge {
log.Printf("[DEBUG] remove log file: %s", logpath)
log.Printf("[info] remove log file: %s", logpath)
os.Remove(logpath)
}
@ -95,6 +95,8 @@ func WalkLogs(runOpts *options.ServerConfig) ([]*Job, error) {
return oldJobs, err
}
//func GetReport(runOpts *options.ServerConfig, safeID webhooks.URLSafeGitID) (*Job, error) {}
// LoadLogs will log logs for a job
func LoadLogs(runOpts *options.ServerConfig, safeID webhooks.URLSafeGitID) (*Job, error) {
b, err := base64.RawURLEncoding.DecodeString(string(safeID))
@ -127,7 +129,6 @@ func LoadLogs(runOpts *options.ServerConfig, safeID webhooks.URLSafeGitID) (*Job
dec := json.NewDecoder(f)
j := &Job{}
if err := dec.Decode(j); nil != err {
log.Printf("[DEBUG] decode error: %v", err)
return nil, errors.New("couldn't read log file")
}
j.ID = string(gitID)

View File

@ -1,13 +1,8 @@
#!/bin/bash
set -e
set -u
set -x
#set -x
echo "[${GIT_REPO_ID:-}#${GIT_REF_NAME:-}] Started at ${GIT_DEPLOY_TIMESTAMP:-}"
sleep ${GIT_DEPLOY_TEST_WAIT:-0.1}
echo "[${GIT_REPO_ID:-}#${GIT_REF_NAME:-}] Finished"
# TODO start/end? duration?
#curl -X POST "${GIT_DEPLOY_CALLBACK_URL}" -d '
# { "report": [ { "name":"sleep", "code":"PASS", "message":"", "details":"" } ] }
#'