move api to own file, add backlog

This commit is contained in:
AJ ONeal 2020-11-21 05:41:01 -07:00
parent 6b85b07c0d
commit d17d521e4a
4 changed files with 500 additions and 399 deletions

457
internal/api/api.go Normal file
View File

@ -0,0 +1,457 @@
package api
import (
"encoding/base64"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"git.rootprojects.org/root/gitdeploy/internal/options"
"git.rootprojects.org/root/gitdeploy/internal/webhooks"
"github.com/go-chi/chi"
)
type job struct {
ID string // {HTTPSURL}#{BRANCH}
Cmd *exec.Cmd
GitRef webhooks.Ref
CreatedAt time.Time
}
var jobs = make(map[string]*job)
var killers = make(chan string)
var tmpDir string
// Job is the JSON we send back through the API about jobs
type Job struct {
JobID string `json:"job_id"`
CreatedAt time.Time `json:"created_at"`
GitRef webhooks.Ref `json:"ref"`
Promote bool `json:"promote,omitempty"`
}
// KillMsg describes which job to kill
type KillMsg struct {
JobID string `json:"job_id"`
Kill bool `json:"kill"`
}
func init() {
var err error
tmpDir, err = ioutil.TempDir("", "gitdeploy-*")
if nil != err {
fmt.Fprintf(os.Stderr, "could not create temporary directory")
os.Exit(1)
return
}
log.Printf("TEMP_DIR=%s", tmpDir)
}
// Route will set up the API and such
func Route(r chi.Router, runOpts *options.ServerConfig) {
go func() {
// TODO read from backlog
for {
//hook := webhooks.Accept()
select {
case hook := <-webhooks.Hooks:
runHook(hook, runOpts)
case jobID := <-killers:
remove(jobID, false)
}
}
}()
webhooks.RouteHandlers(r)
r.Route("/api/admin", 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)
// TODO admin auth middleware
log.Println("TODO: handle authentication")
next.ServeHTTP(w, r)
})
})
r.Get("/repos", func(w http.ResponseWriter, r *http.Request) {
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.Header().Set("Content-Type", "application/json")
w.Write(append(b, '\n'))
})
r.Get("/jobs", func(w http.ResponseWriter, r *http.Request) {
// again, possible race condition, but not one that much matters
jjobs := []Job{}
for jobID, job := range jobs {
jjobs = append(jjobs, Job{
JobID: jobID,
GitRef: job.GitRef,
CreatedAt: job.CreatedAt,
})
}
b, _ := json.Marshal(struct {
Success bool `json:"success"`
Jobs []Job `json:"jobs"`
}{
Success: true,
Jobs: jjobs,
})
w.Write(append(b, '\n'))
})
r.Post("/jobs", func(w http.ResponseWriter, r *http.Request) {
defer func() {
_ = r.Body.Close()
}()
decoder := json.NewDecoder(r.Body)
msg := &KillMsg{}
if err := decoder.Decode(msg); nil != err {
log.Println("kill job invalid json:", err)
http.Error(w, "invalid json body", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
// possible race condition, but not the kind that should matter
if _, exists := jobs[msg.JobID]; !exists {
w.Write([]byte(
`{ "success": false, "error": "job does not exist" }` + "\n",
))
return
}
// killing a job *should* always succeed ...right?
killers <- 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.Println("promotion job invalid json:", err)
http.Error(w, "invalid json body", http.StatusBadRequest)
return
}
if "" == msg.HTTPSURL || "" == msg.RefName {
log.Println("promotion job incomplete json", 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.Println("promotion job invalid: cannot promote:", n)
http.Error(w, "invalid promotion", http.StatusBadRequest)
return
}
promoteTo := runOpts.Promotions[n]
runPromote(*msg, promoteTo, runOpts)
b, _ := json.Marshal(struct {
Success bool `json:"success"`
PromoteTo string `json:"promote_to"`
}{
Success: true,
PromoteTo: promoteTo,
})
w.Write(append(b, '\n'))
})
})
}
func runHook(hook webhooks.Ref, runOpts *options.ServerConfig) {
fmt.Printf("%#v\n", hook)
jobID := base64.RawURLEncoding.EncodeToString([]byte(
fmt.Sprintf("%s#%s", hook.HTTPSURL, hook.RefName),
))
args := []string{
runOpts.ScriptsPath + "/deploy.sh",
jobID,
hook.RefName,
hook.RefType,
hook.Owner,
hook.Repo,
hook.HTTPSURL,
}
cmd := exec.Command("bash", args...)
// https://git.example.com/example/project.git
// => git.example.com/example/project
repoID := strings.TrimPrefix(hook.HTTPSURL, "https://")
repoID = strings.TrimPrefix(repoID, "https://")
repoID = strings.TrimSuffix(repoID, ".git")
jobName := fmt.Sprintf("%s#%s", strings.ReplaceAll(repoID, "/", "-"), hook.RefName)
env := os.Environ()
envs := []string{
"GIT_DEPLOY_JOB_ID=" + jobID,
"GIT_REF_NAME=" + hook.RefName,
"GIT_REF_TYPE=" + hook.RefType,
"GIT_REPO_ID=" + repoID,
"GIT_REPO_OWNER=" + hook.Owner,
"GIT_REPO_NAME=" + hook.Repo,
"GIT_CLONE_URL=" + hook.HTTPSURL, // deprecated
"GIT_HTTPS_URL=" + hook.HTTPSURL,
"GIT_SSH_URL=" + hook.SSHURL,
}
for _, repo := range strings.Fields(runOpts.RepoList) {
last := len(repo) - 1
if len(repo) < 0 {
continue
}
repoID = strings.ToLower(repoID)
if '*' == repo[last] {
// Wildcard match a prefix, for example:
// github.com/whatever/* MATCHES github.com/whatever/foo
// github.com/whatever/ProjectX-* MATCHES github.com/whatever/ProjectX-Foo
if strings.HasPrefix(repoID, repo[:last]) {
envs = append(envs, "GIT_REPO_TRUSTED=true")
break
}
} else if repo == repoID {
envs = append(envs, "GIT_REPO_TRUSTED=true")
break
}
}
cmd.Env = append(env, envs...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if _, exists := jobs[jobID]; exists {
saveBacklog(hook, jobName, jobID)
log.Printf("[runHook] gitdeploy job already started for %s#%s\n", hook.HTTPSURL, hook.RefName)
return
}
if err := cmd.Start(); nil != err {
log.Printf("gitdeploy exec error: %s\n", err)
return
}
jobs[jobID] = &job{
ID: jobID,
Cmd: cmd,
GitRef: hook,
CreatedAt: time.Now(),
}
go func() {
log.Printf("gitdeploy job for %s#%s started\n", hook.HTTPSURL, hook.RefName)
if err := cmd.Wait(); nil != err {
log.Printf("gitdeploy job for %s#%s exited with error: %v", hook.HTTPSURL, hook.RefName, err)
} else {
log.Printf("gitdeploy job for %s#%s finished\n", hook.HTTPSURL, hook.RefName)
}
remove(jobID, true)
restoreBacklog(jobName, jobID)
}()
}
func remove(jobID string, nokill bool) {
job, exists := jobs[jobID]
if !exists {
return
}
delete(jobs, jobID)
if nil != job.Cmd.ProcessState {
// is not yet finished
if nil != job.Cmd.Process {
// but definitely was started
err := job.Cmd.Process.Kill()
log.Println("error killing job:", err)
}
}
}
func runPromote(hook webhooks.Ref, promoteTo string, runOpts *options.ServerConfig) {
// TODO create an origin-branch tag with a timestamp?
jobID1 := base64.RawURLEncoding.EncodeToString([]byte(
fmt.Sprintf("%s#%s", hook.HTTPSURL, hook.RefName),
))
jobID2 := base64.RawURLEncoding.EncodeToString([]byte(
fmt.Sprintf("%s#%s", hook.HTTPSURL, promoteTo),
))
args := []string{
runOpts.ScriptsPath + "/promote.sh",
jobID1,
promoteTo,
hook.RefName,
hook.RefType,
hook.Owner,
hook.Repo,
hook.HTTPSURL,
}
cmd := exec.Command("bash", args...)
env := os.Environ()
envs := []string{
"GIT_DEPLOY_JOB_ID=" + jobID1,
"GIT_DEPLOY_PROMOTE_TO=" + promoteTo,
"GIT_REF_NAME=" + hook.RefName,
"GIT_REF_TYPE=" + hook.RefType,
"GIT_REPO_OWNER=" + hook.Owner,
"GIT_REPO_NAME=" + hook.Repo,
"GIT_CLONE_URL=" + hook.HTTPSURL,
}
cmd.Env = append(env, envs...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if _, exists := jobs[jobID1]; exists {
// TODO put promote in backlog
log.Printf("[promote] gitdeploy job already started for %s#%s\n", hook.HTTPSURL, hook.RefName)
return
}
if _, exists := jobs[jobID2]; exists {
// TODO put promote in backlog
log.Printf("[promote] gitdeploy job already started for %s#%s\n", hook.HTTPSURL, promoteTo)
return
}
if err := cmd.Start(); nil != err {
log.Printf("gitdeploy exec error: %s\n", err)
return
}
jobs[jobID1] = &job{
ID: jobID2,
Cmd: cmd,
GitRef: hook,
CreatedAt: time.Now(),
}
jobs[jobID2] = &job{
ID: jobID2,
Cmd: cmd,
GitRef: hook,
CreatedAt: time.Now(),
}
go func() {
log.Printf("gitdeploy promote for %s#%s started\n", hook.HTTPSURL, hook.RefName)
_ = cmd.Wait()
killers <- jobID1
killers <- jobID2
log.Printf("gitdeploy promote for %s#%s finished\n", hook.HTTPSURL, hook.RefName)
// TODO check for backlog
}()
}
func saveBacklog(hook webhooks.Ref, jobName, jobID string) {
b, _ := json.MarshalIndent(hook, "", " ")
f, err := ioutil.TempFile(tmpDir, "tmp-*")
if nil != err {
log.Printf("[warn] could not create backlog file for %s:\n%v", jobID, err)
return
}
if _, err := f.Write(b); nil != err {
log.Printf("[warn] could not write backlog file for %s:\n%v", jobID, err)
return
}
jobFile := filepath.Join(tmpDir, jobName)
_ = os.Remove(jobFile)
if err := os.Rename(f.Name(), jobFile); nil != err {
log.Printf("[warn] could not rename file %s => %s:\n%v", f.Name(), jobFile, err)
return
}
log.Printf("[BACKLOG] new backlog job for %s", jobName)
}
func restoreBacklog(jobName, jobID string) {
jobFile := filepath.Join(tmpDir, jobName)
_ = os.Remove(jobFile + ".cur")
_ = os.Rename(jobFile, jobFile+".cur")
b, err := ioutil.ReadFile(jobFile + ".cur")
if nil != err {
if !os.IsNotExist(err) {
log.Printf("[warn] could not create backlog file for %s:\n%v", jobID, err)
}
// doesn't exist => no backlog
log.Printf("[NO BACKLOG] no backlog items for %s", jobName)
return
}
ref := webhooks.Ref{}
if err := json.Unmarshal(b, &ref); nil != err {
log.Printf("[warn] could not parse backlog file for %s:\n%v", jobID, err)
return
}
log.Printf("[BACKLOG] pop backlog for %s", jobName)
webhooks.Hook(ref)
}
// 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"`
}

View File

@ -13,6 +13,7 @@ type ServerConfig struct {
Compress bool Compress bool
ServePath string ServePath string
ScriptsPath string ScriptsPath string
Promotions []string
} }
var ServerFlags *flag.FlagSet var ServerFlags *flag.FlagSet

