diff --git a/example.sh b/examples/deploy.sh similarity index 100% rename from example.sh rename to examples/deploy.sh diff --git a/examples/promote.sh b/examples/promote.sh new file mode 100644 index 0000000..b2c0293 --- /dev/null +++ b/examples/promote.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +for x in $@; do + echo "$x" +done + +my_envs='GIT_DEPLOY_JOB_ID +GIT_DEPLOY_PROMOTE_TO +GIT_REF_NAME +GIT_REF_TYPE +GIT_REPO_OWNER +GIT_REPO_NAME +GIT_CLONE_URL' + +echo 'Doing "work" ...' +sleep 5 + +for x in $my_envs; do + echo "$x=${!x}" +done diff --git a/internal/webhooks/webhooks.go b/internal/webhooks/webhooks.go index 30a5147..f0d5f53 100644 --- a/internal/webhooks/webhooks.go +++ b/internal/webhooks/webhooks.go @@ -13,29 +13,29 @@ import ( // Repo ex: example // Org ex: example type Ref struct { - HTTPSURL string - SSHURL string - Rev string - Ref string - RefType string // tags, heads - RefName string - Branch string - Tag string - Owner string - Repo string + HTTPSURL string `json:"clone_url"` + SSHURL string `json:"-"` + Rev string `json:"-"` + Ref string `json:"-"` // refs/tags/v0.0.1, refs/heads/master + RefType string `json:"ref_type"` // tag, branch + RefName string `json:"ref_name"` + Branch string `json:"-"` + Tag string `json:"-"` + Owner string `json:"repo_owner"` + Repo string `json:"repo_name"` } var Providers = make(map[string]func()) var Webhooks = make(map[string]func(chi.Router)) -var hooks = make(chan Ref) +var Hooks = make(chan Ref) func Hook(r Ref) { - hooks <- r + Hooks <- r } func Accept() Ref { - return <-hooks + return <-Hooks } func AddProvider(name string, initProvider func()) { diff --git a/main.go b/main.go index 40b54de..3337eea 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( "compress/flate" "encoding/base64" + "encoding/json" "flag" "fmt" "log" @@ -41,16 +42,20 @@ func ver() { } type job struct { - ID string // {HTTPSURL}#{BRANCH} - Cmd *exec.Cmd - Ref webhooks.Ref + 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 runFlags *flag.FlagSet var initFlags *flag.FlagSet +var promotions = []string{"production", "staging", "master"} +var names = map[string]string{"master": "Development", "production": "Production", "staging": "Staging"} func init() { runOpts = options.Server @@ -64,7 +69,8 @@ func init() { "path to serve, falls back to built-in web app") runFlags.StringVar( &runOpts.Exec, "exec", "", - "path to bash script to run with git info as arguments") + "path to ./scripts/{deploy.sh,promote.sh,etc}") + //"path to bash script to run with git info as arguments") } func main() { @@ -97,7 +103,7 @@ func main() { case "run": _ = runFlags.Parse(args[2:]) if "" == runOpts.Exec { - fmt.Printf("--exec is a required flag") + fmt.Printf("--exec is a required flag") os.Exit(1) return } @@ -110,6 +116,20 @@ 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 serve() { r := chi.NewRouter() @@ -146,6 +166,96 @@ func serve() { 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("/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) + }) + }) r.Get("/*", staticHandler) fmt.Println("Listening for http (with reasonable timeouts) on", runOpts.Addr) @@ -159,61 +269,15 @@ func serve() { } go func() { + // TODO read from backlog for { - hook := webhooks.Accept() - // TODO os.Exec - fmt.Printf("%#v\n", hook) - jobID := base64.URLEncoding.EncodeToString([]byte( - fmt.Sprintf("%s#%s", hook.HTTPSURL, hook.RefName), - )) - - args := []string{ - runOpts.Exec, - jobID, - hook.RefName, - hook.RefType, - hook.Owner, - hook.Repo, - hook.HTTPSURL, + //hook := webhooks.Accept() + select { + case hook := <-webhooks.Hooks: + runHook(hook) + case jobID := <-killers: + kill(jobID) } - cmd := exec.Command("bash", args...) - - env := os.Environ() - envs := []string{ - "GIT_DEPLOY_JOB_ID=" + jobID, - "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[jobID]; exists { - // TODO put job in backlog - log.Printf("git-deploy job already started for %s#%s\n", hook.HTTPSURL, hook.RefName) - return - } - - if err := cmd.Start(); nil != err { - log.Printf("git-deploy exec error: %s\n", err) - return - } - - jobs[jobID] = &job{ - ID: jobID, - Cmd: cmd, - Ref: hook, - } - - go func() { - log.Printf("git-deploy job for %s#%s started\n", hook.HTTPSURL, hook.RefName) - _ = cmd.Wait() - delete(jobs, jobID) - log.Printf("git-deploy job for %s#%s finished\n", hook.HTTPSURL, hook.RefName) - }() } }() @@ -223,3 +287,151 @@ func serve() { 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.Exec + "/deploy.sh", + jobID, + hook.RefName, + hook.RefType, + hook.Owner, + hook.Repo, + hook.HTTPSURL, + } + cmd := exec.Command("bash", args...) + + env := os.Environ() + envs := []string{ + "GIT_DEPLOY_JOB_ID=" + jobID, + "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[jobID]; exists { + // TODO put job in backlog + log.Printf("git-deploy job already started for %s#%s\n", hook.HTTPSURL, hook.RefName) + return + } + + if err := cmd.Start(); nil != err { + log.Printf("git-deploy exec error: %s\n", err) + return + } + + jobs[jobID] = &job{ + ID: jobID, + Cmd: cmd, + Ref: hook, + CreatedAt: time.Now(), + } + + go func() { + log.Printf("git-deploy job for %s#%s started\n", hook.HTTPSURL, hook.RefName) + _ = cmd.Wait() + killers <- jobID + log.Printf("git-deploy job for %s#%s finished\n", hook.HTTPSURL, hook.RefName) + // TODO check for backlog + }() +} + +func kill(jobID string) { + 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.Exec + "/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("git-deploy job already started for %s#%s\n", hook.HTTPSURL, hook.RefName) + return + } + if _, exists := jobs[jobID2]; exists { + // TODO put promote in backlog + log.Printf("git-deploy job already started for %s#%s\n", hook.HTTPSURL, promoteTo) + return + } + + if err := cmd.Start(); nil != err { + log.Printf("git-deploy 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("git-deploy promote for %s#%s started\n", hook.HTTPSURL, hook.RefName) + _ = cmd.Wait() + killers <- jobID1 + killers <- jobID2 + log.Printf("git-deploy promote for %s#%s finished\n", hook.HTTPSURL, hook.RefName) + // TODO check for backlog + }() +}