├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── api ├── api_handler.go ├── event.go ├── event_test.go ├── healthz.go └── healthz_test.go ├── build ├── build-container ├── circle.yml ├── conf └── conf.go ├── glide.lock ├── glide.yaml ├── main.go ├── mock ├── repo_mock.go └── scheduler_mock.go ├── models ├── db.go ├── event.go └── query.go ├── repo └── repo.go ├── runner ├── runner.go └── runner_test.go └── scheduler ├── scheduler.go └── scheduler_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | gom 2 | vendor/** 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | MAINTAINER Rafael Jesus 3 | ADD cron-srv /cron-srv 4 | ENV CRON_SRV_DB="postgres://postgres:@docker/cron_srv?sslmode=disable" 5 | ENV CRON_SRV_PORT="3000" 6 | ENTRYPOINT ["/cron-srv"] 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Rafael Jesus 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 | ## Cron Srv 2 | 3 | * All the flexibility and power of Cron as a Service. 4 | * Simple REST protocol, integrating with a web application in a easy and straightforward way. 5 | * No more wasting time building and managing scheduling infrastructure. 6 | 7 | ## Basic Concepts 8 | Cron Srv works by calling back to your application via HTTP GET according to a schedule constructed by you or your application. 9 | 10 | ## Setup 11 | Env vars 12 | ```bash 13 | export CRON_SRV_DB="postgresql://postgres@localhost/cron_srv_dev?sslmode=disable" 14 | export CRON_SRV_PORT=3000 15 | ``` 16 | > **Note:** You must have created the database 'cron_srv_dev' in postgres running at localhost (or replace with valid database name and IP); 17 | 18 | ```sh 19 | mkdir -p $GOPATH/src/github.com/EmpregoLigado 20 | cd $GOPATH/src/github.com/EmpregoLigado 21 | git clone https://github.com/EmpregoLigado/cron-srv.git 22 | cd cron-srv 23 | glide install 24 | go build 25 | ``` 26 | 27 | ## Running server 28 | ``` 29 | ./cron-srv 30 | # => Starting Cron Service at port 3000 31 | ``` 32 | 33 | ### Create an Cron 34 | - Request 35 | ```bash 36 | curl -X POST -H "Content-Type: application/json" \ 37 | -d '{"url": "example.com/api/v1/stats", "expression": "0 5 * * * *", "status": "active", "max_retries": 2, "retry_timeout": 3}' \ 38 | localhost:3000/v1/events 39 | ``` 40 | 41 | - Response 42 | ```json 43 | { 44 | "id": 1, 45 | "url": "example.com/api/v1/stats", 46 | "expression": "0 5 * * * *", 47 | "status": "active", 48 | "max_retries": 2, 49 | "retry_timeout": 3, 50 | "updated_at": "2016-12-10T14:02:37.064641296-02:00" 51 | } 52 | ``` 53 | 54 | ## API Documentation 55 | |HTTP verb| path| handle| 56 | |:--|:--|:--|:--| 57 | |GET |/v1/healthz|HealthzIndex |return a state of server `{"alive":"up"}`| 58 | |GET |/v1/events|EventsIndex |display a list of all events| 59 | |POST |/v1/events|EventsCreate |create a new event| 60 | |GET |/v1/events/:id|EventsShow |display a specific event| 61 | |PUT |/v1/events/:id|EventsUpdate |update a specific event| 62 | |DELETE |/v1/events/:id|EventsDelete |delete a specific event| 63 | 64 | ## Cron Format 65 | The cron expression format allowed is: 66 | 67 | |Field name| Mandatory?|Allowed values|Allowed special characters| 68 | |:--|:--|:--|:--| 69 | |Seconds | Yes | 0-59 | * / , -| 70 | |Minutes | Yes | 0-59 | * / , -| 71 | |Hours | Yes | 0-23 | * / , -| 72 | |Day of month | Yes | 1-31 | * / , - ?| 73 | |Month | Yes | 1-12 or JAN-DEC | * / , -| 74 | |Day of week | Yes | 0-6 or SUN-SAT | * / , - ?| 75 | more details about expression format [here](https://godoc.org/github.com/robfig/cron#hdr-CRON_Expression_Format) 76 | 77 | ## Contributing 78 | - Fork it 79 | - Create your feature branch (`git checkout -b my-new-feature`) 80 | - Commit your changes (`git commit -am 'Add some feature'`) 81 | - Push to the branch (`git push origin my-new-feature`) 82 | - Create new Pull Request 83 | 84 | ## Badges 85 | [![CircleCI](https://circleci.com/gh/EmpregoLigado/cron-srv.svg?style=svg)](https://circleci.com/gh/EmpregoLigado/cron-srv) 86 | [![Go Report Card](https://goreportcard.com/badge/github.com/EmpregoLigado/cron-srv)](https://goreportcard.com/report/github.com/EmpregoLigado/cron-srv) 87 | [![](https://images.microbadger.com/badges/image/rafaeljesus/cron-srv.svg)](https://microbadger.com/images/rafaeljesus/cron-srv "Get your own image badge on microbadger.com") 88 | [![](https://images.microbadger.com/badges/version/rafaeljesus/cron-srv.svg)](https://microbadger.com/images/rafaeljesus/cron-srv "Get your own version badge on microbadger.com") 89 | -------------------------------------------------------------------------------- /api/api_handler.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/EmpregoLigado/cron-srv/repo" 6 | "github.com/EmpregoLigado/cron-srv/scheduler" 7 | "net/http" 8 | ) 9 | 10 | type APIHandler struct { 11 | Repo repo.Repo 12 | Scheduler scheduler.Scheduler 13 | } 14 | 15 | func NewAPIHandler(r repo.Repo, s scheduler.Scheduler) *APIHandler { 16 | return &APIHandler{ 17 | Repo: r, 18 | Scheduler: s, 19 | } 20 | } 21 | 22 | func JSON(w http.ResponseWriter, code int, v interface{}) { 23 | w.Header().Set("Content-Type", "application/json") 24 | w.WriteHeader(code) 25 | 26 | if v != nil || code == http.StatusNoContent { 27 | json.NewEncoder(w).Encode(v) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /api/event.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/EmpregoLigado/cron-srv/models" 6 | "github.com/nbari/violetear" 7 | "net/http" 8 | "strconv" 9 | ) 10 | 11 | func (h *APIHandler) EventsIndex(w http.ResponseWriter, r *http.Request) { 12 | status := r.URL.Query().Get("status") 13 | expression := r.URL.Query().Get("expression") 14 | query := models.NewQuery(status, expression) 15 | events := []models.Event{} 16 | 17 | if err := h.Repo.FindEvents(&events, query); err != nil { 18 | JSON(w, http.StatusPreconditionFailed, err) 19 | return 20 | } 21 | 22 | JSON(w, http.StatusOK, &events) 23 | } 24 | 25 | func (h *APIHandler) EventsCreate(w http.ResponseWriter, r *http.Request) { 26 | event := new(models.Event) 27 | if err := json.NewDecoder(r.Body).Decode(event); err != nil { 28 | JSON(w, http.StatusBadRequest, err) 29 | return 30 | } 31 | 32 | if err := h.Repo.CreateEvent(event); err != nil { 33 | JSON(w, http.StatusUnprocessableEntity, err) 34 | return 35 | } 36 | 37 | if err := h.Scheduler.Create(event); err != nil { 38 | JSON(w, http.StatusInternalServerError, err) 39 | return 40 | } 41 | 42 | JSON(w, http.StatusCreated, event) 43 | } 44 | 45 | func (h *APIHandler) EventsShow(w http.ResponseWriter, r *http.Request) { 46 | params := r.Context().Value(violetear.ParamsKey).(violetear.Params) 47 | id, err := strconv.Atoi(params[":id"].(string)) 48 | if err != nil { 49 | JSON(w, http.StatusBadRequest, err) 50 | return 51 | } 52 | 53 | event := new(models.Event) 54 | if err := h.Repo.FindEventById(event, id); err != nil { 55 | JSON(w, http.StatusNotFound, err) 56 | return 57 | } 58 | 59 | JSON(w, http.StatusOK, event) 60 | } 61 | 62 | func (h *APIHandler) EventsUpdate(w http.ResponseWriter, r *http.Request) { 63 | params := r.Context().Value(violetear.ParamsKey).(violetear.Params) 64 | id, err := strconv.Atoi(params[":id"].(string)) 65 | if err != nil { 66 | JSON(w, http.StatusBadRequest, err) 67 | return 68 | } 69 | 70 | event := new(models.Event) 71 | if err := h.Repo.FindEventById(event, id); err != nil { 72 | JSON(w, http.StatusNotFound, err) 73 | return 74 | } 75 | 76 | e := new(models.Event) 77 | if err := json.NewDecoder(r.Body).Decode(e); err != nil { 78 | JSON(w, http.StatusBadRequest, err) 79 | return 80 | } 81 | 82 | event.Status = e.Status 83 | event.Expression = e.Expression 84 | event.Url = e.Url 85 | event.Retries = e.Retries 86 | event.Timeout = e.Timeout 87 | 88 | if err := h.Repo.UpdateEvent(event); err != nil { 89 | JSON(w, http.StatusUnprocessableEntity, err) 90 | return 91 | } 92 | 93 | if err := h.Scheduler.Update(event); err != nil { 94 | JSON(w, http.StatusInternalServerError, err) 95 | return 96 | } 97 | 98 | JSON(w, http.StatusOK, event) 99 | } 100 | 101 | func (h *APIHandler) EventsDelete(w http.ResponseWriter, r *http.Request) { 102 | params := r.Context().Value(violetear.ParamsKey).(violetear.Params) 103 | id, err := strconv.Atoi(params[":id"].([]string)[0]) 104 | if err != nil { 105 | JSON(w, http.StatusBadRequest, err) 106 | return 107 | } 108 | 109 | event := new(models.Event) 110 | if err := h.Repo.FindEventById(event, id); err != nil { 111 | JSON(w, http.StatusNotFound, err) 112 | return 113 | } 114 | 115 | if err := h.Repo.DeleteEvent(event); err != nil { 116 | JSON(w, http.StatusUnprocessableEntity, err) 117 | return 118 | } 119 | 120 | if err := h.Scheduler.Delete(event.Id); err != nil { 121 | JSON(w, http.StatusInternalServerError, err) 122 | return 123 | } 124 | 125 | JSON(w, http.StatusNoContent, nil) 126 | } 127 | -------------------------------------------------------------------------------- /api/event_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/EmpregoLigado/cron-srv/mock" 6 | "github.com/EmpregoLigado/cron-srv/models" 7 | "github.com/nbari/violetear" 8 | "net/http" 9 | "net/http/httptest" 10 | "strconv" 11 | "strings" 12 | "testing" 13 | ) 14 | 15 | func TestEventsIndex(t *testing.T) { 16 | schedulerMock := mock.NewScheduler() 17 | repoMock := mock.NewRepo() 18 | h := NewAPIHandler(repoMock, schedulerMock) 19 | 20 | res := httptest.NewRecorder() 21 | req, err := http.NewRequest("GET", "/v1/events", nil) 22 | if err != nil { 23 | t.Errorf("Expected initialize request %s", err) 24 | } 25 | 26 | r := violetear.New() 27 | r.HandleFunc("/v1/events", h.EventsIndex, "GET") 28 | r.ServeHTTP(res, req) 29 | 30 | events := []models.Event{} 31 | if err := json.NewDecoder(res.Body).Decode(&events); err != nil { 32 | t.Errorf("Expected to decode response json %s", err) 33 | } 34 | 35 | if len(events) == 0 { 36 | t.Errorf("Expected response to not be empty %s", strconv.Itoa(len(events))) 37 | } 38 | 39 | if res.Code != http.StatusOK { 40 | t.Errorf("Expected status %d to be equal %d", res.Code, http.StatusOK) 41 | } 42 | } 43 | 44 | func TestEventsIndexByStatus(t *testing.T) { 45 | schedulerMock := mock.NewScheduler() 46 | repoMock := mock.NewRepo() 47 | h := NewAPIHandler(repoMock, schedulerMock) 48 | 49 | res := httptest.NewRecorder() 50 | req, err := http.NewRequest("GET", "/v1/events?status=active", nil) 51 | if err != nil { 52 | t.Errorf("Expected initialize request %s", err) 53 | } 54 | 55 | r := violetear.New() 56 | r.HandleFunc("/v1/events", h.EventsIndex, "GET") 57 | r.ServeHTTP(res, req) 58 | 59 | if !repoMock.ByStatus { 60 | t.Errorf("Expected to search by status") 61 | } 62 | 63 | if res.Code != http.StatusOK { 64 | t.Errorf("Expected status %d to be equal %d", res.Code, http.StatusOK) 65 | } 66 | } 67 | 68 | func TestEventsIndexByExpression(t *testing.T) { 69 | schedulerMock := mock.NewScheduler() 70 | repoMock := mock.NewRepo() 71 | h := NewAPIHandler(repoMock, schedulerMock) 72 | 73 | res := httptest.NewRecorder() 74 | req, err := http.NewRequest("GET", "/v1/events?expression=* * * * *", nil) 75 | if err != nil { 76 | t.Errorf("Expected initialize request %s", err) 77 | } 78 | 79 | r := violetear.New() 80 | r.HandleFunc("/v1/events", h.EventsIndex, "GET") 81 | r.ServeHTTP(res, req) 82 | 83 | if !repoMock.ByExpression { 84 | t.Errorf("Expected to search by expression") 85 | } 86 | 87 | if res.Code != http.StatusOK { 88 | t.Errorf("Expected status %d to be equal %d", res.Code, http.StatusOK) 89 | } 90 | } 91 | 92 | func TestEventCreate(t *testing.T) { 93 | schedulerMock := mock.NewScheduler() 94 | repoMock := mock.NewRepo() 95 | h := NewAPIHandler(repoMock, schedulerMock) 96 | 97 | res := httptest.NewRecorder() 98 | body := strings.NewReader(`{"url":"http://foo.com"}`) 99 | req, err := http.NewRequest("POST", "/v1/events", body) 100 | if err != nil { 101 | t.Errorf("Expected initialize request %s", err) 102 | } 103 | 104 | r := violetear.New() 105 | r.HandleFunc("/v1/events", h.EventsCreate, "POST") 106 | r.ServeHTTP(res, req) 107 | 108 | if !repoMock.Created { 109 | t.Error("Expected repo create to be called") 110 | } 111 | 112 | if !schedulerMock.Created { 113 | t.Error("Expected scheduler create to be called") 114 | } 115 | 116 | if res.Code != http.StatusCreated { 117 | t.Errorf("Expected status %d to be equal %d", res.Code, http.StatusOK) 118 | } 119 | } 120 | 121 | func TestEventShow(t *testing.T) { 122 | schedulerMock := mock.NewScheduler() 123 | repoMock := mock.NewRepo() 124 | h := NewAPIHandler(repoMock, schedulerMock) 125 | 126 | res := httptest.NewRecorder() 127 | req, err := http.NewRequest("GET", "/v1/events/1", nil) 128 | if err != nil { 129 | t.Errorf("Expected initialize request %s", err) 130 | } 131 | 132 | r := violetear.New() 133 | r.AddRegex(":id", `^\d+$`) 134 | r.HandleFunc("/v1/events/:id", h.EventsShow, "GET") 135 | r.ServeHTTP(res, req) 136 | 137 | if !repoMock.Found { 138 | t.Error("Expected repo findEventById to be called") 139 | } 140 | 141 | if res.Code != http.StatusOK { 142 | t.Errorf("Expected status %d to be equal %d", res.Code, http.StatusOK) 143 | } 144 | } 145 | 146 | func TestEventsUpdate(t *testing.T) { 147 | schedulerMock := mock.NewScheduler() 148 | repoMock := mock.NewRepo() 149 | h := NewAPIHandler(repoMock, schedulerMock) 150 | 151 | res := httptest.NewRecorder() 152 | body := strings.NewReader(`{"url":"http://foo.com"}`) 153 | req, err := http.NewRequest("PUT", "/v1/events/1", body) 154 | if err != nil { 155 | t.Errorf("Expected initialize request %s", err) 156 | } 157 | 158 | r := violetear.New() 159 | r.AddRegex(":id", `^\d+$`) 160 | r.HandleFunc("/v1/events/:id", h.EventsUpdate, "PUT") 161 | r.ServeHTTP(res, req) 162 | 163 | if !repoMock.Updated { 164 | t.Error("Expected repo update to be called") 165 | } 166 | 167 | if !schedulerMock.Updated { 168 | t.Error("Expected scheduler update to be called") 169 | } 170 | 171 | if res.Code != http.StatusOK { 172 | t.Errorf("Expected status %d to be equal %d", res.Code, http.StatusOK) 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /api/healthz.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | func (h *APIHandler) HealthzIndex(w http.ResponseWriter, r *http.Request) { 8 | response := map[string]string{"status": "up"} 9 | JSON(w, http.StatusOK, response) 10 | } 11 | -------------------------------------------------------------------------------- /api/healthz_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/nbari/violetear" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | ) 10 | 11 | func TestHealthzIndex(t *testing.T) { 12 | h := new(APIHandler) 13 | 14 | res := httptest.NewRecorder() 15 | req, err := http.NewRequest("GET", "/v1/healthz", nil) 16 | if err != nil { 17 | t.Errorf("Expected initialize request %s", err) 18 | } 19 | 20 | r := violetear.New() 21 | r.HandleFunc("/v1/healthz", h.HealthzIndex, "GET") 22 | r.ServeHTTP(res, req) 23 | 24 | response := make(map[string]string) 25 | if err := json.NewDecoder(res.Body).Decode(&response); err != nil { 26 | t.Errorf("Expected to decode response json %s", err) 27 | } 28 | 29 | if response["status"] != "up" { 30 | t.Errorf("Expected status to equal %s", response["status"]) 31 | } 32 | 33 | if res.Code != http.StatusOK { 34 | t.Error("Expected status %s to be equal %s", res.Code, http.StatusOK) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /build: -------------------------------------------------------------------------------- 1 | CGO_ENABLED=0 GOOS=linux go build -a --ldflags '-extldflags "-static"' -tags netgo -installsuffix netgo -o cron-srv . 2 | -------------------------------------------------------------------------------- /build-container: -------------------------------------------------------------------------------- 1 | GOOS=linux bash build 2 | docker build -t rafaeljesus/cron-srv . 3 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | environment: 3 | IMPORT_PATH: "/home/ubuntu/.go_workspace/src/github.com/EmpregoLigado" 4 | APP_PATH: "$IMPORT_PATH/cron-srv" 5 | services: 6 | - docker 7 | 8 | dependencies: 9 | pre: 10 | - sudo add-apt-repository ppa:masterminds/glide -y 11 | - sudo apt-get update 12 | - sudo apt-get install glide -y 13 | - mkdir -p "$IMPORT_PATH" 14 | override: 15 | - ln -sf "$(pwd)" "$APP_PATH" 16 | - cd "$APP_PATH" && glide install 17 | 18 | test: 19 | override: 20 | - cd "$APP_PATH" && go vet && go test ./api/... ./scheduler/... ./runner/... -v -race -cover $(glide nv) 21 | 22 | deployment: 23 | master: 24 | branch: master 25 | commands: 26 | - cd "$APP_PATH" && sh build 27 | - docker build -t rafaeljesus/cron-srv . 28 | - docker login -e $DOCKERHUB_EMAIL -u $DOCKERHUB_USER -p $DOCKERHUB_PASS 29 | - docker tag rafaeljesus/cron-srv rafaeljesus/cron-srv:master 30 | - docker push rafaeljesus/cron-srv:master 31 | -------------------------------------------------------------------------------- /conf/conf.go: -------------------------------------------------------------------------------- 1 | package conf 2 | 3 | import "github.com/spf13/viper" 4 | 5 | var ( 6 | CRON_SRV_DB string 7 | CRON_SRV_PORT string 8 | ) 9 | 10 | func init() { 11 | viper.AutomaticEnv() 12 | 13 | CRON_SRV_DB = viper.GetString("CRON_SRV_DB") 14 | CRON_SRV_PORT = viper.GetString("CRON_SRV_PORT") 15 | } 16 | -------------------------------------------------------------------------------- /glide.lock: -------------------------------------------------------------------------------- 1 | hash: bdaf457395442ca15686831c2f463e316588574477f2d3d1d2c4ec2ec2ad5534 2 | updated: 2017-03-20T12:55:21.238520192-03:00 3 | imports: 4 | - name: github.com/cenk/backoff 5 | version: 3db60c813733fce657c114634171689bbf1f8dee 6 | - name: github.com/facebookgo/clock 7 | version: 600d898af40aa09a7a93ecb9265d87b0504b6f03 8 | - name: github.com/fsnotify/fsnotify 9 | version: fd9ec7deca8bf46ecd2a795baaacf2b3a9be1197 10 | - name: github.com/hashicorp/hcl 11 | version: 39fa3a62ba92cf550eb0f9cfb84757ef79b8aa30 12 | subpackages: 13 | - hcl/ast 14 | - hcl/parser 15 | - hcl/scanner 16 | - hcl/strconv 17 | - hcl/token 18 | - json/parser 19 | - json/scanner 20 | - json/token 21 | - name: github.com/jinzhu/gorm 22 | version: 5174cc5c242a728b435ea2be8a2f7f998e15429b 23 | subpackages: 24 | - dialects/postgres 25 | - name: github.com/jinzhu/inflection 26 | version: 1c35d901db3da928c72a72d8458480cc9ade058f 27 | - name: github.com/lib/pq 28 | version: 67c3f2a8884c9b1aac5503c8d42ae4f73a93511c 29 | subpackages: 30 | - hstore 31 | - oid 32 | - name: github.com/magiconair/properties 33 | version: b3b15ef068fd0b17ddf408a23669f20811d194d2 34 | - name: github.com/mitchellh/mapstructure 35 | version: ed105d635dfa9ea7133f7c79f1eb36203fc3a156 36 | - name: github.com/nbari/violetear 37 | version: 4793bb4af8b6acda065b07bbf5e5723624ee9d8c 38 | - name: github.com/pelletier/go-buffruneio 39 | version: df1e16fde7fc330a0ca68167c23bf7ed6ac31d6d 40 | - name: github.com/pelletier/go-toml 41 | version: a1f048ba24490f9b0674a67e1ce995d685cddf4a 42 | - name: github.com/robfig/cron 43 | version: b024fc5ea0e34bc3f83d9941c8d60b0622bfaca4 44 | - name: github.com/rubyist/circuitbreaker 45 | version: 7e3e7fbe9c62b943d487af023566a79d9eb22d3b 46 | - name: github.com/Sirupsen/logrus 47 | version: ba1b36c82c5e05c4f912a88eab0dcd91a171688f 48 | - name: github.com/spf13/afero 49 | version: 72b31426848c6ef12a7a8e216708cb0d1530f074 50 | subpackages: 51 | - mem 52 | - name: github.com/spf13/cast 53 | version: 56a7ecbeb18dde53c6db4bd96b541fd9741b8d44 54 | - name: github.com/spf13/jwalterweatherman 55 | version: fa7ca7e836cf3a8bb4ebf799f472c12d7e903d66 56 | - name: github.com/spf13/pflag 57 | version: a232f6d9f87afaaa08bafaff5da685f974b83313 58 | - name: github.com/spf13/viper 59 | version: 5ed0fc31f7f453625df314d8e66b9791e8d13003 60 | - name: golang.org/x/net 61 | version: f315505cf3349909cdf013ea56690da34e96a451 62 | subpackages: 63 | - context 64 | - name: golang.org/x/sys 65 | version: d75a52659825e75fff6158388dddc6a5b04f9ba5 66 | subpackages: 67 | - unix 68 | - name: golang.org/x/text 69 | version: 11dbc599981ccdf4fb18802a28392a8bcf7a9395 70 | subpackages: 71 | - transform 72 | - unicode/norm 73 | - name: gopkg.in/h2non/gock.v1 74 | version: 2897ffde93a71060ce86518f1e7317ec8433d2d6 75 | - name: gopkg.in/yaml.v2 76 | version: a5b47d31c556af34a302ce5d659e6fea44d90de0 77 | testImports: [] 78 | -------------------------------------------------------------------------------- /glide.yaml: -------------------------------------------------------------------------------- 1 | package: github.com/EmpregoLigado/cron-srv 2 | import: 3 | - package: github.com/Sirupsen/logrus 4 | version: v0.11.5 5 | - package: github.com/jinzhu/gorm 6 | version: v1.0 7 | subpackages: 8 | - dialects/postgres 9 | - package: github.com/nbari/violetear 10 | version: 3.0.0 11 | - package: github.com/robfig/cron 12 | version: v1 13 | - package: github.com/spf13/viper 14 | - package: github.com/rubyist/circuitbreaker 15 | version: v2.2.0 16 | - package: gopkg.in/h2non/gock.v1 17 | version: v1.0.4 18 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/EmpregoLigado/cron-srv/api" 5 | "github.com/EmpregoLigado/cron-srv/conf" 6 | "github.com/EmpregoLigado/cron-srv/models" 7 | "github.com/EmpregoLigado/cron-srv/scheduler" 8 | log "github.com/Sirupsen/logrus" 9 | "github.com/nbari/violetear" 10 | "net/http" 11 | "runtime" 12 | ) 13 | 14 | func main() { 15 | numcpu := runtime.NumCPU() 16 | runtime.GOMAXPROCS(numcpu) 17 | 18 | db, err := models.NewDB(models.DBConfig{ 19 | Url: conf.CRON_SRV_DB, 20 | }) 21 | 22 | if err != nil { 23 | log.WithError(err).Fatal("Failed to init database connection!") 24 | return 25 | } 26 | defer db.Close() 27 | event := new(models.Event) 28 | db.AutoMigrate(event) 29 | 30 | sc := scheduler.New() 31 | go func() { 32 | if err := sc.ScheduleAll(db); err != nil { 33 | log.WithError(err).Fatal("Failed to schedule crons from database!") 34 | } 35 | }() 36 | 37 | h := api.NewAPIHandler(db, sc) 38 | 39 | router := violetear.New() 40 | router.LogRequests = true 41 | router.RequestID = "X-Request-ID" 42 | router.AddRegex(":id", `^\d+$`) 43 | router.HandleFunc("/v1/healthz", h.HealthzIndex, "GET") 44 | router.HandleFunc("/v1/events", h.EventsIndex, "GET") 45 | router.HandleFunc("/v1/events", h.EventsCreate, "POST") 46 | router.HandleFunc("/v1/events/:id", h.EventsShow, "GET") 47 | router.HandleFunc("/v1/events/:id", h.EventsUpdate, "PUT") 48 | router.HandleFunc("/v1/events/:id", h.EventsDelete, "DELETE") 49 | 50 | log.WithField("port", conf.CRON_SRV_PORT).Info("Starting Cron Service") 51 | log.Fatal(http.ListenAndServe(":"+conf.CRON_SRV_PORT, router)) 52 | } 53 | -------------------------------------------------------------------------------- /mock/repo_mock.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import "github.com/EmpregoLigado/cron-srv/models" 4 | 5 | type RepoMock struct { 6 | Created bool 7 | Updated bool 8 | Deleted bool 9 | Found bool 10 | Searched bool 11 | ByStatus bool 12 | ByExpression bool 13 | } 14 | 15 | func NewRepo() *RepoMock { 16 | return &RepoMock{ 17 | Created: false, 18 | Updated: false, 19 | Deleted: false, 20 | Found: false, 21 | Searched: false, 22 | ByStatus: false, 23 | ByExpression: false, 24 | } 25 | } 26 | 27 | func (repo *RepoMock) FindEvents(events *[]models.Event, sc *models.Query) (err error) { 28 | *events = append(*events, models.Event{Expression: "* * * * * *"}) 29 | switch true { 30 | case sc.Status != "": 31 | repo.ByStatus = true 32 | case sc.Expression != "": 33 | repo.ByExpression = true 34 | default: 35 | repo.Searched = true 36 | } 37 | return 38 | } 39 | 40 | func (repo *RepoMock) FindEventById(event *models.Event, id int) (err error) { 41 | repo.Found = true 42 | return 43 | } 44 | 45 | func (repo *RepoMock) CreateEvent(event *models.Event) (err error) { 46 | repo.Created = true 47 | return 48 | } 49 | 50 | func (repo *RepoMock) UpdateEvent(event *models.Event) (err error) { 51 | repo.Updated = true 52 | return 53 | } 54 | 55 | func (repo *RepoMock) DeleteEvent(event *models.Event) (err error) { 56 | repo.Deleted = true 57 | return 58 | } 59 | -------------------------------------------------------------------------------- /mock/scheduler_mock.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "github.com/EmpregoLigado/cron-srv/models" 5 | "github.com/EmpregoLigado/cron-srv/repo" 6 | "github.com/robfig/cron" 7 | ) 8 | 9 | type SchedulerMock struct { 10 | Created bool 11 | Updated bool 12 | Deleted bool 13 | } 14 | 15 | func NewScheduler() *SchedulerMock { 16 | return &SchedulerMock{ 17 | Created: false, 18 | Updated: false, 19 | Deleted: false, 20 | } 21 | } 22 | 23 | func (s *SchedulerMock) Create(event *models.Event) (err error) { 24 | s.Created = true 25 | return 26 | } 27 | 28 | func (s *SchedulerMock) Update(event *models.Event) (err error) { 29 | s.Updated = true 30 | return 31 | } 32 | 33 | func (s *SchedulerMock) Delete(id uint) (err error) { 34 | s.Deleted = true 35 | return 36 | } 37 | 38 | func (s SchedulerMock) Find(id uint) (c *cron.Cron, err error) { 39 | return 40 | } 41 | 42 | func (sm *SchedulerMock) ScheduleAll(repo repo.Repo) (err error) { 43 | return 44 | } 45 | -------------------------------------------------------------------------------- /models/db.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/jinzhu/gorm" 5 | _ "github.com/jinzhu/gorm/dialects/postgres" 6 | ) 7 | 8 | type DB struct { 9 | *gorm.DB 10 | } 11 | 12 | type DBConfig struct { 13 | Url string 14 | MaxIdleConn int 15 | MaxOpenConn int 16 | LogMode bool 17 | } 18 | 19 | func NewDB(c DBConfig) (db *DB, err error) { 20 | conn, err := gorm.Open("postgres", c.Url) 21 | if err != nil { 22 | return 23 | } 24 | 25 | if err = conn.DB().Ping(); err != nil { 26 | return 27 | } 28 | 29 | if c.MaxIdleConn == 0 { 30 | c.MaxIdleConn = 10 31 | } 32 | 33 | if c.MaxIdleConn == 0 { 34 | c.MaxIdleConn = 100 35 | } 36 | 37 | conn.DB().SetMaxIdleConns(c.MaxIdleConn) 38 | conn.DB().SetMaxOpenConns(c.MaxOpenConn) 39 | conn.LogMode(c.LogMode) 40 | 41 | db = &DB{conn} 42 | 43 | return 44 | } 45 | -------------------------------------------------------------------------------- /models/event.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/jinzhu/gorm" 5 | "time" 6 | ) 7 | 8 | type Event struct { 9 | Id uint `json:"id",sql:"primary_key"` 10 | Url string `json:"url",sql:"not null"` 11 | Expression string `json:"expression",sql:"not null"` 12 | Status string `json:"status",sql:"not null"` 13 | Retries int64 `json:"retries` 14 | Timeout int64 `json:"timeout` 15 | CreatedAt time.Time `json:"created_at",sql:"not null"` 16 | UpdatedAt time.Time `json:"updated_at",sql:"not null"` 17 | DeletedAt *time.Time `json:"created_at,omitempty"` 18 | } 19 | 20 | func (db *DB) CreateEvent(c *Event) error { 21 | return db.Create(c).Error 22 | } 23 | 24 | func (db *DB) FindEventById(c *Event, id int) error { 25 | return db.Find(c, id).Error 26 | } 27 | 28 | func (db *DB) UpdateEvent(c *Event) error { 29 | return db.Save(c).Error 30 | } 31 | 32 | func (db *DB) DeleteEvent(c *Event) error { 33 | return db.Delete(c).Error 34 | } 35 | 36 | func (db *DB) FindEvents(events *[]Event, q *Query) error { 37 | if q.IsEmpty() { 38 | return db.Find(events).Error 39 | } 40 | 41 | var r *gorm.DB 42 | if q.Status != "" { 43 | r = db.Where("status = ?", q.Status) 44 | } 45 | 46 | if q.Expression != "" { 47 | r = db.Where("expression = ?", q.Expression) 48 | } 49 | 50 | return r.Find(events).Error 51 | } 52 | -------------------------------------------------------------------------------- /models/query.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Query struct { 4 | Status string 5 | Expression string 6 | } 7 | 8 | func NewQuery(status, expression string) *Query { 9 | return &Query{ 10 | Status: status, 11 | Expression: expression, 12 | } 13 | } 14 | 15 | func (q *Query) IsEmpty() bool { 16 | return q.Status == "" && 17 | q.Expression == "" 18 | } 19 | -------------------------------------------------------------------------------- /repo/repo.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import "github.com/EmpregoLigado/cron-srv/models" 4 | 5 | type Repo interface { 6 | CreateEvent(event *models.Event) (err error) 7 | FindEventById(event *models.Event, id int) (err error) 8 | UpdateEvent(event *models.Event) (err error) 9 | DeleteEvent(event *models.Event) (err error) 10 | FindEvents(events *[]models.Event, query *models.Query) (err error) 11 | } 12 | -------------------------------------------------------------------------------- /runner/runner.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | log "github.com/Sirupsen/logrus" 5 | "github.com/rubyist/circuitbreaker" 6 | "time" 7 | ) 8 | 9 | type Runner interface { 10 | Run() chan *Config 11 | } 12 | 13 | type Config struct { 14 | Url string 15 | Retries int64 16 | Timeout int64 17 | } 18 | 19 | type runner struct { 20 | runChannel chan *Config 21 | } 22 | 23 | func New() Runner { 24 | r := &runner{ 25 | runChannel: make(chan *Config), 26 | } 27 | 28 | go r.register() 29 | 30 | return r 31 | } 32 | 33 | func (r *runner) Run() chan *Config { 34 | return r.runChannel 35 | } 36 | 37 | func (r *runner) register() { 38 | for { 39 | select { 40 | case event := <-r.runChannel: 41 | r.run(event) 42 | } 43 | } 44 | } 45 | 46 | func (r *runner) run(c *Config) { 47 | timeout := time.Second * time.Duration(c.Timeout) 48 | client := circuit.NewHTTPClient(timeout, c.Retries, nil) 49 | 50 | _, err := client.Get(c.Url) 51 | if err == nil { 52 | log.WithField("url", c.Url).Info("Event job event sent") 53 | return 54 | } 55 | 56 | log.WithField("url", c.Url).Info("Failed to send event") 57 | } 58 | -------------------------------------------------------------------------------- /runner/runner_test.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "gopkg.in/h2non/gock.v1" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestRunnerRun(t *testing.T) { 10 | url := "https://github.com" 11 | 12 | defer gock.Off() 13 | gock.New(url). 14 | Get("/"). 15 | Reply(200). 16 | BodyString("foo") 17 | 18 | c := &Config{Url: url} 19 | r := New() 20 | r.Run() <- c 21 | 22 | time.Sleep(time.Second * 1) 23 | 24 | if !gock.IsDone() { 25 | t.Errorf("Expected to call %s", url) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /scheduler/scheduler.go: -------------------------------------------------------------------------------- 1 | package scheduler 2 | 3 | import ( 4 | "errors" 5 | "github.com/EmpregoLigado/cron-srv/models" 6 | "github.com/EmpregoLigado/cron-srv/repo" 7 | "github.com/EmpregoLigado/cron-srv/runner" 8 | "github.com/robfig/cron" 9 | "sync" 10 | ) 11 | 12 | var ( 13 | ErrEventNotExist = errors.New("finding a scheduled event requires a existent cron id") 14 | ) 15 | 16 | type Scheduler interface { 17 | Create(cron *models.Event) error 18 | Update(cron *models.Event) error 19 | Delete(id uint) error 20 | Find(id uint) (*cron.Cron, error) 21 | ScheduleAll(repo repo.Repo) error 22 | } 23 | 24 | type scheduler struct { 25 | sync.RWMutex 26 | Kv map[uint]*cron.Cron 27 | Cron *cron.Cron 28 | } 29 | 30 | func New() Scheduler { 31 | s := &scheduler{ 32 | Kv: make(map[uint]*cron.Cron), 33 | Cron: cron.New(), 34 | } 35 | 36 | s.Cron.Start() 37 | 38 | return s 39 | } 40 | 41 | func (s *scheduler) ScheduleAll(repo repo.Repo) (err error) { 42 | crons := []models.Event{} 43 | query := new(models.Query) 44 | if err = repo.FindEvents(&crons, query); err != nil { 45 | return 46 | } 47 | 48 | for _, cron := range crons { 49 | if err = s.Create(&cron); err != nil { 50 | return 51 | } 52 | } 53 | 54 | return 55 | } 56 | 57 | func (s *scheduler) Create(event *models.Event) (err error) { 58 | s.Cron.AddFunc(event.Expression, func() { 59 | c := &runner.Config{ 60 | Url: event.Url, 61 | Retries: event.Retries, 62 | Timeout: event.Timeout, 63 | } 64 | 65 | r := runner.New() 66 | r.Run() <- c 67 | }) 68 | 69 | s.Lock() 70 | defer s.Unlock() 71 | 72 | s.Kv[event.Id] = s.Cron 73 | 74 | return 75 | } 76 | 77 | func (s *scheduler) Find(id uint) (cron *cron.Cron, err error) { 78 | s.Lock() 79 | defer s.Unlock() 80 | 81 | cron, found := s.Kv[id] 82 | if !found { 83 | err = ErrEventNotExist 84 | return 85 | } 86 | 87 | return 88 | } 89 | 90 | func (s *scheduler) Update(cron *models.Event) (err error) { 91 | if err = s.Delete(cron.Id); err != nil { 92 | return 93 | } 94 | 95 | return s.Create(cron) 96 | } 97 | 98 | func (s scheduler) Delete(id uint) (err error) { 99 | s.Lock() 100 | defer s.Unlock() 101 | 102 | _, found := s.Kv[id] 103 | if !found { 104 | err = ErrEventNotExist 105 | return 106 | } 107 | 108 | s.Kv[id].Stop() 109 | s.Kv[id] = nil 110 | 111 | return 112 | } 113 | -------------------------------------------------------------------------------- /scheduler/scheduler_test.go: -------------------------------------------------------------------------------- 1 | package scheduler 2 | 3 | import ( 4 | "github.com/EmpregoLigado/cron-srv/mock" 5 | "github.com/EmpregoLigado/cron-srv/models" 6 | "testing" 7 | ) 8 | 9 | func TestScheduleAll(t *testing.T) { 10 | repoMock := mock.NewRepo() 11 | s := New() 12 | if err := s.ScheduleAll(repoMock); err != nil { 13 | t.Errorf("Expected to schedule all events %s", err) 14 | } 15 | } 16 | 17 | func TestSchedulerCreate(t *testing.T) { 18 | s := New() 19 | c := &models.Event{Id: 1, Expression: "* * * * *"} 20 | if err := s.Create(c); err != nil { 21 | t.Errorf("Expected to schedule a cron %s", err) 22 | } 23 | } 24 | 25 | func TestSchedulerFind(t *testing.T) { 26 | s := New() 27 | c := &models.Event{Id: 1, Expression: "* * * * *"} 28 | if err := s.Create(c); err != nil { 29 | t.Errorf("Expected to schedule a cron %s", err) 30 | } 31 | 32 | _, err := s.Find(c.Id) 33 | if err != nil { 34 | t.Errorf("Expected to find a cron %s", err) 35 | } 36 | } 37 | 38 | func TestSchedulerUpdate(t *testing.T) { 39 | s := New() 40 | c := &models.Event{Id: 1, Expression: "* * * * *"} 41 | if err := s.Create(c); err != nil { 42 | t.Errorf("Expected to schedule a cron %s", err) 43 | } 44 | 45 | c.Status = "active" 46 | if err := s.Update(c); err != nil { 47 | t.Errorf("Expected to update a scheduled cron %s", err) 48 | } 49 | } 50 | 51 | func TestSchedulerDelete(t *testing.T) { 52 | s := New() 53 | c := &models.Event{Id: 1, Expression: "* * * * *"} 54 | if err := s.Create(c); err != nil { 55 | t.Errorf("Expected to schedule a cron %s", err) 56 | } 57 | 58 | if err := s.Delete(c.Id); err != nil { 59 | t.Errorf("Expected to delete a scheduled cron %s", err) 60 | } 61 | } 62 | --------------------------------------------------------------------------------