View File

@ -16,45 +16,55 @@ import (
// Repo ex: example // Repo ex: example
// Org ex: example // Org ex: example
type Ref struct { type Ref struct {
HTTPSURL string `json:"clone_url"` HTTPSURL string `json:"https_url"`
SSHURL string `json:"-"` SSHURL string `json:"ssh_url"`
Rev string `json:"-"` Rev string `json:"rev"`
Ref string `json:"-"` // refs/tags/v0.0.1, refs/heads/master Ref string `json:"ref"` // refs/tags/v0.0.1, refs/heads/master
RefType string `json:"ref_type"` // tag, branch RefType string `json:"ref_type"` // tag, branch
RefName string `json:"ref_name"` RefName string `json:"ref_name"`
Branch string `json:"-"` Branch string `json:"branch"`
Tag string `json:"-"` Tag string `json:"tag"`
Owner string `json:"repo_owner"` Owner string `json:"repo_owner"`
Repo string `json:"repo_name"` Repo string `json:"repo_name"`
} }
// Providers is a map of the git webhook providers
var Providers = make(map[string]func()) var Providers = make(map[string]func())
// Webhooks is a map of routes
var Webhooks = make(map[string]func(chi.Router)) var Webhooks = make(map[string]func(chi.Router))
// Hooks is a channel of incoming webhooks
var Hooks = make(chan Ref) var Hooks = make(chan Ref)
// Hook will put a Git Ref on the queue
func Hook(r Ref) { func Hook(r Ref) {
Hooks <- r Hooks <- r
} }
// Accept will pop a Git Ref off the queue
func Accept() Ref { func Accept() Ref {
return <-Hooks return <-Hooks
} }
// AddProvider registers a git webhook provider
func AddProvider(name string, initProvider func()) { func AddProvider(name string, initProvider func()) {
Providers[name] = initProvider Providers[name] = initProvider
} }
// AddRouteHandler registers a git webhook route
func AddRouteHandler(name string, route func(router chi.Router)) { func AddRouteHandler(name string, route func(router chi.Router)) {
Webhooks[name] = route Webhooks[name] = route
} }
// MustRegisterAll registers all webhook route functions
func MustRegisterAll() { func MustRegisterAll() {
for _, addHandler := range Providers { for _, addHandler := range Providers {
addHandler() addHandler()
} }
} }
// RouteHandlers registers the webhook functions to the route
func RouteHandlers(r chi.Router) { func RouteHandlers(r chi.Router) {
r.Route("/api/webhooks", func(r chi.Router) { r.Route("/api/webhooks", func(r chi.Router) {
for provider, handler := range Webhooks { for provider, handler := range Webhooks {
@ -65,6 +75,7 @@ func RouteHandlers(r chi.Router) {
}) })
} }
// ParseSecrets grabs secrets from the ENV at runtime
func ParseSecrets(providername, secretList, envname string) [][]byte { func ParseSecrets(providername, secretList, envname string) [][]byte {
if 0 == len(secretList) { if 0 == len(secretList) {
secretList = os.Getenv(envname) secretList = os.Getenv(envname)

418
main.go
View File

@ -2,26 +2,26 @@ package main
import ( import (
"compress/flate" "compress/flate"
"encoding/base64"
"encoding/json" "encoding/json"
"flag" "flag"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"git.rootprojects.org/root/gitdeploy/assets/examples" "git.rootprojects.org/root/gitdeploy/assets/examples"
"git.rootprojects.org/root/gitdeploy/assets/public" "git.rootprojects.org/root/gitdeploy/assets/public"
"git.rootprojects.org/root/gitdeploy/internal/api"
"git.rootprojects.org/root/gitdeploy/internal/options" "git.rootprojects.org/root/gitdeploy/internal/options"
"git.rootprojects.org/root/gitdeploy/internal/webhooks" "git.rootprojects.org/root/gitdeploy/internal/webhooks"
"git.rootprojects.org/root/vfscopy" "git.rootprojects.org/root/vfscopy"
"github.com/go-chi/chi" "github.com/go-chi/chi"
"github.com/go-chi/chi/middleware" "github.com/go-chi/chi/middleware"
_ "github.com/joho/godotenv/autoload" _ "github.com/joho/godotenv/autoload"
) )
@ -44,16 +44,6 @@ func ver() string {
return fmt.Sprintf("%s v%s (%s) %s", name, version, commit[:7], date) return fmt.Sprintf("%s v%s (%s) %s", name, version, commit[:7], date)
} }
type job struct {
ID string // {HTTPSURL}#{BRANCH}
Cmd *exec.Cmd
Ref webhooks.Ref
CreatedAt time.Time
}
var jobs = make(map[string]*job)
var killers = make(chan string)
var runOpts *options.ServerConfig var runOpts *options.ServerConfig
var runFlags *flag.FlagSet var runFlags *flag.FlagSet
var initFlags *flag.FlagSet var initFlags *flag.FlagSet
@ -171,13 +161,16 @@ func main() {
runOpts.RepoList = strings.ReplaceAll(runOpts.RepoList, " ", " ") runOpts.RepoList = strings.ReplaceAll(runOpts.RepoList, " ", " ")
runOpts.RepoList = strings.ToLower(runOpts.RepoList) runOpts.RepoList = strings.ToLower(runOpts.RepoList)
} }
cwd, _ := os.Getwd()
log.Printf("WORK_DIR=%s", cwd)
log.Printf("TRUST_REPOS=%s", runOpts.RepoList)
if 0 == len(promotionList) { if 0 == len(promotionList) {
promotionList = os.Getenv("PROMOTIONS") promotionList = os.Getenv("PROMOTIONS")
} }
if 0 == len(promotionList) { if 0 == len(promotionList) {
promotionList = defaultPromotionList promotionList = defaultPromotionList
} }
promotions = strings.Fields( runOpts.Promotions = strings.Fields(
strings.ReplaceAll(promotionList, ",", " "), strings.ReplaceAll(promotionList, ",", " "),
) )
@ -190,20 +183,6 @@ func main() {
} }
} }
// Job is the JSON we send back through the API about jobs
type Job struct {
JobID string `json:"job_id"`
CreatedAt time.Time `json:"created_at"`
Ref webhooks.Ref `json:"ref"`
Promote bool `json:"promote,omitempty"`
}
// KillMsg describes which job to kill
type KillMsg struct {
JobID string `json:"job_id"`
Kill bool `json:"kill"`
}
func gdInit() { func gdInit() {
vfs := vfscopy.NewVFS(examples.Assets) vfs := vfscopy.NewVFS(examples.Assets)
_, err := os.Open("scripts") _, err := os.Open("scripts")
@ -260,7 +239,25 @@ func serve() {
} }
r.Use(middleware.Logger) r.Use(middleware.Logger)
r.Use(middleware.Recoverer) r.Use(middleware.Recoverer)
r.Use(middleware.Recoverer)
r.Get("/version", func(w http.ResponseWriter, r *http.Request) {
w.Write(append([]byte(ver()), '\n'))
})
r.Get("/api/version", func(w http.ResponseWriter, r *http.Request) {
b, _ := json.MarshalIndent(struct {
Name string `json:"name"`
Version string `json:"version"`
Date string `json:"date"`
Commit string `json:"commit"`
}{
Name: name,
Version: version,
Date: date,
Commit: commit,
}, "", " ")
w.Write(append(b, '\n'))
})
api.Route(r, runOpts)
var staticHandler http.HandlerFunc var staticHandler http.HandlerFunc
pub := http.FileServer(public.Assets) pub := http.FileServer(public.Assets)
@ -281,167 +278,6 @@ func serve() {
pub.ServeHTTP(w, r) pub.ServeHTTP(w, r)
} }
} }
webhooks.RouteHandlers(r)
r.Get("/version", func(w http.ResponseWriter, r *http.Request) {
w.Write(append([]byte(ver()), '\n'))
})
r.Get("/api/version", func(w http.ResponseWriter, r *http.Request) {
b, _ := json.MarshalIndent(struct {
Name string `json:"name"`
Version string `json:"version"`
Date string `json:"date"`
Commit string `json:"commit"`
}{
Name: name,
Version: version,
Date: date,
Commit: commit,
}, "", " ")
w.Write(append(b, '\n'))
})
r.Route("/api/admin", 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)
// TODO admin auth middleware
log.Println("TODO: handle authentication")
next.ServeHTTP(w, r)
})
})
r.Get("/repos", func(w http.ResponseWriter, r *http.Request) {
repos := []Repo{}
for _, id := range strings.Fields(runOpts.RepoList) {
repos = append(repos, Repo{
ID: id,
CloneURL: fmt.Sprintf("https://%s.git", id),
Promotions: 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: 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.Header().Set("Content-Type", "application/json")
w.Write(append(b, '\n'))
})
r.Get("/jobs", func(w http.ResponseWriter, r *http.Request) {
// again, possible race condition, but not one that much matters
jjobs := []Job{}
for jobID, job := range jobs {
jjobs = append(jjobs, Job{
JobID: jobID,
Ref: job.Ref,
CreatedAt: job.CreatedAt,
})
}
b, _ := json.Marshal(struct {
Success bool `json:"success"`
Jobs []Job `json:"jobs"`
}{
Success: true,
Jobs: jjobs,
})
w.Write(append(b, '\n'))
})
r.Post("/jobs", func(w http.ResponseWriter, r *http.Request) {
defer func() {
_ = r.Body.Close()
}()
decoder := json.NewDecoder(r.Body)
msg := &KillMsg{}
if err := decoder.Decode(msg); nil != err {
log.Println("kill job invalid json:", err)
http.Error(w, "invalid json body", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
// possible race condition, but not the kind that should matter
if _, exists := jobs[msg.JobID]; !exists {
w.Write([]byte(
`{ "success": false, "error": "job does not exist" }` + "\n",
))
return
}
// killing a job *should* always succeed ...right?
killers <- 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.Println("promotion job invalid json:", err)
http.Error(w, "invalid json body", http.StatusBadRequest)
return
}
if "" == msg.HTTPSURL || "" == msg.RefName {
log.Println("promotion job incomplete json", msg)
http.Error(w, "incomplete json body", http.StatusBadRequest)
return
}
n := -2
for i := range promotions {
if promotions[i] == msg.RefName {
n = i - 1
break
}
}
if n < 0 {
log.Println("promotion job invalid: cannot promote:", n)
http.Error(w, "invalid promotion", http.StatusBadRequest)
return
}
promoteTo := promotions[n]
runPromote(*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.Get("/*", staticHandler) r.Get("/*", staticHandler)
fmt.Println("Listening for http (with reasonable timeouts) on", runOpts.Addr) fmt.Println("Listening for http (with reasonable timeouts) on", runOpts.Addr)
@ -453,213 +289,9 @@ func serve() {
WriteTimeout: 20 * time.Second, WriteTimeout: 20 * time.Second,
MaxHeaderBytes: 1024 * 1024, // 1MiB MaxHeaderBytes: 1024 * 1024, // 1MiB
} }
go func() {
// TODO read from backlog
for {
//hook := webhooks.Accept()
select {
case hook := <-webhooks.Hooks:
runHook(hook)
case jobID := <-killers:
remove(jobID, false)
}
}
}()
if err := srv.ListenAndServe(); nil != err { if err := srv.ListenAndServe(); nil != err {
fmt.Fprintf(os.Stderr, "%s", err) fmt.Fprintf(os.Stderr, "%s", err)
os.Exit(1) os.Exit(1)
return return
} }
} }
func runHook(hook webhooks.Ref) {
fmt.Printf("%#v\n", hook)
jobID := base64.RawURLEncoding.EncodeToString([]byte(
fmt.Sprintf("%s#%s", hook.HTTPSURL, hook.RefName),
))
args := []string{
runOpts.ScriptsPath + "/deploy.sh",
jobID,
hook.RefName,
hook.RefType,
hook.Owner,
hook.Repo,
hook.HTTPSURL,
}
cmd := exec.Command("bash", args...)
// https://git.example.com/example/project.git
// => git.example.com/example/project
repoID := strings.TrimPrefix(hook.HTTPSURL, "https://")
repoID = strings.TrimPrefix(repoID, "https://")
repoID = strings.TrimSuffix(repoID, ".git")
env := os.Environ()
envs := []string{
"GIT_DEPLOY_JOB_ID=" + jobID,
"GIT_REF_NAME=" + hook.RefName,
"GIT_REF_TYPE=" + hook.RefType,
"GIT_REPO_ID=" + repoID,
"GIT_REPO_OWNER=" + hook.Owner,
"GIT_REPO_NAME=" + hook.Repo,
"GIT_CLONE_URL=" + hook.HTTPSURL,
}
for _, repo := range strings.Fields(runOpts.RepoList) {
last := len(repo) - 1
if len(repo) < 0 {
continue
}
repoID = strings.ToLower(repoID)
if '*' == repo[last] {
// Wildcard match a prefix, for example:
// github.com/whatever/* MATCHES github.com/whatever/foo
// github.com/whatever/ProjectX-* MATCHES github.com/whatever/ProjectX-Foo
if strings.HasPrefix(repoID, repo[:last]) {
envs = append(envs, "GIT_REPO_TRUSTED=true")
break
}
} else if repo == repoID {
envs = append(envs, "GIT_REPO_TRUSTED=true")
break
}
}
cmd.Env = append(env, envs...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if _, exists := jobs[jobID]; exists {
// TODO put job in backlog
log.Printf("gitdeploy job already started for %s#%s\n", hook.HTTPSURL, hook.RefName)
return
}
if err := cmd.Start(); nil != err {
log.Printf("gitdeploy exec error: %s\n", err)
return
}
jobs[jobID] = &job{
ID: jobID,
Cmd: cmd,
Ref: hook,
CreatedAt: time.Now(),
}
go func() {
log.Printf("gitdeploy job for %s#%s started\n", hook.HTTPSURL, hook.RefName)
if err := cmd.Wait(); nil != err {
log.Printf("gitdeploy job for %s#%s exited with error: %v", hook.HTTPSURL, hook.RefName, err)
} else {
log.Printf("gitdeploy job for %s#%s finished\n", hook.HTTPSURL, hook.RefName)
}
remove(jobID, true)
// TODO check for backlog
}()
}
func remove(jobID string, nokill bool) {
job, exists := jobs[jobID]
if !exists {
return
}
delete(jobs, jobID)
if nil != job.Cmd.ProcessState {
// is not yet finished
if nil != job.Cmd.Process {
// but definitely was started
err := job.Cmd.Process.Kill()
log.Println("error killing job:", err)
}
}
}
func runPromote(hook webhooks.Ref, promoteTo string) {
// TODO create an origin-branch tag with a timestamp?
jobID1 := base64.RawURLEncoding.EncodeToString([]byte(
fmt.Sprintf("%s#%s", hook.HTTPSURL, hook.RefName),
))
jobID2 := base64.RawURLEncoding.EncodeToString([]byte(
fmt.Sprintf("%s#%s", hook.HTTPSURL, promoteTo),
))
args := []string{
runOpts.ScriptsPath + "/promote.sh",
jobID1,
promoteTo,
hook.RefName,
hook.RefType,
hook.Owner,
hook.Repo,
hook.HTTPSURL,
}
cmd := exec.Command("bash", args...)
env := os.Environ()
envs := []string{
"GIT_DEPLOY_JOB_ID=" + jobID1,
"GIT_DEPLOY_PROMOTE_TO=" + promoteTo,
"GIT_REF_NAME=" + hook.RefName,
"GIT_REF_TYPE=" + hook.RefType,
"GIT_REPO_OWNER=" + hook.Owner,
"GIT_REPO_NAME=" + hook.Repo,
"GIT_CLONE_URL=" + hook.HTTPSURL,
}
cmd.Env = append(env, envs...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if _, exists := jobs[jobID1]; exists {
// TODO put promote in backlog
log.Printf("gitdeploy job already started for %s#%s\n", hook.HTTPSURL, hook.RefName)
return
}
if _, exists := jobs[jobID2]; exists {
// TODO put promote in backlog
log.Printf("gitdeploy job already started for %s#%s\n", hook.HTTPSURL, promoteTo)
return
}
if err := cmd.Start(); nil != err {
log.Printf("gitdeploy exec error: %s\n", err)
return
}
jobs[jobID1] = &job{
ID: jobID2,
Cmd: cmd,
Ref: hook,
CreatedAt: time.Now(),
}
jobs[jobID2] = &job{
ID: jobID2,
Cmd: cmd,
Ref: hook,
CreatedAt: time.Now(),
}
go func() {
log.Printf("gitdeploy promote for %s#%s started\n", hook.HTTPSURL, hook.RefName)
_ = cmd.Wait()
killers <- jobID1
killers <- jobID2
log.Printf("gitdeploy promote for %s#%s finished\n", hook.HTTPSURL, hook.RefName)
// TODO check for backlog
}()
}
// 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"`
}