├── .gitignore ├── .gitmodules ├── .travis.yml ├── LICENSE ├── README.md ├── handlers.go ├── handlers_test.go ├── main.go ├── promo.png ├── service ├── database.go ├── executor.go ├── hub.go ├── jobs.go ├── jobs_test.go ├── list.go ├── output_holder.go ├── runs.go ├── tasks.go ├── tasks_test.go ├── triggers.go └── triggers_test.go ├── test └── functional_test.py ├── utils.go └── web └── static ├── app.html ├── css └── style.css ├── ext ├── ace │ ├── ace.js │ ├── mode-sh.js │ └── ui-ace.js ├── angular │ ├── ng-grid-2.0.7.min.js │ ├── ng-grid-flexible-height.js │ ├── ng-grid.min.css │ └── ui-bootstrap-tpls-0.6.0.min.js └── shim │ └── html5.js ├── gridTemplates └── count.html ├── img └── spinner.gif ├── js ├── app.js ├── jobs.js ├── runs.js ├── services.js ├── tasks.js └── triggers.js └── templates ├── job.html ├── jobs.html ├── run.html ├── runs.html ├── task.html ├── tasks.html ├── trigger.html └── triggers.html /.gitignore: -------------------------------------------------------------------------------- 1 | *.json 2 | 3 | /.idea 4 | 5 | /*.iml 6 | 7 | /*.exe 8 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "web/static/codemirror"] 2 | path = web/static/codemirror 3 | url = https://github.com/marijnh/CodeMirror.git 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Jake Coffman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | gorunner 2 | ======== 3 | 4 | [![Build Status](https://secure.travis-ci.org/jakecoffman/gorunner.png?branch=master)](http://travis-ci.org/jakecoffman/gorunner) 5 | 6 | gorunner is an attempt to create a continuous integration web server written in Golang. 7 | 8 | This project is a work-in-progress but development is not very active. I accept pull requests but also if you want to take it in a different direction let me know and we can collaborate. 9 | 10 | Installation instructions 11 | ---- 12 | 13 | Assuming $GOPATH/bin is on your path: 14 | 15 | go get github.com/jakecoffman/gorunner 16 | cd $GOPATH/src/github.com/jakecoffman/gorunner 17 | gorunner 18 | 19 | Technologies 20 | ---- 21 | 22 | * Go (golang) 23 | * Javascript 24 | * Angularjs 25 | * Websockets 26 | 27 | Why Go? 28 | ---- 29 | 30 | Go's ability to handle many connections would be beneficial for: 31 | 32 | * running multiple build scripts and monitoring progress 33 | * connecting to a cluster of gorunner servers 34 | * live updates to builds in the UI via websockets, etc 35 | 36 | ![gorunner](https://raw.githubusercontent.com/jakecoffman/gorunner/master/promo.png "gorunner") 37 | -------------------------------------------------------------------------------- /handlers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | 7 | "github.com/gorilla/mux" 8 | "github.com/gorilla/websocket" 9 | . "github.com/jakecoffman/gorunner/service" 10 | "github.com/nu7hatch/gouuid" 11 | ) 12 | 13 | var nothing = map[string]string{} 14 | 15 | func errHelp(msg string) map[string]interface{} { 16 | return map[string]interface{}{"error": msg} 17 | } 18 | 19 | // General 20 | 21 | func app(w http.ResponseWriter, r *http.Request) { 22 | http.ServeFile(w, r, "web/static/app.html") 23 | } 24 | 25 | func wsHandler(c context, w http.ResponseWriter, r *http.Request) (int, interface{}) { 26 | // Upgrade the HTTP connection to a websocket 27 | ws, err := websocket.Upgrade(w, r, nil, 1024, 1024) 28 | if _, ok := err.(websocket.HandshakeError); ok { 29 | return http.StatusBadRequest, errHelp("Not a websocket handshake") 30 | } else if err != nil { 31 | return http.StatusInternalServerError, errHelp(err.Error()) 32 | } 33 | conn := NewConnection(ws) 34 | c.Hub().Register(conn) 35 | defer c.Hub().Unregister(conn) 36 | go conn.Writer() 37 | conn.Reader() 38 | return http.StatusOK, nothing 39 | } 40 | 41 | // Jobs 42 | 43 | func listJobs(c context, w http.ResponseWriter, r *http.Request) (int, interface{}) { 44 | return http.StatusOK, c.JobList().Dump() 45 | } 46 | 47 | func addJob(c context, w http.ResponseWriter, r *http.Request) (int, interface{}) { 48 | payload := unmarshal(r.Body, "name", w) 49 | 50 | err := c.JobList().Append(Job{Name: payload["name"], Status: "New"}) 51 | if err != nil { 52 | return http.StatusInternalServerError, err.Error() 53 | } 54 | return http.StatusCreated, nothing 55 | } 56 | 57 | func getJob(c context, w http.ResponseWriter, r *http.Request) (int, interface{}) { 58 | vars := mux.Vars(r) 59 | job, err := c.JobList().Get(vars["job"]) 60 | if err != nil { 61 | return http.StatusNotFound, err.Error() 62 | } 63 | 64 | return http.StatusOK, job 65 | } 66 | 67 | func deleteJob(c context, w http.ResponseWriter, r *http.Request) (int, interface{}) { 68 | vars := mux.Vars(r) 69 | job, err := c.JobList().Get(vars["job"]) 70 | if err != nil { 71 | return http.StatusNotFound, err.Error() 72 | } 73 | 74 | err = c.JobList().Delete(job.ID()) 75 | if err != nil { 76 | return http.StatusInternalServerError, err.Error() 77 | } 78 | 79 | return http.StatusOK, nothing 80 | } 81 | 82 | func addTaskToJob(c context, w http.ResponseWriter, r *http.Request) (int, interface{}) { 83 | vars := mux.Vars(r) 84 | job, err := c.JobList().Get(vars["job"]) 85 | if err != nil { 86 | return http.StatusNotFound, err.Error() 87 | } 88 | j := job.(Job) 89 | 90 | payload := unmarshal(r.Body, "task", w) 91 | j.AppendTask(payload["task"]) 92 | c.JobList().Update(j) 93 | 94 | return http.StatusCreated, nothing 95 | } 96 | 97 | func removeTaskFromJob(c context, w http.ResponseWriter, r *http.Request) (int, interface{}) { 98 | vars := mux.Vars(r) 99 | job, err := c.JobList().Get(vars["job"]) 100 | if err != nil { 101 | return http.StatusNotFound, err.Error() 102 | } 103 | j := job.(Job) 104 | 105 | taskPosition, err := strconv.Atoi(vars["task"]) 106 | if err != nil { 107 | return http.StatusBadRequest, err.Error() 108 | } 109 | j.DeleteTask(taskPosition) 110 | c.JobList().Update(j) 111 | return http.StatusOK, nothing 112 | } 113 | 114 | func addTriggerToJob(c context, w http.ResponseWriter, r *http.Request) (int, interface{}) { 115 | vars := mux.Vars(r) 116 | job, err := c.JobList().Get(vars["job"]) 117 | if err != nil { 118 | return http.StatusNotFound, err.Error() 119 | } 120 | j := job.(Job) 121 | 122 | payload := unmarshal(r.Body, "trigger", w) 123 | 124 | j.AppendTrigger(payload["trigger"]) 125 | t, err := c.TriggerList().Get(payload["trigger"]) 126 | if err != nil { 127 | return http.StatusInternalServerError, err.Error() 128 | } 129 | c.Executor().ArmTrigger(t.(Trigger)) 130 | c.JobList().Update(j) 131 | 132 | return http.StatusCreated, nothing 133 | } 134 | 135 | func removeTriggerFromJob(c context, w http.ResponseWriter, r *http.Request) (int, interface{}) { 136 | vars := mux.Vars(r) 137 | job, err := c.JobList().Get(vars["job"]) 138 | if err != nil { 139 | return http.StatusNotFound, err.Error() 140 | } 141 | j := job.(Job) 142 | 143 | t := vars["trigger"] 144 | j.DeleteTrigger(t) 145 | c.JobList().Update(j) 146 | 147 | // If Trigger is no longer attached to any Jobs, remove it from Cron to save cycles 148 | jobs := c.JobList().GetJobsWithTrigger(t) 149 | 150 | if len(jobs) == 0 { 151 | c.Executor().DisarmTrigger(t) 152 | } 153 | return http.StatusOK, nothing 154 | } 155 | 156 | // Run 157 | 158 | func listRuns(c context, w http.ResponseWriter, r *http.Request) (int, interface{}) { 159 | offset := r.FormValue("offset") 160 | length := r.FormValue("length") 161 | 162 | if offset == "" { 163 | offset = "-1" 164 | } 165 | if length == "" { 166 | length = "-1" 167 | } 168 | 169 | o, err := strconv.Atoi(offset) 170 | if err != nil { 171 | return http.StatusBadRequest, err.Error() 172 | } 173 | 174 | l, err := strconv.Atoi(length) 175 | if err != nil { 176 | return http.StatusBadRequest, err.Error() 177 | } 178 | 179 | return http.StatusOK, c.RunList().GetRecent(o, l) 180 | } 181 | 182 | func addRun(c context, w http.ResponseWriter, r *http.Request) (int, interface{}) { 183 | payload := unmarshal(r.Body, "job", w) 184 | 185 | job, err := c.JobList().Get(payload["job"]) 186 | if err != nil { 187 | return http.StatusInternalServerError, err.Error() 188 | } 189 | j := job.(Job) 190 | 191 | id, err := uuid.NewV4() 192 | if err != nil { 193 | return http.StatusInternalServerError, err.Error() 194 | } 195 | 196 | var tasks []Task 197 | for _, taskName := range j.Tasks { 198 | task, err := c.TaskList().Get(taskName) 199 | if err != nil { 200 | panic(err) 201 | } 202 | t := task.(Task) 203 | tasks = append(tasks, t) 204 | } 205 | err = c.RunList().AddRun(id.String(), j, tasks) 206 | if err != nil { 207 | return http.StatusInternalServerError, err.Error() 208 | } 209 | 210 | return http.StatusCreated, map[string]string{"uuid": id.String()} 211 | } 212 | 213 | func getRun(c context, w http.ResponseWriter, r *http.Request) (int, interface{}) { 214 | vars := mux.Vars(r) 215 | run, err := c.RunList().Get(vars["run"]) 216 | if err != nil { 217 | return http.StatusNotFound, err.Error() 218 | } 219 | return http.StatusOK, run 220 | } 221 | 222 | // Tasks 223 | 224 | func listTasks(c context, w http.ResponseWriter, r *http.Request) (int, interface{}) { 225 | return http.StatusOK, c.TaskList().Dump() 226 | } 227 | 228 | func addTask(c context, w http.ResponseWriter, r *http.Request) (int, interface{}) { 229 | payload := unmarshal(r.Body, "name", w) 230 | 231 | err := c.TaskList().Append(Task{payload["name"], ""}) 232 | if err != nil { 233 | return http.StatusBadRequest, err.Error() 234 | } 235 | return http.StatusCreated, nothing 236 | } 237 | 238 | func getTask(c context, w http.ResponseWriter, r *http.Request) (int, interface{}) { 239 | vars := mux.Vars(r) 240 | task, err := c.TaskList().Get(vars["task"]) 241 | if err != nil { 242 | return http.StatusNotFound, err.Error() 243 | } 244 | return http.StatusOK, task 245 | } 246 | 247 | func updateTask(c context, w http.ResponseWriter, r *http.Request) (int, interface{}) { 248 | vars := mux.Vars(r) 249 | task, err := c.TaskList().Get(vars["task"]) 250 | if err != nil { 251 | return http.StatusNotFound, err.Error() 252 | } 253 | payload := unmarshal(r.Body, "script", w) 254 | t := task.(Task) 255 | t.Script = payload["script"] 256 | c.TaskList().Update(t) 257 | return http.StatusOK, nothing 258 | } 259 | 260 | func deleteTask(c context, w http.ResponseWriter, r *http.Request) (int, interface{}) { 261 | vars := mux.Vars(r) 262 | task, err := c.TaskList().Get(vars["task"]) 263 | if err != nil { 264 | return http.StatusNotFound, err.Error() 265 | } 266 | c.TaskList().Delete(task.ID()) 267 | return http.StatusOK, nothing 268 | } 269 | 270 | func listJobsForTask(c context, w http.ResponseWriter, r *http.Request) (int, interface{}) { 271 | vars := mux.Vars(r) 272 | jobs := c.JobList().GetJobsWithTask(vars["task"]) 273 | return http.StatusOK, jobs 274 | } 275 | 276 | // Triggers 277 | 278 | func listTriggers(c context, w http.ResponseWriter, r *http.Request) (int, interface{}) { 279 | return http.StatusOK, c.TriggerList().Dump() 280 | } 281 | 282 | func addTrigger(c context, w http.ResponseWriter, r *http.Request) (int, interface{}) { 283 | payload := unmarshal(r.Body, "name", w) 284 | trigger := Trigger{Name: payload["name"]} 285 | c.TriggerList().Append(trigger) 286 | return http.StatusCreated, nothing 287 | } 288 | 289 | func getTrigger(c context, w http.ResponseWriter, r *http.Request) (int, interface{}) { 290 | vars := mux.Vars(r) 291 | trigger, err := c.TriggerList().Get(vars["trigger"]) 292 | if err != nil { 293 | return http.StatusNotFound, err.Error() 294 | } 295 | return http.StatusNotFound, trigger 296 | } 297 | 298 | func updateTrigger(c context, w http.ResponseWriter, r *http.Request) (int, interface{}) { 299 | vars := mux.Vars(r) 300 | trigger, err := c.TriggerList().Get(vars["trigger"]) 301 | if err != nil { 302 | return http.StatusNotFound, err.Error() 303 | } 304 | 305 | payload := unmarshal(r.Body, "cron", w) 306 | 307 | t := trigger.(Trigger) 308 | t.Schedule = payload["cron"] 309 | c.Executor().ArmTrigger(t) 310 | err = c.TriggerList().Update(t) 311 | if err != nil { 312 | return http.StatusInternalServerError, err.Error() 313 | } 314 | 315 | return http.StatusOK, nothing 316 | } 317 | 318 | func deleteTrigger(c context, w http.ResponseWriter, r *http.Request) (int, interface{}) { 319 | vars := mux.Vars(r) 320 | c.TriggerList().Delete(vars["trigger"]) 321 | return http.StatusOK, nothing 322 | } 323 | 324 | func listJobsForTrigger(c context, w http.ResponseWriter, r *http.Request) (int, interface{}) { 325 | vars := mux.Vars(r) 326 | jobs := c.JobList().GetJobsWithTrigger(vars["trigger"]) 327 | return http.StatusOK, jobs 328 | } 329 | -------------------------------------------------------------------------------- /handlers_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "net/url" 7 | "testing" 8 | 9 | "github.com/jakecoffman/gorunner/service" 10 | ) 11 | 12 | func TestResponses(t *testing.T) { 13 | w := httptest.NewRecorder() 14 | 15 | uri := "/jobs" 16 | param := make(url.Values) 17 | 18 | r, err := http.NewRequest("GET", uri+param.Encode(), nil) 19 | if err != nil { 20 | t.Fatal(err) 21 | } 22 | 23 | jobList := service.NewJobList() 24 | c := ctx{jobList: jobList} 25 | 26 | status, _ := listJobs(c, w, r) 27 | 28 | if status != http.StatusOK { 29 | t.Errorf("Expected %v, %v; got %v, %v", http.StatusOK, status) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "os" 7 | 8 | "github.com/gorilla/mux" 9 | . "github.com/jakecoffman/gorunner/service" 10 | ) 11 | 12 | const port = "localhost:8090" 13 | 14 | var routes = []struct { 15 | route string 16 | handler func(context, http.ResponseWriter, *http.Request) (int, interface{}) 17 | method string 18 | }{ 19 | {"/jobs", listJobs, "GET"}, 20 | {"/jobs", addJob, "POST"}, 21 | {"/jobs/{job}", getJob, "GET"}, 22 | {"/jobs/{job}", deleteJob, "DELETE"}, 23 | {"/jobs/{job}/tasks", addTaskToJob, "POST"}, 24 | {"/jobs/{job}/tasks/{task}", removeTaskFromJob, "DELETE"}, 25 | {"/jobs/{job}/triggers/", addTriggerToJob, "POST"}, 26 | {"/jobs/{job}/triggers/{trigger}", removeTriggerFromJob, "DELETE"}, 27 | 28 | {"/tasks", listTasks, "GET"}, 29 | {"/tasks", addTask, "POST"}, 30 | {"/tasks/{task}", getTask, "GET"}, 31 | {"/tasks/{task}", updateTask, "PUT"}, 32 | {"/tasks/{task}", deleteTask, "DELETE"}, 33 | {"/tasks/{task}/jobs", listJobsForTask, "GET"}, 34 | 35 | {"/runs", listRuns, "GET"}, 36 | {"/runs", addRun, "POST"}, 37 | {"/runs/{run}", getRun, "GET"}, 38 | 39 | {"/triggers", listTriggers, "GET"}, 40 | {"/triggers", addTrigger, "POST"}, 41 | {"/triggers/{trigger}", getTrigger, "GET"}, 42 | {"/triggers/{trigger}", updateTrigger, "PUT"}, 43 | {"/triggers/{trigger}", deleteTrigger, "DELETE"}, 44 | {"/triggers/{trigger}/jobs", listJobsForTrigger, "GET"}, 45 | } 46 | 47 | type ctx struct { 48 | hub *Hub 49 | executor *Executor 50 | jobList *JobList 51 | taskList *TaskList 52 | triggerList *TriggerList 53 | runList *RunList 54 | } 55 | 56 | func (t ctx) Hub() *Hub { 57 | return t.hub 58 | } 59 | 60 | func (t ctx) Executor() *Executor { 61 | return t.executor 62 | } 63 | 64 | func (t ctx) JobList() *JobList { 65 | return t.jobList 66 | } 67 | 68 | func (t ctx) TaskList() *TaskList { 69 | return t.taskList 70 | } 71 | 72 | func (t ctx) TriggerList() *TriggerList { 73 | return t.triggerList 74 | } 75 | 76 | func (t ctx) RunList() *RunList { 77 | return t.runList 78 | } 79 | 80 | type context interface { 81 | Hub() *Hub 82 | Executor() *Executor 83 | JobList() *JobList 84 | TaskList() *TaskList 85 | TriggerList() *TriggerList 86 | RunList() *RunList 87 | } 88 | 89 | type appHandler struct { 90 | *ctx 91 | handler func(context, http.ResponseWriter, *http.Request) (int, interface{}) 92 | } 93 | 94 | func (t appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 95 | code, data := t.handler(t.ctx, w, r) 96 | marshal(data, w) 97 | w.Header().Set("Content-Type", "application/json") 98 | w.WriteHeader(code) 99 | log.Println(r.URL, "-", r.Method, "-", code, r.RemoteAddr) 100 | } 101 | 102 | func main() { 103 | wd, _ := os.Getwd() 104 | log.Println("Working directory", wd) 105 | 106 | jobList := NewJobList() 107 | taskList := NewTaskList() 108 | triggerList := NewTriggerList() 109 | runList := NewRunList(jobList) 110 | 111 | jobList.Load() 112 | taskList.Load() 113 | triggerList.Load() 114 | runList.Load() 115 | 116 | hub := NewHub(runList) 117 | go hub.HubLoop() 118 | 119 | executor := NewExecutor(jobList, taskList, runList) 120 | 121 | appContext := &ctx{hub, executor, jobList, taskList, triggerList, runList} 122 | 123 | r := mux.NewRouter() 124 | 125 | // non REST routes 126 | r.PathPrefix("/static/").Handler(http.FileServer(http.Dir("web/"))) 127 | r.HandleFunc("/", app).Methods("GET") 128 | r.Handle("/ws", appHandler{appContext, wsHandler}).Methods("GET") 129 | 130 | for _, detail := range routes { 131 | r.Handle(detail.route, appHandler{appContext, detail.handler}).Methods(detail.method) 132 | } 133 | 134 | log.Println("Running on " + port) 135 | http.ListenAndServe(port, r) 136 | } 137 | -------------------------------------------------------------------------------- /promo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakecoffman/gorunner/9bdd25aecd9ae95e0a58a7aa167fd99828759e82/promo.png -------------------------------------------------------------------------------- /service/database.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "sort" 7 | ) 8 | 9 | const ( 10 | jobsFile = "jobs.json" 11 | runsFile = "runs.json" 12 | tasksFile = "tasks.json" 13 | triggersFile = "triggers.json" 14 | ) 15 | 16 | type ListWriter func([]byte, string) 17 | type ListReader func(string) []byte 18 | 19 | func writeFile(bytes []byte, filePath string) { 20 | err := ioutil.WriteFile(filePath, bytes, 0644) 21 | if err != nil { 22 | panic(err) 23 | } 24 | } 25 | 26 | func readFile(filePath string) []byte { 27 | _, err := os.Stat(filePath) 28 | if err != nil { 29 | println("Couldn't read file, creating fresh:", filePath) 30 | err = ioutil.WriteFile(filePath, []byte("[]"), 0644) 31 | if err != nil { 32 | panic(err) 33 | } 34 | } 35 | 36 | bytes, err := ioutil.ReadFile(filePath) 37 | if err != nil { 38 | panic(err) 39 | } 40 | return bytes 41 | } 42 | 43 | type Reverse struct { 44 | sort.Interface 45 | } 46 | 47 | func (r Reverse) Less(i, j int) bool { 48 | return r.Interface.Less(j, i) 49 | } 50 | -------------------------------------------------------------------------------- /service/executor.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | cronService "github.com/jakecoffman/cron" 5 | "github.com/nu7hatch/gouuid" 6 | ) 7 | 8 | var triggers map[string]struct{} 9 | 10 | type Executor struct { 11 | cron *cronService.Cron 12 | jobList *JobList 13 | taskList *TaskList 14 | runList *RunList 15 | } 16 | 17 | func NewExecutor(jobList *JobList, taskList *TaskList, runList *RunList) *Executor { 18 | cron := cronService.New() 19 | cron.Start() 20 | return &Executor{ 21 | cron, 22 | jobList, 23 | taskList, 24 | runList, 25 | } 26 | } 27 | 28 | func (e Executor) ArmTrigger(t Trigger) { 29 | e.cron.AddFunc(t.Schedule, func() { e.findAndRun(t) }, t.Name) 30 | } 31 | 32 | func (e Executor) DisarmTrigger(name string) { 33 | e.cron.RemoveJob(name) 34 | println("Trigger has been removed") 35 | } 36 | 37 | // Walks through each job, seeing if the trigger who's turn it is to execute is attached. Executes those jobs. 38 | func (e Executor) findAndRun(t Trigger) { 39 | jobs := e.jobList.GetJobsWithTrigger(t.ID()) 40 | for _, job := range jobs { 41 | println("Executing job " + job.Name) 42 | e.runnit(job) 43 | } 44 | } 45 | 46 | // Gathers the tasks attached to the given job and executes them. 47 | func (e Executor) runnit(j Job) { 48 | id, err := uuid.NewV4() 49 | if err != nil { 50 | panic(err) 51 | } 52 | var tasks []Task 53 | for _, taskName := range j.Tasks { 54 | task, err := e.taskList.Get(taskName) 55 | if err != nil { 56 | panic(err) 57 | } 58 | t := task.(Task) 59 | tasks = append(tasks, t) 60 | } 61 | err = e.runList.AddRun(id.String(), j, tasks) 62 | if err != nil { 63 | panic(err) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /service/hub.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "sort" 7 | 8 | "github.com/gorilla/websocket" 9 | ) 10 | 11 | type Hub struct { 12 | connections map[*Connection]bool 13 | register chan *Connection 14 | unregister chan *Connection 15 | refresh chan bool 16 | runList *RunList 17 | } 18 | 19 | func NewHub(runList *RunList) *Hub { 20 | return &Hub{ 21 | refresh: make(chan bool), 22 | register: make(chan *Connection), 23 | unregister: make(chan *Connection), 24 | connections: make(map[*Connection]bool), 25 | runList: runList, 26 | } 27 | } 28 | 29 | func (h *Hub) Register(c *Connection) { 30 | h.register <- c 31 | } 32 | 33 | func (h *Hub) Unregister(c *Connection) { 34 | h.unregister <- c 35 | } 36 | 37 | func (h *Hub) Refresh() { 38 | h.refresh <- true 39 | } 40 | 41 | func (h *Hub) onRefresh() []byte { 42 | sort.Sort(Reverse{h.runList}) 43 | recent := h.runList.GetRecent(0, 10) 44 | bytes, err := json.Marshal(recent) 45 | if err != nil { 46 | panic(err.Error()) 47 | } 48 | return bytes 49 | } 50 | 51 | func (h *Hub) HubLoop() { 52 | for { 53 | select { 54 | case c := <-h.register: 55 | fmt.Println("Connect") 56 | h.connections[c] = true 57 | bytes := h.onRefresh() 58 | c.send <- bytes 59 | case c := <-h.unregister: 60 | fmt.Println("Disconnect") 61 | delete(h.connections, c) 62 | close(c.send) 63 | case <-h.refresh: 64 | fmt.Println("Refreshing") 65 | bytes := h.onRefresh() 66 | for c := range h.connections { 67 | select { 68 | case c.send <- bytes: 69 | default: 70 | delete(h.connections, c) 71 | close(c.send) 72 | go c.ws.Close() 73 | } 74 | } 75 | } 76 | } 77 | } 78 | 79 | type Connection struct { 80 | ws *websocket.Conn 81 | send chan []byte 82 | } 83 | 84 | func NewConnection(ws *websocket.Conn) *Connection { 85 | return &Connection{send: make(chan []byte, 256), ws: ws} 86 | } 87 | 88 | func (c *Connection) Reader() { 89 | for { 90 | _, msg, err := c.ws.ReadMessage() 91 | if err != nil { 92 | fmt.Printf("Error in websocket read: %s\n", err.Error()) 93 | break 94 | } 95 | fmt.Printf("Message received: %s\n", msg) 96 | // TODO: Do something with the msg 97 | } 98 | c.ws.Close() 99 | } 100 | 101 | func (c *Connection) Writer() { 102 | for msg := range c.send { 103 | err := c.ws.WriteMessage(websocket.TextMessage, msg) 104 | if err != nil { 105 | fmt.Printf("Error in websocket write: %s\n", err.Error()) 106 | break 107 | } 108 | } 109 | c.ws.Close() 110 | } 111 | -------------------------------------------------------------------------------- /service/jobs.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | ) 7 | 8 | type Job struct { 9 | Name string `json:"name"` 10 | Tasks []string `json:"tasks"` 11 | Status string `json:"status"` 12 | Triggers []string `json:"triggers"` 13 | } 14 | 15 | func (j Job) ID() string { 16 | return j.Name 17 | } 18 | 19 | func (j *Job) AppendTask(task string) { 20 | j.Tasks = append(j.Tasks, task) 21 | } 22 | 23 | func (j *Job) DeleteTask(taskPosition int) error { 24 | i := taskPosition 25 | j.Tasks = j.Tasks[:i+copy(j.Tasks[i:], j.Tasks[i+1:])] 26 | return nil 27 | } 28 | 29 | func (j *Job) AppendTrigger(trigger string) error { 30 | for _, name := range j.Triggers { 31 | if name == trigger { 32 | return errors.New("Trigger already on job") 33 | } 34 | } 35 | j.Triggers = append(j.Triggers, trigger) 36 | return nil 37 | } 38 | 39 | func (j *Job) DeleteTrigger(trigger string) error { 40 | for i, name := range j.Triggers { 41 | if name == trigger { 42 | j.Triggers = j.Triggers[:i+copy(j.Triggers[i:], j.Triggers[i+1:])] 43 | return nil 44 | } 45 | } 46 | return errors.New("Trigger not found") 47 | } 48 | 49 | type JobList struct { 50 | list 51 | } 52 | 53 | func NewJobList() *JobList { 54 | return &JobList{ 55 | list{elements: []elementer{}, fileName: jobsFile}, 56 | } 57 | } 58 | 59 | func (l *JobList) Load() { 60 | bytes := readFile(l.fileName) 61 | var jobs []Job 62 | err := json.Unmarshal([]byte(string(bytes)), &jobs) 63 | if err != nil { 64 | panic(err) 65 | } 66 | l.elements = []elementer{} 67 | for _, job := range jobs { 68 | l.elements = append(l.elements, job) 69 | } 70 | } 71 | 72 | func (l *JobList) GetJobsWithTrigger(triggerName string) (jobs []Job) { 73 | jobs = make([]Job, 0) 74 | for _, e := range l.elements { 75 | job := e.(Job) 76 | for _, trigger := range job.Triggers { 77 | if trigger == triggerName { 78 | jobs = append(jobs, job) 79 | } 80 | } 81 | } 82 | return 83 | } 84 | 85 | func (l *JobList) GetJobsWithTask(taskName string) (jobs []Job) { 86 | jobs = make([]Job, 0) 87 | for _, e := range l.elements { 88 | job := e.(Job) 89 | for _, task := range job.Tasks { 90 | if task == taskName { 91 | jobs = append(jobs, job) 92 | } 93 | } 94 | } 95 | return 96 | } 97 | -------------------------------------------------------------------------------- /service/jobs_test.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestJobID(t *testing.T) { 9 | job := Job{Name: "name"} 10 | if job.ID() != "name" { 11 | t.Errorf("ID() expected %s but got %s", "name", job.ID()) 12 | } 13 | } 14 | 15 | func TestJobAppendTask(t *testing.T) { 16 | job := Job{"name", make([]string, 0), "status", make([]string, 0)} 17 | job.AppendTask("task") 18 | expected := []string{"task"} 19 | if fmt.Sprintf("%#v", job.Tasks) != fmt.Sprintf("%#v", expected) { 20 | t.Errorf("Expected %#v but got %#v", expected, job.Tasks) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /service/list.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "sync" 8 | ) 9 | 10 | type elementer interface { 11 | ID() string 12 | } 13 | 14 | type list struct { 15 | elements []elementer 16 | fileName string 17 | sync.RWMutex 18 | } 19 | 20 | func (l *list) Get(id string) (elementer, error) { 21 | l.RLock() 22 | defer l.RUnlock() 23 | 24 | for _, e := range l.elements { 25 | if e.ID() == id { 26 | return e, nil 27 | } 28 | } 29 | return nil, fmt.Errorf("Element '%s' not found", id) 30 | } 31 | 32 | func (l *list) Update(e elementer) error { 33 | l.Lock() 34 | defer l.Unlock() 35 | 36 | position, err := l.pos(e.ID()) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | l.elements[position] = e 42 | l.save() 43 | return nil 44 | } 45 | 46 | func (l *list) Append(e elementer) error { 47 | l.Lock() 48 | defer l.Unlock() 49 | 50 | if e.ID() == "" { 51 | return errors.New("No ID provided") 52 | } 53 | 54 | _, err := l.pos(e.ID()) 55 | if err == nil { 56 | return errors.New("Element with that id found in list") 57 | } 58 | l.elements = append(l.elements, e) 59 | l.save() 60 | return nil 61 | } 62 | 63 | func (l *list) Delete(id string) error { 64 | l.Lock() 65 | defer l.Unlock() 66 | 67 | found := false 68 | var i int 69 | var thing elementer 70 | for i, thing = range l.elements { 71 | if thing.ID() == id { 72 | found = true 73 | break 74 | } 75 | } 76 | if !found { 77 | return fmt.Errorf("Element '%s' not found for deletion", id) 78 | } 79 | l.elements = l.elements[:i+copy(l.elements[i:], l.elements[i+1:])] 80 | l.save() 81 | return nil 82 | } 83 | 84 | func (l *list) save() { 85 | writeFile(l.dumps(), l.fileName) 86 | } 87 | 88 | func (l *list) dumps() []byte { 89 | bytes, err := json.Marshal(l.elements) 90 | if err != nil { 91 | panic(err) 92 | } 93 | return bytes 94 | } 95 | 96 | func (l *list) Dump() []elementer { 97 | l.RLock() 98 | defer l.RUnlock() 99 | return l.elements 100 | } 101 | 102 | func (l *list) pos(id string) (int, error) { 103 | for i, e := range l.elements { 104 | if e.ID() == id { 105 | return i, nil 106 | } 107 | } 108 | return -1, errors.New("not found") 109 | } 110 | -------------------------------------------------------------------------------- /service/output_holder.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | ) 7 | 8 | // An alias of Buffer that json encoder will marshal to a string and unmarshal from a string. 9 | type OutputHolder bytes.Buffer 10 | 11 | func (holder *OutputHolder) MarshalJSON() ([]byte, error) { 12 | return json.Marshal(holder.String()) 13 | } 14 | 15 | func (holder *OutputHolder) UnmarshalJSON(data []byte) error { 16 | var s string 17 | if err := json.Unmarshal(data, &s); err != nil { 18 | return err 19 | } 20 | holder.Reset() 21 | _, err := holder.WriteString(s) 22 | return err 23 | } 24 | 25 | func (holder *OutputHolder) WriteString(s string) (int, error) { 26 | return (*bytes.Buffer)(holder).WriteString(s) 27 | } 28 | 29 | func (holder *OutputHolder) Reset() { 30 | (*bytes.Buffer)(holder).Reset() 31 | } 32 | 33 | func (holder *OutputHolder) String() string { 34 | return (*bytes.Buffer)(holder).String() 35 | } 36 | -------------------------------------------------------------------------------- /service/runs.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "errors" 7 | "io" 8 | "log" 9 | "os" 10 | "os/exec" 11 | "sync" 12 | "time" 13 | ) 14 | 15 | type Result struct { 16 | Start time.Time `json:"start"` 17 | End time.Time `json:"end"` 18 | Task Task `json:"task"` 19 | Output OutputHolder `json:"output"` 20 | Error string `json:"error"` 21 | } 22 | 23 | type Run struct { 24 | UUID string `json:"uuid"` 25 | Job Job `json:"job"` 26 | Tasks []Task `json:"tasks"` 27 | Start time.Time `json:"start"` 28 | End time.Time `json:"end"` 29 | Results []*Result `json:"results"` 30 | Status string `json:"status"` 31 | } 32 | 33 | func (r Run) ID() string { 34 | return r.UUID 35 | } 36 | 37 | type RunList struct { 38 | list 39 | jobList *JobList 40 | } 41 | 42 | func NewRunList(jobList *JobList) *RunList { 43 | return &RunList{ 44 | list{elements: []elementer{}, fileName: runsFile}, 45 | jobList, 46 | } 47 | } 48 | 49 | func (l *RunList) Load() { 50 | bytes := readFile(l.fileName) 51 | var runs []Run 52 | err := json.Unmarshal([]byte(string(bytes)), &runs) 53 | if err != nil { 54 | panic(err) 55 | } 56 | l.elements = nil 57 | for _, run := range runs { 58 | l.elements = append(l.elements, run) 59 | } 60 | } 61 | 62 | func (j *RunList) Len() int { 63 | j.RLock() 64 | defer j.RUnlock() 65 | 66 | return len(j.elements) 67 | } 68 | 69 | func (l *RunList) Less(i, j int) bool { 70 | l.RLock() 71 | defer l.RUnlock() 72 | 73 | return l.elements[i].(Run).Start.Before(l.elements[j].(Run).Start) 74 | } 75 | 76 | func (l *RunList) Swap(i, j int) { 77 | l.RLock() 78 | defer l.RUnlock() 79 | 80 | l.elements[i], l.elements[j] = l.elements[j], l.elements[i] 81 | } 82 | 83 | func (l RunList) GetRecent(offset, length int) []elementer { 84 | runs := l.elements 85 | if offset != -1 { 86 | if offset >= len(runs) { 87 | return nil 88 | } 89 | if length != -1 && offset+length < len(runs) { 90 | runs = runs[offset : offset+length] 91 | } else { 92 | runs = runs[offset:] 93 | } 94 | } else { 95 | if length != -1 { 96 | runs = runs[:length] 97 | } 98 | } 99 | return runs 100 | } 101 | 102 | func (j *RunList) AddRun(UUID string, job Job, tasks []Task) error { 103 | run := Run{UUID: UUID, Job: job, Tasks: tasks, Start: time.Now(), Status: "New"} 104 | // check to make sure that UUID doesn't already exist 105 | var found bool = false 106 | for _, j := range j.elements { 107 | if run.UUID == j.(Run).UUID { 108 | found = true 109 | } 110 | } 111 | if found { 112 | return errors.New("Run with that name found in list") 113 | } 114 | j.Lock() 115 | defer j.Unlock() 116 | 117 | // add the run to the list and execute 118 | j.elements = append(j.elements, run) 119 | go j.execute(&run) 120 | j.save() 121 | return nil 122 | } 123 | 124 | func (l *RunList) execute(r *Run) { 125 | r.Status = "Running" 126 | for _, task := range r.Tasks { 127 | result := &Result{Start: time.Now(), Task: task} 128 | r.Results = append(r.Results, result) 129 | l.Update(*r) 130 | shell, commandArg := getShell() 131 | cmd := exec.Command(shell, commandArg, task.Script) 132 | 133 | cmd.Env = append(cmd.Env, "UUID=" + r.UUID) 134 | 135 | outPipe, err := cmd.StdoutPipe() 136 | if err != nil { 137 | reportRunError(l, r, result, err) 138 | return 139 | } 140 | errPipe, err := cmd.StderrPipe() 141 | if err != nil { 142 | outPipe.Close() 143 | reportRunError(l, r, result, err) 144 | return 145 | } 146 | var outputWg sync.WaitGroup 147 | outputWg.Add(1) 148 | go result.muxIntoOutput(outPipe, errPipe, &outputWg) 149 | 150 | if err := cmd.Start(); err != nil { 151 | reportRunError(l, r, result, err) 152 | return 153 | } 154 | outputWg.Wait() 155 | if err := cmd.Wait(); err != nil { 156 | reportRunError(l, r, result, err) 157 | return 158 | } 159 | result.End = time.Now() 160 | if err != nil { 161 | reportRunError(l, r, result, err) 162 | return 163 | } 164 | l.Update(*r) 165 | } 166 | r.End = time.Now() 167 | r.Status = "Done" 168 | job, err := l.jobList.Get(r.Job.Name) 169 | if err != nil { 170 | return 171 | } 172 | j := job.(Job) 173 | j.Status = "Ok" 174 | l.jobList.Update(job) 175 | l.Update(*r) 176 | } 177 | 178 | func (result *Result) muxIntoOutput(stdout io.ReadCloser, stderr io.ReadCloser, done *sync.WaitGroup) { 179 | defer done.Done() 180 | outLines := consumeLines(stdout) 181 | errLines := consumeLines(stderr) 182 | for outLines != nil || errLines != nil { 183 | select { 184 | case line, ok := <-outLines: 185 | if ok { 186 | result.Output.WriteString(line + "\n") 187 | } else { 188 | outLines = nil 189 | } 190 | case line, ok := <-errLines: 191 | if ok { 192 | result.Output.WriteString(line + "\n") 193 | } else { 194 | errLines = nil 195 | } 196 | } 197 | } 198 | } 199 | 200 | func consumeLines(reader io.ReadCloser) <-chan string { 201 | lines := make(chan string) 202 | go func() { 203 | defer reader.Close() 204 | defer close(lines) 205 | scanner := bufio.NewScanner(reader) 206 | 207 | for scanner.Scan() { 208 | lines <- scanner.Text() 209 | } 210 | if err := scanner.Err(); err != nil { 211 | log.Println("reading output: ", err) 212 | } 213 | }() 214 | return lines 215 | } 216 | 217 | func reportRunError(l *RunList, r *Run, result *Result, err error) { 218 | log.Println("Reporting error", err) 219 | result.Error = err.Error() 220 | r.Status = "Failed" 221 | r.End = time.Now() 222 | l.Update(*r) 223 | job, err := l.jobList.Get(r.Job.Name) 224 | if err != nil { 225 | return 226 | } 227 | j := job.(Job) 228 | j.Status = "Failing" 229 | l.jobList.Update(job) 230 | return 231 | } 232 | 233 | func getShell() (string, string) { 234 | var shell = os.Getenv("SHELL") 235 | if "" != shell { 236 | return shell, "-c" 237 | } 238 | return "cmd", "/C" 239 | } 240 | -------------------------------------------------------------------------------- /service/tasks.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | type Task struct { 8 | Name string `json:"name"` 9 | Script string `json:"script"` 10 | } 11 | 12 | func (t Task) ID() string { 13 | return t.Name 14 | } 15 | 16 | type TaskList struct { 17 | list 18 | } 19 | 20 | func NewTaskList() *TaskList { 21 | return &TaskList{ 22 | list{elements: []elementer{}, fileName: tasksFile}, 23 | } 24 | } 25 | 26 | func (l *TaskList) Load() { 27 | bytes := readFile(l.fileName) 28 | var tasks []Task 29 | err := json.Unmarshal([]byte(string(bytes)), &tasks) 30 | if err != nil { 31 | panic(err) 32 | } 33 | l.elements = []elementer{} 34 | for _, task := range tasks { 35 | l.elements = append(l.elements, task) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /service/tasks_test.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestTaskID(t *testing.T) { 8 | task := Task{Name: "Task", Script: "echo 'hello'"} 9 | if task.ID() != "Task" { 10 | t.Errorf("ID() expected %s but got %s", "Task", task.ID()) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /service/triggers.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | type Trigger struct { 8 | Name string `json:"name"` 9 | Schedule string `json:"schedule"` 10 | } 11 | 12 | func (t Trigger) ID() string { 13 | return t.Name 14 | } 15 | 16 | type TriggerList struct { 17 | list 18 | } 19 | 20 | func NewTriggerList() *TriggerList { 21 | return &TriggerList{ 22 | list{elements: []elementer{}, fileName: triggersFile}, 23 | } 24 | } 25 | 26 | func (l *TriggerList) Load() { 27 | bytes := readFile(l.fileName) 28 | var triggers []Trigger 29 | err := json.Unmarshal([]byte(string(bytes)), &triggers) 30 | if err != nil { 31 | panic(err) 32 | } 33 | l.elements = []elementer{} 34 | for _, trigger := range triggers { 35 | l.elements = append(l.elements, trigger) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /service/triggers_test.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestTriggerID(t *testing.T) { 8 | trigger := Trigger{Name: "Triggy", Schedule: "* * * * * *"} 9 | if trigger.ID() != "Triggy" { 10 | t.Errorf("ID() expected %s but got %s", "Triggy", trigger.ID()) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/functional_test.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import json 3 | import unittest 4 | 5 | 6 | class GoRunnerAPI(object): 7 | def __init__(self, host): 8 | self.host = host 9 | 10 | def list_jobs(self): 11 | r = requests.get("%s/jobs" % self.host) 12 | self._raise_if_status_not(r, 200) 13 | return r.json() 14 | 15 | def list_job_names(self): 16 | jobs = self.list_jobs() 17 | return [job['name'] for job in jobs] 18 | 19 | def add_job(self, name): 20 | r = requests.post("%s/jobs" % self.host, data=json.dumps({'name': name})) 21 | self._raise_if_status_not(r, 201) 22 | 23 | def get_job(self, name): 24 | r = requests.get("%s/jobs/%s" % (self.host, name)) 25 | self._raise_if_status_not(r, 200) 26 | return r.json() 27 | 28 | def delete_job(self, name): 29 | r = requests.delete("%s/jobs/%s" % (self.host, name)) 30 | self._raise_if_status_not(r, 200) 31 | 32 | def add_task_to_job(self, task, job): 33 | r = requests.post("%s/jobs/%s/tasks" % (self.host, job), json.dumps({'task': task})) 34 | self._raise_if_status_not(r, 201) 35 | 36 | def remove_task_from_job(self, task, job): 37 | r = requests.delete("%s/jobs/%s/tasks/%s" % (self.host, job, task)) 38 | self._raise_if_status_not(r, 200) 39 | 40 | def list_tasks(self): 41 | r = requests.get("%s/tasks" % self.host) 42 | self._raise_if_status_not(r, 200) 43 | return r.json() 44 | 45 | def list_task_names(self): 46 | tasks = self.list_tasks() 47 | return [task['name'] for task in tasks] 48 | 49 | def add_task(self, name): 50 | r = requests.post("%s/tasks" % self.host, json.dumps({'name': name})) 51 | self._raise_if_status_not(r, 201) 52 | 53 | def get_task(self, name): 54 | r = requests.get("%s/tasks/%s" % (self.host, name)) 55 | self._raise_if_status_not(r, 200) 56 | return r.json() 57 | 58 | def update_task(self, name, script): 59 | r = requests.put("%s/tasks/%s" % (self.host, name), data=json.dumps({'script': script})) 60 | self._raise_if_status_not(r, 200) 61 | 62 | def delete_task(self, name): 63 | r = requests.delete("%s/tasks/%s" % (self.host, name)) 64 | self._raise_if_status_not(r, 200) 65 | 66 | def list_runs(self): 67 | r = requests.get("%s/runs" % self.host) 68 | self._raise_if_status_not(r, 200) 69 | return r.json() 70 | 71 | def list_run_ids(self): 72 | runs = self.list_runs() 73 | return [run['uuid'] for run in runs] 74 | 75 | def run_job(self, name): 76 | r = requests.post("%s/runs" % self.host, json.dumps({'job': name})) 77 | self._raise_if_status_not(r, 201) 78 | return r.json() 79 | 80 | def list_triggers(self): 81 | r = requests.get("%s/triggers" % self.host) 82 | self._raise_if_status_not(r, 200) 83 | return r.json() 84 | 85 | def list_trigger_names(self): 86 | triggers = self.list_triggers() 87 | return [trigger['name'] for trigger in triggers] 88 | 89 | def add_trigger(self, name): 90 | r = requests.post("%s/triggers" % self.host, data=json.dumps({'name': name})) 91 | self._raise_if_status_not(r, 201) 92 | 93 | def get_trigger(self, name): 94 | r = requests.get("%s/triggers/%s" % (self.host, name)) 95 | self._raise_if_status_not(r, 200) 96 | return r.json() 97 | 98 | def update_trigger(self, name, cron): 99 | r = requests.put("%s/triggers/%s" % (self.host, name), data=json.dumps({'cron': cron})) 100 | self._raise_if_status_not(r, 200) 101 | 102 | def delete_trigger(self, name): 103 | r = requests.delete("%s/triggers/%s" % (self.host, name)) 104 | self._raise_if_status_not(r, 200) 105 | 106 | def add_trigger_to_job(self, trigger, job): 107 | r = requests.post("%s/jobs/%s/triggers" % (self.host, job), data=json.dumps({'trigger': trigger})) 108 | self._raise_if_status_not(r, 201) 109 | 110 | def remove_trigger_from_job(self, trigger_idx, job): 111 | r = requests.delete("%s/jobs/%s/triggers/%s" % (self.host, job, trigger_idx)) 112 | self._raise_if_status_not(r, 200) 113 | 114 | def _raise_if_status_not(self, r, status): 115 | if r.status_code != status: 116 | raise Exception(r.text) 117 | 118 | 119 | class TestGoAPI(unittest.TestCase): 120 | def setUp(self): 121 | self.api = GoRunnerAPI("http://localhost:8090") 122 | 123 | self.test_job = "test_job999" 124 | self.test_task = "test_task999" 125 | self.test_trigger = "test_trigger999" 126 | self._clean() 127 | 128 | def tearDown(self): 129 | self._clean() 130 | 131 | def _clean(self): 132 | try: 133 | self.api.delete_job(self.test_job) 134 | except: 135 | pass 136 | try: 137 | self.api.delete_task(self.test_task) 138 | except: 139 | pass 140 | try: 141 | self.api.delete_trigger(self.test_trigger) 142 | except: 143 | pass 144 | 145 | def test_jobs(self): 146 | self.crud_test(self.api.list_job_names, self.api.delete_job, self.api.add_job, self.api.get_job) 147 | 148 | def test_tasks(self): 149 | self.crud_test(self.api.list_task_names, self.api.delete_task, self.api.add_task, self.api.get_task) 150 | 151 | def test_triggers(self): 152 | self.crud_test(self.api.list_trigger_names, self.api.delete_trigger, self.api.add_trigger, self.api.get_trigger) 153 | 154 | def test_adding_job_with_no_name(self): 155 | try: 156 | self.api.add_job("") 157 | self.fail() 158 | except Exception: 159 | pass 160 | 161 | def test_adding_job_with_no_payload(self): 162 | try: 163 | requests.post("%s/jobs" % self.api.host) 164 | self.fail() 165 | except Exception: 166 | pass 167 | 168 | def test_add_remove_task_to_job(self): 169 | self.api.add_job(self.test_job) 170 | self.api.add_task(self.test_task) 171 | self.api.add_task_to_job(self.test_task, self.test_job) 172 | 173 | job = self.api.get_job(self.test_job) 174 | self.assertIn(self.test_task, job['tasks']) 175 | 176 | self.api.remove_task_from_job(0, self.test_job) 177 | job = self.api.get_job(self.test_job) 178 | self.assertNotIn(self.test_task, job['tasks']) 179 | 180 | def test_add_remove_trigger_to_job(self): 181 | self.api.add_job(self.test_job) 182 | try: 183 | self.api.add_trigger(self.test_trigger) 184 | except: 185 | pass 186 | self.api.update_trigger(self.test_trigger, "* * * * * *") 187 | self.api.add_trigger_to_job(self.test_trigger, self.test_job) 188 | 189 | job = self.api.get_job(self.test_job) 190 | self.assertIn(self.test_trigger, job['triggers']) 191 | 192 | self.api.remove_trigger_from_job(self.test_trigger, self.test_job) 193 | job = self.api.get_job(self.test_job) 194 | self.assertNotIn(self.test_trigger, job['triggers']) 195 | 196 | def test_updating_task(self): 197 | self.api.add_task(self.test_task) 198 | task = self.api.get_task(self.test_task) 199 | self.assertEqual("", task['script']) 200 | 201 | self.api.update_task(self.test_task, "hello") 202 | task = self.api.get_task(self.test_task) 203 | self.assertEqual("hello", task['script']) 204 | 205 | def test_updating_trigger(self): 206 | self.api.add_trigger(self.test_trigger) 207 | trigger = self.api.get_trigger(self.test_trigger) 208 | self.assertEqual("", trigger['schedule']) 209 | self.api.update_trigger(self.test_trigger, "0 * * * *") 210 | trigger = self.api.get_trigger(self.test_trigger) 211 | self.assertEqual("0 * * * *", trigger['schedule']) 212 | 213 | def test_runs(self): 214 | self.api.add_job(self.test_job) 215 | self.api.add_task(self.test_task) 216 | self.api.add_task_to_job(self.test_task, self.test_job) 217 | uuid = self.api.run_job(self.test_job)['uuid'] 218 | runs = self.api.list_run_ids() 219 | self.assertIn(uuid, runs) 220 | 221 | def crud_test(self, list_names, delete, add, get): 222 | test_name = "test999" 223 | 224 | names = list_names() 225 | if test_name in names: 226 | delete(test_name) 227 | names = list_names() 228 | self.assertNotIn(test_name, names) 229 | 230 | add(test_name) 231 | names = list_names() 232 | self.assertIn(test_name, names) 233 | 234 | thing = get(test_name) 235 | self.assertEqual(test_name, thing['name']) 236 | 237 | delete(test_name) 238 | names = list_names() 239 | self.assertNotIn(test_name, names) 240 | 241 | if __name__ == "__main__": 242 | unittest.main() 243 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "io/ioutil" 7 | "net/http" 8 | ) 9 | 10 | func marshal(item interface{}, w http.ResponseWriter) { 11 | bytes, err := json.Marshal(item) 12 | if err != nil { 13 | http.Error(w, err.Error(), http.StatusBadRequest) 14 | return 15 | } 16 | w.Write(bytes) 17 | } 18 | 19 | func unmarshal(r io.Reader, k string, w http.ResponseWriter) (payload map[string]string) { 20 | data, err := ioutil.ReadAll(r) 21 | if err != nil { 22 | http.Error(w, err.Error(), http.StatusBadRequest) 23 | return 24 | } 25 | 26 | err = json.Unmarshal(data, &payload) 27 | if err != nil { 28 | http.Error(w, err.Error(), http.StatusBadRequest) 29 | return 30 | } 31 | 32 | if payload[k] == "" { 33 | http.Error(w, "Please provide a '"+k+"'", http.StatusBadRequest) 34 | return 35 | } 36 | 37 | return 38 | } 39 | -------------------------------------------------------------------------------- /web/static/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | gorunner 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 32 | 33 |
34 |
35 |
36 |
37 |
38 | Navigation 39 |
40 |
41 | 47 |
48 |
49 |
50 |
51 | Lost connection to server 52 | Most Recent Runs 53 |
54 | 66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /web/static/css/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding-top: 60px; 3 | } 4 | 5 | th:not(:first-child), td:not(:first-child) { 6 | text-align: center; 7 | } 8 | 9 | .history { 10 | font-size: 9pt; 11 | } 12 | 13 | .ng-grid-style { 14 | border: 1px solid rgb(212,212,212); 15 | width: 100%; 16 | } 17 | -------------------------------------------------------------------------------- /web/static/ext/ace/mode-sh.js: -------------------------------------------------------------------------------- 1 | /* ***** BEGIN LICENSE BLOCK ***** 2 | * Distributed under the BSD license: 3 | * 4 | * Copyright (c) 2010, Ajax.org B.V. 5 | * All rights reserved. 6 | * 7 | * Redistribution and use in source and binary forms, with or without 8 | * modification, are permitted provided that the following conditions are met: 9 | * * Redistributions of source code must retain the above copyright 10 | * notice, this list of conditions and the following disclaimer. 11 | * * Redistributions in binary form must reproduce the above copyright 12 | * notice, this list of conditions and the following disclaimer in the 13 | * documentation and/or other materials provided with the distribution. 14 | * * Neither the name of Ajax.org B.V. nor the 15 | * names of its contributors may be used to endorse or promote products 16 | * derived from this software without specific prior written permission. 17 | * 18 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | * DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY 22 | * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 25 | * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | * 29 | * ***** END LICENSE BLOCK ***** */ 30 | 31 | ace.define('ace/mode/sh', ['require', 'exports', 'module' , 'ace/lib/oop', 'ace/mode/text', 'ace/tokenizer', 'ace/mode/sh_highlight_rules', 'ace/range'], function(require, exports, module) { 32 | 33 | 34 | var oop = require("../lib/oop"); 35 | var TextMode = require("./text").Mode; 36 | var Tokenizer = require("../tokenizer").Tokenizer; 37 | var ShHighlightRules = require("./sh_highlight_rules").ShHighlightRules; 38 | var Range = require("../range").Range; 39 | 40 | var Mode = function() { 41 | this.HighlightRules = ShHighlightRules; 42 | }; 43 | oop.inherits(Mode, TextMode); 44 | 45 | (function() { 46 | 47 | 48 | this.lineCommentStart = "#"; 49 | 50 | this.getNextLineIndent = function(state, line, tab) { 51 | var indent = this.$getIndent(line); 52 | 53 | var tokenizedLine = this.getTokenizer().getLineTokens(line, state); 54 | var tokens = tokenizedLine.tokens; 55 | 56 | if (tokens.length && tokens[tokens.length-1].type == "comment") { 57 | return indent; 58 | } 59 | 60 | if (state == "start") { 61 | var match = line.match(/^.*[\{\(\[\:]\s*$/); 62 | if (match) { 63 | indent += tab; 64 | } 65 | } 66 | 67 | return indent; 68 | }; 69 | 70 | var outdents = { 71 | "pass": 1, 72 | "return": 1, 73 | "raise": 1, 74 | "break": 1, 75 | "continue": 1 76 | }; 77 | 78 | this.checkOutdent = function(state, line, input) { 79 | if (input !== "\r\n" && input !== "\r" && input !== "\n") 80 | return false; 81 | 82 | var tokens = this.getTokenizer().getLineTokens(line.trim(), state).tokens; 83 | 84 | if (!tokens) 85 | return false; 86 | do { 87 | var last = tokens.pop(); 88 | } while (last && (last.type == "comment" || (last.type == "text" && last.value.match(/^\s+$/)))); 89 | 90 | if (!last) 91 | return false; 92 | 93 | return (last.type == "keyword" && outdents[last.value]); 94 | }; 95 | 96 | this.autoOutdent = function(state, doc, row) { 97 | 98 | row += 1; 99 | var indent = this.$getIndent(doc.getLine(row)); 100 | var tab = doc.getTabString(); 101 | if (indent.slice(-tab.length) == tab) 102 | doc.remove(new Range(row, indent.length-tab.length, row, indent.length)); 103 | }; 104 | 105 | }).call(Mode.prototype); 106 | 107 | exports.Mode = Mode; 108 | }); 109 | 110 | ace.define('ace/mode/sh_highlight_rules', ['require', 'exports', 'module' , 'ace/lib/oop', 'ace/mode/text_highlight_rules'], function(require, exports, module) { 111 | 112 | 113 | var oop = require("../lib/oop"); 114 | var TextHighlightRules = require("./text_highlight_rules").TextHighlightRules; 115 | 116 | var reservedKeywords = exports.reservedKeywords = ( 117 | '!|{|}|case|do|done|elif|else|'+ 118 | 'esac|fi|for|if|in|then|until|while|'+ 119 | '&|;|export|local|read|typeset|unset|'+ 120 | 'elif|select|set' 121 | ); 122 | 123 | var languageConstructs = exports.languageConstructs = ( 124 | '[|]|alias|bg|bind|break|builtin|'+ 125 | 'cd|command|compgen|complete|continue|'+ 126 | 'dirs|disown|echo|enable|eval|exec|'+ 127 | 'exit|fc|fg|getopts|hash|help|history|'+ 128 | 'jobs|kill|let|logout|popd|printf|pushd|'+ 129 | 'pwd|return|set|shift|shopt|source|'+ 130 | 'suspend|test|times|trap|type|ulimit|'+ 131 | 'umask|unalias|wait' 132 | ); 133 | 134 | var ShHighlightRules = function() { 135 | var keywordMapper = this.createKeywordMapper({ 136 | "keyword": reservedKeywords, 137 | "support.function.builtin": languageConstructs, 138 | "invalid.deprecated": "debugger" 139 | }, "identifier"); 140 | 141 | var integer = "(?:(?:[1-9]\\d*)|(?:0))"; 142 | 143 | var fraction = "(?:\\.\\d+)"; 144 | var intPart = "(?:\\d+)"; 145 | var pointFloat = "(?:(?:" + intPart + "?" + fraction + ")|(?:" + intPart + "\\.))"; 146 | var exponentFloat = "(?:(?:" + pointFloat + "|" + intPart + ")" + ")"; 147 | var floatNumber = "(?:" + exponentFloat + "|" + pointFloat + ")"; 148 | var fileDescriptor = "(?:&" + intPart + ")"; 149 | 150 | var variableName = "[a-zA-Z][a-zA-Z0-9_]*"; 151 | var variable = "(?:(?:\\$" + variableName + ")|(?:" + variableName + "=))"; 152 | 153 | var builtinVariable = "(?:\\$(?:SHLVL|\\$|\\!|\\?))"; 154 | 155 | var func = "(?:" + variableName + "\\s*\\(\\))"; 156 | 157 | this.$rules = { 158 | "start" : [{ 159 | token : "constant", 160 | regex : /\\./ 161 | }, { 162 | token : ["text", "comment"], 163 | regex : /(^|\s)(#.*)$/ 164 | }, { 165 | token : "string", 166 | regex : '"', 167 | push : [{ 168 | token : "constant.language.escape", 169 | regex : /\\(?:[$abeEfnrtv\\'"]|x[a-fA-F\d]{1,2}|u[a-fA-F\d]{4}([a-fA-F\d]{4})?|c.|\d{1,3})/ 170 | }, { 171 | token : "constant", 172 | regex : /\$\w+/ 173 | }, { 174 | token : "string", 175 | regex : '"', 176 | next: "pop" 177 | }, { 178 | defaultToken: "string" 179 | }] 180 | }, { 181 | token : "variable.language", 182 | regex : builtinVariable 183 | }, { 184 | token : "variable", 185 | regex : variable 186 | }, { 187 | token : "support.function", 188 | regex : func 189 | }, { 190 | token : "support.function", 191 | regex : fileDescriptor 192 | }, { 193 | token : "string", // ' string 194 | start : "'", end : "'" 195 | }, { 196 | token : "constant.numeric", // float 197 | regex : floatNumber 198 | }, { 199 | token : "constant.numeric", // integer 200 | regex : integer + "\\b" 201 | }, { 202 | token : keywordMapper, 203 | regex : "[a-zA-Z_$][a-zA-Z0-9_$]*\\b" 204 | }, { 205 | token : "keyword.operator", 206 | regex : "\\+|\\-|\\*|\\*\\*|\\/|\\/\\/|~|<|>|<=|=>|=|!=" 207 | }, { 208 | token : "paren.lparen", 209 | regex : "[\\[\\(\\{]" 210 | }, { 211 | token : "paren.rparen", 212 | regex : "[\\]\\)\\}]" 213 | } ] 214 | }; 215 | 216 | this.normalizeRules(); 217 | }; 218 | 219 | oop.inherits(ShHighlightRules, TextHighlightRules); 220 | 221 | exports.ShHighlightRules = ShHighlightRules; 222 | }); 223 | -------------------------------------------------------------------------------- /web/static/ext/ace/ui-ace.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Binds a ACE Ediitor widget 3 | */ 4 | 5 | //TODO handle Could not load worker ace.js:1 6 | //DOMException {message: "SECURITY_ERR: DOM Exception 18", name: "SECURITY_ERR", code: 18, stack: "Error: An attempt was made to break through the se…cloudfront.net/src-min-noconflict/ace.js:1:76296)", INDEX_SIZE_ERR: 1…} 7 | 8 | angular.module('ui.ace', []) 9 | .constant('uiAceConfig', {}) 10 | .directive('uiAce', ['uiAceConfig', function (uiAceConfig) { 11 | if (angular.isUndefined(window.ace)) { 12 | throw new Error('ui-ace need ace to work... (o rly?)'); 13 | } 14 | return { 15 | restrict: 'EA', 16 | require: '?ngModel', 17 | link: function (scope, elm, attrs, ngModel) { 18 | var options, opts, acee, session, onChange; 19 | 20 | options = uiAceConfig.ace || {}; 21 | opts = angular.extend({}, options, scope.$eval(attrs.uiAce)); 22 | 23 | acee = window.ace.edit(elm[0]); 24 | session = acee.getSession(); 25 | 26 | onChange = function (callback) { 27 | return function (e) { 28 | var newValue = session.getValue(); 29 | if (newValue !== scope.$eval(attrs.value) && !scope.$$phase && !scope.$root.$$phase) { 30 | if (angular.isDefined(ngModel)) { 31 | scope.$apply(function () { 32 | ngModel.$setViewValue(newValue); 33 | }); 34 | } 35 | 36 | /** 37 | * Call the user onChange function. 38 | */ 39 | if (angular.isDefined(callback)) { 40 | scope.$apply(function () { 41 | if (angular.isFunction(callback)) { 42 | callback(e, acee); 43 | } 44 | else { 45 | throw new Error('ui-ace use a function as callback.'); 46 | } 47 | }); 48 | } 49 | } 50 | }; 51 | }; 52 | 53 | 54 | // Boolean options 55 | if (angular.isDefined(opts.showGutter)) { 56 | acee.renderer.setShowGutter(opts.showGutter); 57 | } 58 | if (angular.isDefined(opts.useWrapMode)) { 59 | session.setUseWrapMode(opts.useWrapMode); 60 | } 61 | 62 | // onLoad callback 63 | if (angular.isFunction(opts.onLoad)) { 64 | opts.onLoad(acee); 65 | } 66 | 67 | // Basic options 68 | if (angular.isString(opts.theme)) { 69 | acee.setTheme("ace/theme/" + opts.theme); 70 | } 71 | if (angular.isString(opts.mode)) { 72 | session.setMode("ace/mode/" + opts.mode); 73 | } 74 | 75 | attrs.$observe('readonly', function (value) { 76 | acee.setReadOnly(value === 'true'); 77 | }); 78 | 79 | // Value Blind 80 | if (angular.isDefined(ngModel)) { 81 | ngModel.$formatters.push(function (value) { 82 | if (angular.isUndefined(value) || value === null) { 83 | return ''; 84 | } 85 | else if (angular.isObject(value) || angular.isArray(value)) { 86 | throw new Error('ui-ace cannot use an object or an array as a model'); 87 | } 88 | return value; 89 | }); 90 | 91 | ngModel.$render = function () { 92 | session.setValue(ngModel.$viewValue); 93 | }; 94 | } 95 | 96 | // EVENTS 97 | session.on('change', onChange(opts.onChange)); 98 | 99 | } 100 | }; 101 | }]); -------------------------------------------------------------------------------- /web/static/ext/angular/ng-grid-2.0.7.min.js: -------------------------------------------------------------------------------- 1 | (function(e,t){"use strict";var n=6,o=4,i="asc",r="desc",l="_ng_field_",a="_ng_depth_",s="_ng_hidden_",c="_ng_column_",g=/CUSTOM_FILTERS/g,d=/COL_FIELD/g,u=/DISPLAY_CELL_TEMPLATE/g,f=/EDITABLE_CELL_TEMPLATE/g,h=/<.+>/;e.ngGrid={},e.ngGrid.i18n={},angular.module("ngGrid.services",[]);var p=angular.module("ngGrid.directives",[]),m=angular.module("ngGrid.filters",[]);angular.module("ngGrid",["ngGrid.services","ngGrid.directives","ngGrid.filters"]);var v=function(e,t,o,i){if(void 0===e.selectionProvider.selectedItems)return!0;var r,l=o.which||o.keyCode,a=!1,s=!1,c=void 0===e.selectionProvider.lastClickedRow?1:e.selectionProvider.lastClickedRow.rowIndex,g=e.columns.filter(function(e){return e.visible}),d=e.columns.filter(function(e){return e.pinned});if(e.col&&(r=g.indexOf(e.col)),37!==l&&38!==l&&39!==l&&40!==l&&9!==l&&13!==l)return!0;if(e.enableCellSelection){9===l&&o.preventDefault();var u=e.showSelectionCheckbox?1===e.col.index:0===e.col.index,f=1===e.$index||0===e.$index,h=e.$index===e.renderedColumns.length-1||e.$index===e.renderedColumns.length-2,p=g.indexOf(e.col)===g.length-1,m=d.indexOf(e.col)===d.length-1;if(37===l||9===l&&o.shiftKey){var v=0;u||(r-=1),f?u&&9===l&&o.shiftKey?(v=i.$canvas.width(),r=g.length-1,s=!0):v=i.$viewport.scrollLeft()-e.col.width:d.length>0&&(v=i.$viewport.scrollLeft()-g[r].width),i.$viewport.scrollLeft(v)}else(39===l||9===l&&!o.shiftKey)&&(h?p&&9===l&&!o.shiftKey?(i.$viewport.scrollLeft(0),r=e.showSelectionCheckbox?1:0,a=!0):i.$viewport.scrollLeft(i.$viewport.scrollLeft()+e.col.width):m&&i.$viewport.scrollLeft(0),p||(r+=1))}var w;w=e.configGroups.length>0?i.rowFactory.parsedData.filter(function(e){return!e.isAggRow}):i.filteredRows;var C=0;if(0!==c&&(38===l||13===l&&o.shiftKey||9===l&&o.shiftKey&&s)?C=-1:c!==w.length-1&&(40===l||13===l&&!o.shiftKey||9===l&&a)&&(C=1),C){var b=w[c+C];b.beforeSelectionChange(b,o)&&(b.continueSelection(o),e.$emit("ngGridEventDigestGridParent"),e.selectionProvider.lastClickedRow.renderedRowIndex>=e.renderedRows.length-n-2?i.$viewport.scrollTop(i.$viewport.scrollTop()+e.rowHeight):n+2>=e.selectionProvider.lastClickedRow.renderedRowIndex&&i.$viewport.scrollTop(i.$viewport.scrollTop()-e.rowHeight))}return e.enableCellSelection&&setTimeout(function(){e.domAccessProvider.focusCellElement(e,e.renderedColumns.indexOf(g[r]))},3),!1};String.prototype.trim||(String.prototype.trim=function(){return this.replace(/^\s+|\s+$/g,"")}),Array.prototype.indexOf||(Array.prototype.indexOf=function(e){var t=this.length>>>0,n=Number(arguments[1])||0;for(n=0>n?Math.ceil(n):Math.floor(n),0>n&&(n+=t);t>n;n++)if(n in this&&this[n]===e)return n;return-1}),Array.prototype.filter||(Array.prototype.filter=function(e){var t=Object(this),n=t.length>>>0;if("function"!=typeof e)throw new TypeError;for(var o=[],i=arguments[1],r=0;n>r;r++)if(r in t){var l=t[r];e.call(i,l,r,t)&&o.push(l)}return o}),m.filter("checkmark",function(){return function(e){return e?"✔":"✘"}}),m.filter("ngColumns",function(){return function(e){return e.filter(function(e){return!e.isAggCol})}}),angular.module("ngGrid.services").factory("$domUtilityService",["$utilityService",function(e){var n={},o={},i=function(){var e=t("
");e.appendTo("body"),e.height(100).width(100).css("position","absolute").css("overflow","scroll"),e.append('
'),n.ScrollH=e.height()-e[0].clientHeight,n.ScrollW=e.width()-e[0].clientWidth,e.empty(),e.attr("style",""),e.append('M'),n.LetterW=e.children().first().width(),e.remove()};return n.eventStorage={},n.AssignGridContainers=function(e,o,i){i.$root=t(o),i.$topPanel=i.$root.find(".ngTopPanel"),i.$groupPanel=i.$root.find(".ngGroupPanel"),i.$headerContainer=i.$topPanel.find(".ngHeaderContainer"),e.$headerContainer=i.$headerContainer,i.$headerScroller=i.$topPanel.find(".ngHeaderScroller"),i.$headers=i.$headerScroller.children(),i.$viewport=i.$root.find(".ngViewport"),i.$canvas=i.$viewport.find(".ngCanvas"),i.$footerPanel=i.$root.find(".ngFooterPanel"),e.$watch(function(){return i.$viewport.scrollLeft()},function(e){return i.$headerContainer.scrollLeft(e)}),n.UpdateGridLayout(e,i)},n.getRealWidth=function(e){var n=0,o={visibility:"hidden",display:"block"},i=e.parents().andSelf().not(":visible");return t.swap(i[0],o,function(){n=e.outerWidth()}),n},n.UpdateGridLayout=function(e,t){var o=t.$viewport.scrollTop();t.elementDims.rootMaxW=t.$root.width(),t.$root.is(":hidden")&&(t.elementDims.rootMaxW=n.getRealWidth(t.$root)),t.elementDims.rootMaxH=t.$root.height(),t.refreshDomSizes(),e.adjustScrollTop(o,!0)},n.numberOfGrids=0,n.BuildStyles=function(o,i,r){var l,a=i.config.rowHeight,s=i.$styleSheet,c=i.gridId,g=o.columns,d=0;s||(s=t("#"+c),s[0]||(s=t(""; 6 | c=d.insertBefore(c.lastChild,d.firstChild);b.hasCSS=!!c}g||t(a,b);return a}var k=l.html5||{},s=/^<|^(?:button|map|select|textarea|object|iframe|option|optgroup)$/i,r=/^(?:a|b|code|div|fieldset|h1|h2|h3|h4|h5|h6|i|label|li|ol|p|q|span|strong|style|table|tbody|td|th|tr|ul)$/i,j,o="_html5shiv",h=0,n={},g;(function(){try{var a=f.createElement("a");a.innerHTML="";j="hidden"in a;var b;if(!(b=1==a.childNodes.length)){f.createElement("a");var c=f.createDocumentFragment();b="undefined"==typeof c.cloneNode|| 7 | "undefined"==typeof c.createDocumentFragment||"undefined"==typeof c.createElement}g=b}catch(d){g=j=!0}})();var e={elements:k.elements||"abbr article aside audio bdi canvas data datalist details dialog figcaption figure footer header hgroup main mark meter nav output progress section summary template time video",version:"3.7.0",shivCSS:!1!==k.shivCSS,supportsUnknownElements:g,shivMethods:!1!==k.shivMethods,type:"default",shivDocument:q,createElement:p,createDocumentFragment:function(a,b){a||(a=f); 8 | if(g)return a.createDocumentFragment();for(var b=b||i(a),c=b.frag.cloneNode(),d=0,e=m(),h=e.length;d 2 | 3 | {{COL_FIELD.length||0}} 4 | 5 | -------------------------------------------------------------------------------- /web/static/img/spinner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakecoffman/gorunner/9bdd25aecd9ae95e0a58a7aa167fd99828759e82/web/static/img/spinner.gif -------------------------------------------------------------------------------- /web/static/js/app.js: -------------------------------------------------------------------------------- 1 | var app = angular.module("GoRunnerApp", ['ui.bootstrap', 'gorunnerServices', 'ngRoute', 'ui.ace', 'ngGrid'], function ($routeProvider) { 2 | $routeProvider.when('/jobs', { 3 | title: "jobs", 4 | templateUrl: '/static/templates/jobs.html', 5 | controller: JobsCtl 6 | }) 7 | .when('/jobs/:job', { 8 | title: "job", 9 | templateUrl: '/static/templates/job.html', 10 | controller: JobCtl 11 | }) 12 | .when('/tasks', { 13 | title: "tasks", 14 | templateUrl: '/static/templates/tasks.html', 15 | controller: TasksCtl 16 | }) 17 | .when('/tasks/:task', { 18 | title: "task", 19 | templateUrl: '/static/templates/task.html', 20 | controller: TaskCtl 21 | }) 22 | .when('/triggers', { 23 | title: 'triggers', 24 | templateUrl: '/static/templates/triggers.html', 25 | controller: TriggersCtl 26 | }) 27 | .when('/triggers/:trigger', { 28 | title: 'trigger', 29 | templateUrl: '/static/templates/trigger.html', 30 | controller: TriggerCtl 31 | }) 32 | .when('/runs', { 33 | title: 'runs', 34 | templateUrl: '/static/templates/runs.html', 35 | controller: RunsCtl 36 | }) 37 | .when('/runs/:run', { 38 | title: 'run', 39 | templateUrl: '/static/templates/run.html', 40 | controller: RunCtl 41 | }) 42 | .otherwise({ 43 | redirectTo: '/jobs' 44 | }); 45 | }); 46 | 47 | app.filter('join', function(){ 48 | return function(input) { 49 | if(input) 50 | return input.join(', '); 51 | else 52 | return ""; 53 | }; 54 | }); 55 | 56 | app.run(['$location', '$rootScope', function($location, $rootScope) { 57 | $rootScope.$on('$routeChangeSuccess', function (event, current, previous) { 58 | if(current.$$route) { 59 | $rootScope.title = current.$$route.title; 60 | } 61 | }); 62 | }]); 63 | 64 | app.controller('MainCtl', function ($scope, $timeout, Run) { 65 | $scope.recent = null; 66 | 67 | var conn = new WebSocket("ws://localhost:8090/ws"); 68 | conn.onclose = function(e) { 69 | console.log("Connection closed"); 70 | $scope.$apply(function(){ 71 | $scope.recent = null; 72 | }); 73 | }; 74 | 75 | conn.onopen = function(e) { 76 | console.log("Connected"); 77 | }; 78 | 79 | conn.onmessage = function(e){ 80 | $scope.$apply(function(){ 81 | $scope.recent = JSON.parse(e.data); 82 | }); 83 | } 84 | 85 | }); 86 | -------------------------------------------------------------------------------- /web/static/js/jobs.js: -------------------------------------------------------------------------------- 1 | function JobsCtl($scope, Job, Run) { 2 | $scope.jobs = Job.query(); 3 | $scope.selected = []; 4 | $scope.gridOptions = { 5 | data: 'jobs', 6 | plugins: [new ngGridFlexibleHeightPlugin()], 7 | multiSelect: false, 8 | selectedItems: $scope.selected, 9 | columnDefs: [ 10 | {field: 'name', displayName: 'Name'}, 11 | {field: 'status', displayName: 'Status'}, 12 | {field: 'tasks', displayName: 'Tasks', cellTemplate: '/static/gridTemplates/count.html'}, 13 | {field: 'triggers', displayName: 'Triggers', cellTemplate: '/static/gridTemplates/count.html'} 14 | ] 15 | }; 16 | 17 | $scope.quickRun = function(name) { 18 | console.log(name); 19 | var run = new Run(); 20 | run.job = name; 21 | run.$save(); 22 | }; 23 | 24 | $scope.promptJob = function() { 25 | var name = prompt("Enter name of job:"); 26 | if(name) { 27 | var job = new Job(); 28 | job.name = name; 29 | job.$save(); 30 | window.location = "/#/jobs/" + name; 31 | } 32 | } 33 | } 34 | 35 | function JobCtl($scope, $routeParams, Job, Task, Trigger) { 36 | $scope.refreshJob = function(){ 37 | $scope.job = Job.get({id: $routeParams.job}) 38 | }; 39 | 40 | $scope.refreshTasks = function(){ 41 | $scope.tasks = Task.query(); 42 | }; 43 | 44 | $scope.refreshTriggers = function() { 45 | $scope.triggers = Trigger.query(); 46 | }; 47 | 48 | $scope.removeTask = function(idx) { 49 | Job.removeTask({tidx: idx, id: $routeParams.job}); 50 | $scope.refreshJob(); 51 | }; 52 | 53 | $scope.removeTrigger = function(name) { 54 | Job.removeTrigger({trigger: name, id: $routeParams.job}); 55 | $scope.refreshJob() 56 | }; 57 | 58 | $scope.addTaskToJob = function(task) { 59 | Job.addTask({task: task, id: $routeParams.job}); 60 | $scope.refreshJob(); 61 | }; 62 | 63 | $scope.addTriggerToJob = function(trigger) { 64 | Job.addTrigger({trigger: trigger, id: $routeParams.job}); 65 | $scope.refreshJob(); 66 | }; 67 | 68 | $scope.deleteJob = function() { 69 | Job.$delete({id: $routeParams.job}); 70 | window.location = "/#/jobs"; 71 | }; 72 | 73 | $scope.refreshJob(); 74 | $scope.refreshTasks(); 75 | $scope.refreshTriggers(); 76 | } 77 | -------------------------------------------------------------------------------- /web/static/js/runs.js: -------------------------------------------------------------------------------- 1 | app.filter('datetime', function(){ 2 | return function(input){ 3 | var dt = new Date(Date.parse(input)); 4 | return dt.toLocaleString(); 5 | } 6 | }); 7 | 8 | function RunsCtl($scope, Run) { 9 | $scope.runs = Run.query(); 10 | $scope.selected = []; 11 | 12 | $scope.blah = function(data) { 13 | console.log(data); 14 | return data; 15 | }; 16 | 17 | $scope.gridOptions = { 18 | data: 'runs', 19 | plugins: [new ngGridFlexibleHeightPlugin()], 20 | multiSelect: false, 21 | selectedItems: $scope.selected, 22 | columnDefs: [ 23 | {field: 'uuid', displayName: 'UUID'}, 24 | {field: 'job.name', displayName: 'Job'}, 25 | {field: 'tasks', displayName: 'Tasks', cellTemplate: '/static/gridTemplates/count.html'}, 26 | {field: 'status', displayName: 'Status'}, 27 | {field: 'start', displayName: 'Start', cellFilter: 'datetime'}, 28 | {field: 'end', displayName: 'End', cellFilter: 'datetime'} 29 | ] 30 | }; 31 | } 32 | 33 | function RunCtl($scope, $routeParams, $timeout, Run) { 34 | var update = function() { 35 | Run.get({id: $routeParams.run}, function(data) { 36 | $scope.run = data; 37 | if (data.status == "Running") { 38 | $timeout(update, 3000); 39 | } 40 | }); 41 | }; 42 | $scope.run |= {}; 43 | update(); 44 | 45 | } 46 | -------------------------------------------------------------------------------- /web/static/js/services.js: -------------------------------------------------------------------------------- 1 | var gorunnerServices = angular.module('gorunnerServices', ['ngResource']); 2 | 3 | gorunnerServices.factory('Job', ['$resource', function($resource){ 4 | return $resource('/jobs/:id', {}, { 5 | update: { method: "PUT", params: {id: '@id'}}, 6 | addTask: { method: "POST", url: '/jobs/:id/tasks', params: {id: '@id'}}, 7 | removeTask: { method: "DELETE", url: '/jobs/:id/tasks/:tidx', params: {id: '@id', tid: '@tidx'}}, 8 | addTrigger: { method: "POST", url: '/jobs/:id/triggers', params: {id: '@id'}}, 9 | removeTrigger: { method: "DELETE", url: '/jobs/:id/triggers/:trigger', params: {id: '@id', trigger: '@trigger'}} 10 | }) 11 | }]); 12 | 13 | gorunnerServices.factory('Task', ['$resource', function($resource){ 14 | return $resource('/tasks/:id', {}, { 15 | update: { method: "PUT" , params: {id: '@id'}}, 16 | jobs: { method: "GET", url: 'tasks/:id/jobs', params: {id: '@id'}, isArray: true} 17 | }) 18 | }]); 19 | 20 | gorunnerServices.factory('Trigger', ['$resource', function($resource){ 21 | return $resource('/triggers/:id', {}, { 22 | update: { method: "PUT", params: {id: '@id'}}, 23 | listJobs: { method: "GET", url: '/triggers/:id/jobs', params: {id: '@id'}, isArray: true} 24 | }) 25 | }]); 26 | 27 | gorunnerServices.factory('Run', ['$resource', function($resource) { 28 | return $resource('/runs/:id', {}, {}) 29 | }]); 30 | -------------------------------------------------------------------------------- /web/static/js/tasks.js: -------------------------------------------------------------------------------- 1 | 2 | function TasksCtl($scope, Task) { 3 | $scope.tasks = Task.query(); 4 | 5 | $scope.promptTask = function() { 6 | var name = prompt("Enter name of task:"); 7 | if(name) { 8 | var newTask = new Task(); 9 | newTask.name = name; 10 | newTask.$save(); 11 | window.location = "/#/tasks/" + name; 12 | } 13 | }; 14 | } 15 | 16 | function TaskCtl($scope, $routeParams, Task) { 17 | $scope.task = Task.get({id: $routeParams.task}); 18 | $scope.jobs = Task.jobs({id: $routeParams.task}); 19 | 20 | $scope.saveTask = function() { 21 | Task.update({id: $routeParams.task, script: $scope.task.script}); 22 | window.location = "/#/tasks"; 23 | }; 24 | 25 | $scope.deleteTask = function() { 26 | Task.$delete({id: $routeParams.task}); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /web/static/js/triggers.js: -------------------------------------------------------------------------------- 1 | function TriggersCtl($scope, Trigger) { 2 | $scope.triggers = Trigger.query(); 3 | $scope.selected = []; 4 | $scope.gridOptions = { 5 | data: 'triggers', 6 | plugins: [new ngGridFlexibleHeightPlugin()], 7 | multiSelect: false, 8 | selectedItems: $scope.selected 9 | }; 10 | 11 | $scope.deleteTrigger = function(name) { 12 | Trigger.delete({id: name}); 13 | $scope.triggers = Trigger.query(); 14 | }; 15 | 16 | $scope.promptTrigger = function(){ 17 | var name = prompt("Enter a name for the new trigger"); 18 | if(name) { 19 | var trigger = new Trigger(); 20 | trigger.name = name; 21 | trigger.$save(); 22 | $scope.triggers = Trigger.query(); 23 | } 24 | } 25 | } 26 | 27 | function TriggerCtl($scope, $routeParams, Trigger) { 28 | $scope.trigger = Trigger.get({id: $routeParams.trigger}); 29 | 30 | $scope.saveTrigger = function() { 31 | Trigger.update({id: $scope.trigger.name, cron: $scope.trigger.schedule}); 32 | window.location = "/#/triggers"; 33 | }; 34 | 35 | $scope.jobs = Trigger.listJobs({id: $routeParams.trigger}); 36 | } 37 | -------------------------------------------------------------------------------- /web/static/templates/job.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 7 | 10 |
11 |
12 |

Job:

13 |
14 |
Status
15 |
16 |
17 |
18 | 20 | 25 |
26 |

Triggers

27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 47 | 48 | 49 |
NameRemove
{{trigger}} 43 | 46 |
50 |

There are no Triggers defined for this job.

51 | 52 |
53 | 56 | 61 |
62 |

Tasks

63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 86 | 91 | 92 | 93 |
NameOrderRemove
{{ task }} 82 | 85 | 87 | 90 |
94 | 95 |

There are no Tasks defined for this job.

96 | 97 | 98 | -------------------------------------------------------------------------------- /web/static/templates/jobs.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 7 | 10 |
11 |
12 |

Jobs

13 |
14 | 15 | Open 16 | 17 | 20 |
21 |
22 |
23 |
24 | -------------------------------------------------------------------------------- /web/static/templates/run.html: -------------------------------------------------------------------------------- 1 |

Run

2 |
3 |
UUID
4 |
{{run.uuid}}
5 |
6 | -------------------------------------------------------------------------------- /web/static/templates/runs.html: -------------------------------------------------------------------------------- 1 |

Runs

2 |

3 | No runs yet. To create a run, first create a Task and assign it to a 4 | Job, then run the job. 5 |

6 |
7 | 8 | Open 9 | 10 |
11 |
12 |
13 | -------------------------------------------------------------------------------- /web/static/templates/task.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 7 | 10 |
11 |
12 |

{{ task.name }}

13 | 16 |
17 |
18 | 19 |

Bindings

20 |

This task is not bound to any jobs.

21 |

This task is bound to the following jobs:

22 | 25 | -------------------------------------------------------------------------------- /web/static/templates/tasks.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 7 | 10 |
11 |
12 |

Tasks

13 |

There are not any tasks yet. You can add one now by clicking on the gear icon above.

14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |
Task
{{ task.name }}
26 | -------------------------------------------------------------------------------- /web/static/templates/trigger.html: -------------------------------------------------------------------------------- 1 |

{{ trigger.name }}

2 |

Enter how often this trigger should fire:

3 | 4 |
5 | 6 | 7 |

Bindings

8 |

This trigger is not bound to any jobs.

9 |

This trigger is bound to the following jobs.

10 | -------------------------------------------------------------------------------- /web/static/templates/triggers.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 7 | 11 |
12 |
13 |

Triggers

14 |

There are no triggers. You can add one by clicking the gear above.

15 |
16 | 17 | Open 18 | 19 | 22 |
23 |
24 |
25 |
26 |
27 | --------------------------------------------------------------------------------