├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── build-container ├── build ├── build.sh └── test.sh ├── circle.yml ├── cmd └── crony │ ├── main.go │ └── serve.go ├── glide.lock ├── glide.yaml └── pkg ├── checker ├── api.go ├── checker.go └── postgres.go ├── config └── config.go ├── datastore ├── datastore.go ├── error.go └── postgres.go ├── handlers ├── event.go ├── event_test.go ├── healthz.go └── healthz_test.go ├── migrations └── migrations.go ├── mocks ├── checker_mock.go ├── event_repo_mock.go └── scheduler_mock.go ├── models ├── event.go ├── event_test.go ├── query.go └── query_test.go ├── render └── render.go ├── repos └── event.go ├── runner ├── runner.go └── runner_test.go └── scheduler ├── error.go ├── scheduler.go └── scheduler_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/** 2 | dist/** 3 | 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | MAINTAINER Rafael Jesus 3 | ADD dist/crony /dist/crony 4 | ENV DATASTORE_URL="postgres://postgres:@docker/crony?sslmode=disable" 5 | ENV PORT="3000" 6 | ENTRYPOINT ["/crony"] 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NO_COLOR=\033[0m 2 | OK_COLOR=\033[32;01m 3 | ERROR_COLOR=\033[31;01m 4 | WARN_COLOR=\033[33;01m 5 | 6 | IGNORED_PACKAGES := /vendor/ 7 | 8 | .PHONY: all clean deps build 9 | 10 | all: clean deps test build 11 | 12 | deps: 13 | @echo "$(OK_COLOR)==> Installing glide dependencies$(NO_COLOR)" 14 | @go get -u github.com/Masterminds/glide 15 | @glide install 16 | 17 | build: 18 | @echo "$(OK_COLOR)==> Building... $(NO_COLOR)" 19 | /bin/sh -c "VERSION=${VERSION} ./build/build.sh" 20 | 21 | test: 22 | @/bin/sh -c "./build/test.sh $(allpackages)" 23 | 24 | clean: 25 | @echo "$(OK_COLOR)==> Cleaning project$(NO_COLOR)" 26 | @go clean 27 | @rm -rf dist 28 | 29 | _allpackages = $(shell ( go list ./... 2>&1 1>&3 | \ 30 | grep -v -e "^$$" $(addprefix -e ,$(IGNORED_PACKAGES)) 1>&2 ) 3>&1 | \ 31 | grep -v -e "^$$" $(addprefix -e ,$(IGNORED_PACKAGES))) 32 | 33 | allpackages = $(if $(__allpackages),,$(eval __allpackages := $$(_allpackages)))$(__allpackages) 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Crony :clock530: 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 | Crony 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 DATASTORE_URL="postgresql://postgres@localhost/crony?sslmode=disable" 14 | export PORT=3000 15 | ``` 16 | 17 | ```sh 18 | mkdir -p $GOPATH/src/github.com/rafaeljesus 19 | cd $GOPATH/src/github.com/rafaeljesus 20 | git clone https://github.com/rafaeljesus/crony.git 21 | cd crony 22 | make all 23 | ``` 24 | 25 | ## Running server 26 | ``` 27 | ./dist/crony 28 | # => Starting Crony at port 3000 29 | ``` 30 | 31 | ## Authentication 32 | This API does not ship with an authentication layer. You **should not** expose the API to the Internet. This API should be deployed behind a firewall, only your application servers should be allowed to send requests to the API. 33 | 34 | ## API Endpoints 35 | - [`GET` /health](#get-health) - Get application health 36 | - [`GET` /events](#get-events) - Get a list of scheduled events 37 | - [`POST` /events](#post-events) - Create a event 38 | - [`GET` /events/:id](#get-eventsid) - Get a single event 39 | - [`DELETE` /events/:id](#delete-eventsid) - Delete a event 40 | - [`PATCH` /events/:id](#patch-eventsid) - Update a event 41 | 42 | ### API Documentation 43 | #### `GET` `/events` 44 | Get a list of available events. 45 | - Method: `GET` 46 | - Endpoint: `/events` 47 | - Responses: 48 | * 200 OK 49 | ```json 50 | [ 51 | { 52 | "id":1, 53 | "url":"your-api/job", 54 | "expression": "0 5 * * * *", 55 | "status": "active", 56 | "max_retries": 2, 57 | "retry_timeout": 3, 58 | "created_at": "2016-12-10T14:02:37.064641296-02:00", 59 | "updated_at": "2016-12-10T14:02:37.064641296-02:00" 60 | } 61 | ] 62 | ``` 63 | - `id` is the id of the event. 64 | - `url`: is the url callback to called. 65 | - `expression`: is cron expression format. 66 | - `status`: tell if the event is active or paused. 67 | - `max_retries`: the number of attempts to send event. 68 | - `retry_timeout`: is the retry timeout. 69 | 70 | #### `POST` `/events` 71 | Create a new event. 72 | - Method: `POST` 73 | - Endpoint: `/events` 74 | - Input: 75 | The `Content-Type` HTTP header should be set to `application/json` 76 | 77 | ```json 78 | { 79 | "url":"your-api/job", 80 | "expression": "0 5 * * * *", 81 | "status": "active", 82 | "max_retries": 2, 83 | "retry_timeout": 3, 84 | } 85 | ``` 86 | - Responses: 87 | * 201 Created 88 | ```json 89 | { 90 | "url":"your-api/job", 91 | "expression": "0 5 * * * *", 92 | "status": "active", 93 | "max_retries": 2, 94 | "retry_timeout": 3, 95 | "updated_at": "2016-12-10T14:02:37.064641296-02:00", 96 | "created_at": "2016-12-10T14:02:37.064641296-02:00" 97 | } 98 | ``` 99 | * 422 Unprocessable entity: 100 | ```json 101 | { 102 | "status":"invalid_event", 103 | "message":"" 104 | } 105 | ``` 106 | * 400 Bad Request 107 | ```json 108 | { 109 | "status":"invalid_json", 110 | "message":"Cannot decode the given JSON payload" 111 | } 112 | ``` 113 | Common reasons: 114 | - the event job already scheduled. The `message` will be `Event already exists` 115 | - the expression must be crontab format. 116 | - the retry must be between `0` and `10` 117 | - the status must be `active` or `incative` 118 | 119 | #### `GET` `/events/:id` 120 | Get a specific event. 121 | - Method: `GET` 122 | - Endpoint: `/events/:id` 123 | - Responses: 124 | * 200 OK 125 | ```json 126 | { 127 | "url":"your-api/job", 128 | "expression": "0 5 * * * *", 129 | "status": "active", 130 | "max_retries": 2, 131 | "retry_timeout": 3, 132 | "updated_at": "2016-12-10T14:02:37.064641296-02:00", 133 | "created_at": "2016-12-10T14:02:37.064641296-02:00" 134 | } 135 | ``` 136 | * 404 Not Found 137 | ```json 138 | { 139 | "status":"event_not_found", 140 | "message":"The event was not found" 141 | } 142 | ``` 143 | 144 | #### `DELETE` `/events/:id` 145 | Remove a scheduled event. 146 | - Method: `DELETE` 147 | - Endpoint: `/events/:id` 148 | - Responses: 149 | * 200 OK 150 | ```json 151 | { 152 | "status":"event_deleted", 153 | "message":"The event was successfully deleted" 154 | } 155 | ``` 156 | * 404 Not Found 157 | ```json 158 | { 159 | "status":"event_not_found", 160 | "message":"The event was not found" 161 | } 162 | ``` 163 | 164 | #### `PATCH` `/events/:id` 165 | Update a event. 166 | - Method: `PATCH` 167 | - Endpoint: `/events/:id` 168 | - Input: 169 | The `Content-Type` HTTP header should be set to `application/json` 170 | 171 | ```json 172 | { 173 | "expression": "0 2 * * * *" 174 | } 175 | ``` 176 | - Responses: 177 | * 200 OK 178 | ```json 179 | { 180 | "url":"your-api/job", 181 | "expression": "0 2 * * * *", 182 | "status": "active", 183 | "max_retries": 2, 184 | "retry_timeout": 3, 185 | "updated_at": "2016-12-10T14:02:37.064641296-02:00", 186 | "created_at": "2016-12-10T14:02:37.064641296-02:00" 187 | } 188 | ``` 189 | * 404 Not Found 190 | ```json 191 | { 192 | "status":"event_not_found", 193 | "message":"The event was not found" 194 | } 195 | ``` 196 | * 422 Unprocessable entity: 197 | ```json 198 | { 199 | "status":"invalid_json", 200 | "message":"Cannot decode the given JSON payload" 201 | } 202 | ``` 203 | * 400 Bad Request 204 | ```json 205 | { 206 | "status":"invalid_event", 207 | "message":"" 208 | } 209 | ``` 210 | 211 | ## Cron Format 212 | The cron expression format allowed is: 213 | 214 | |Field name| Mandatory?|Allowed values|Allowed special characters| 215 | |:--|:--|:--|:--| 216 | |Seconds | Yes | 0-59 | * / , -| 217 | |Minutes | Yes | 0-59 | * / , -| 218 | |Hours | Yes | 0-23 | * / , -| 219 | |Day of month | Yes | 1-31 | * / , - ?| 220 | |Month | Yes | 1-12 or JAN-DEC | * / , -| 221 | |Day of week | Yes | 0-6 or SUN-SAT | * / , - ?| 222 | more details about expression format [here](https://godoc.org/github.com/robfig/cron#hdr-CRON_Expression_Format) 223 | 224 | ## Contributing 225 | - Fork it 226 | - Create your feature branch (`git checkout -b my-new-feature`) 227 | - Commit your changes (`git commit -am 'Add some feature'`) 228 | - Push to the branch (`git push origin my-new-feature`) 229 | - Create new Pull Request 230 | 231 | ## Badges 232 | [![CircleCI](https://circleci.com/gh/rafaeljesus/crony.svg?style=svg)](https://circleci.com/gh/rafaeljesus/crony) 233 | [![Go Report Card](https://goreportcard.com/badge/github.com/rafaeljesus/crony)](https://goreportcard.com/report/github.com/rafaeljesus/crony) 234 | [![](https://images.microbadger.com/badges/image/rafaeljesus/crony.svg)](https://microbadger.com/images/rafaeljesus/crony "Get your own image badge on microbadger.com") 235 | [![](https://images.microbadger.com/badges/version/rafaeljesus/crony.svg)](https://microbadger.com/images/rafaeljesus/crony "Get your own version badge on microbadger.com") 236 | -------------------------------------------------------------------------------- /build-container: -------------------------------------------------------------------------------- 1 | GOOS=linux CGO_ENABLED=0 GOOS=linux go build -a --ldflags '-extldflags "-static"' -tags netgo -installsuffix netgo -o crony . 2 | docker build -t rafaeljesus/crony . 3 | -------------------------------------------------------------------------------- /build/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | rm -f dist/crony* 6 | 7 | if [ -z "$VERSION" ]; then 8 | VERSION="0.0.1-dev" 9 | fi 10 | echo "Building application version $VERSION" 11 | 12 | echo "Building default binaries" 13 | CGO_ENABLED=0 go build -ldflags "-s -w" -ldflags "-X main.version=${VERSION}" -o "dist/crony" github.com/rafaeljesus/crony/cmd/crony 14 | 15 | OS_PLATFORM_ARG=(linux darwin) 16 | OS_ARCH_ARG=(amd64) 17 | for OS in ${OS_PLATFORM_ARG[@]}; do 18 | for ARCH in ${OS_ARCH_ARG[@]}; do 19 | echo "Building binaries for $OS/$ARCH..." 20 | GOARCH=$ARCH GOOS=$OS CGO_ENABLED=0 go build -ldflags "-s -w" -ldflags "-X main.version=${VERSION}" -o "dist/crony_$OS-$ARCH" github.com/rafaeljesus/crony/cmd/crony 21 | done 22 | done 23 | -------------------------------------------------------------------------------- /build/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Copyright 2016 The Kubernetes Authors. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | set -e 18 | 19 | export CGO_ENABLED=1 20 | NO_COLOR='\033[0m' 21 | OK_COLOR='\033[32;01m' 22 | ERROR_COLOR='\033[31;01m' 23 | WARN_COLOR='\033[33;01m' 24 | PASS="${OK_COLOR}PASS ${NO_COLOR}" 25 | FAIL="${ERROR_COLOR}FAIL ${NO_COLOR}" 26 | 27 | TARGETS=$@ 28 | 29 | echo "${OK_COLOR}Running tests: ${NO_COLOR}" 30 | go test -race ${TARGETS} 31 | 32 | echo "${OK_COLOR}Formatting: ${NO_COLOR}" 33 | ERRS=$(find cmd pkg -type f -name \*.go | xargs gofmt -l 2>&1 || true) 34 | if [ -n "${ERRS}" ]; then 35 | echo "${ERROR_COLOR}FAIL - the following files need to be gofmt'ed: ${NO_COLOR}" 36 | for e in ${ERRS}; do 37 | echo " $e" 38 | done 39 | exit 1 40 | fi 41 | echo ${PASS} 42 | 43 | echo "${OK_COLOR}Vetting: ${NO_COLOR}" 44 | ERRS=$(go vet ${TARGETS} 2>&1 || true) 45 | if [ -n "${ERRS}" ]; then 46 | echo ${FAIL} 47 | echo "${ERRS}" 48 | exit 1 49 | fi 50 | echo ${PASS} 51 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | environment: 3 | IMPORT_PATH: "/home/ubuntu/.go_workspace/src/github.com/rafaeljesus" 4 | APP_PATH: "$IMPORT_PATH/crony" 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" && make test 21 | 22 | deployment: 23 | master: 24 | branch: master 25 | commands: 26 | - cd "$APP_PATH" && make build 27 | - docker build -t rafaeljesus/crony . 28 | - docker login -e $DOCKER_EMAIL -u $DOCKER_USER -p $DOCKER_PASS 29 | - docker tag rafaeljesus/crony rafaeljesus/crony:master 30 | - docker push rafaeljesus/crony:master 31 | -------------------------------------------------------------------------------- /cmd/crony/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | var ( 11 | version string 12 | versionFlag bool 13 | ) 14 | 15 | func main() { 16 | versionString := "Cron Service v" + version 17 | cobra.OnInitialize(func() { 18 | if versionFlag { 19 | fmt.Println(versionString) 20 | os.Exit(0) 21 | } 22 | }) 23 | 24 | var rootCmd = &cobra.Command{ 25 | Use: "cron-srv", 26 | Short: "Cron Service", 27 | Long: versionString, 28 | Run: Serve, 29 | } 30 | 31 | rootCmd.Flags().BoolVarP(&versionFlag, "version", "v", false, "Print application version") 32 | 33 | if err := rootCmd.Execute(); err != nil { 34 | fmt.Println(err) 35 | os.Exit(-1) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /cmd/crony/serve.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | 8 | log "github.com/Sirupsen/logrus" 9 | "github.com/nbari/violetear" 10 | "github.com/rafaeljesus/crony/pkg/checker" 11 | "github.com/rafaeljesus/crony/pkg/config" 12 | "github.com/rafaeljesus/crony/pkg/datastore" 13 | "github.com/rafaeljesus/crony/pkg/handlers" 14 | "github.com/rafaeljesus/crony/pkg/repos" 15 | "github.com/rafaeljesus/crony/pkg/scheduler" 16 | "github.com/spf13/cobra" 17 | ) 18 | 19 | func Serve(cmd *cobra.Command, args []string) { 20 | log.WithField("version", version).Info("Cron Service starting...") 21 | 22 | env, err := config.LoadEnv() 23 | failOnError(err, "Failed to load config!") 24 | 25 | level, err := log.ParseLevel(strings.ToLower(env.LogLevel)) 26 | failOnError(err, "Failed to get log level!") 27 | log.SetLevel(level) 28 | 29 | ds, err := datastore.New(env.DatastoreURL) 30 | failOnError(err, "Failed to init dababase connection!") 31 | defer ds.Close() 32 | 33 | checkers := map[string]checker.Checker{ 34 | "api": checker.NewApi(), 35 | "postgres": checker.NewPostgres(env.DatastoreURL), 36 | } 37 | healthzHandler := handlers.NewHealthzHandler(checkers) 38 | 39 | eventRepo := repos.NewEvent(ds) 40 | 41 | sc := scheduler.New() 42 | go sc.ScheduleAll(eventRepo) 43 | 44 | eventsHandler := handlers.NewEventsHandler(eventRepo, sc) 45 | 46 | r := violetear.New() 47 | r.LogRequests = true 48 | r.RequestID = "X-Request-ID" 49 | r.AddRegex(":id", `^\d+$`) 50 | 51 | r.HandleFunc("/health", healthzHandler.HealthzIndex, "GET") 52 | 53 | r.HandleFunc("/events", eventsHandler.EventsIndex, "GET") 54 | r.HandleFunc("/events", eventsHandler.EventsCreate, "POST") 55 | r.HandleFunc("/events/:id", eventsHandler.EventsShow, "GET") 56 | r.HandleFunc("/events/:id", eventsHandler.EventsUpdate, "PUT") 57 | r.HandleFunc("/events/:id", eventsHandler.EventsDelete, "DELETE") 58 | 59 | log.Fatal(http.ListenAndServe(fmt.Sprintf(":%v", env.Port), r)) 60 | } 61 | 62 | func failOnError(err error, msg string) { 63 | if err != nil { 64 | log.WithError(err).Panic(msg) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /glide.lock: -------------------------------------------------------------------------------- 1 | hash: bdaf457395442ca15686831c2f463e316588574477f2d3d1d2c4ec2ec2ad5534 2 | updated: 2017-04-16T16:45:26.958728553+02:00 3 | imports: 4 | - name: github.com/cenk/backoff 5 | version: 5d150e7eec023ce7a124856b37c68e54b4050ac7 6 | - name: github.com/facebookgo/clock 7 | version: 600d898af40aa09a7a93ecb9265d87b0504b6f03 8 | - name: github.com/fsnotify/fsnotify 9 | version: 4da3e2cfbabc9f751898f250b49f2439785783a1 10 | - name: github.com/hashicorp/hcl 11 | version: 630949a3c5fa3c613328e1b8256052cbc2327c9b 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/inconshreveable/mousetrap 22 | version: 76626ae9c91c4f2a10f34cad8ce83ea42c93bb75 23 | - name: github.com/jinzhu/gorm 24 | version: 5174cc5c242a728b435ea2be8a2f7f998e15429b 25 | subpackages: 26 | - dialects/postgres 27 | - name: github.com/jinzhu/inflection 28 | version: 1c35d901db3da928c72a72d8458480cc9ade058f 29 | - name: github.com/kelseyhightower/envconfig 30 | version: f611eb38b3875cc3bd991ca91c51d06446afa14c 31 | - name: github.com/lib/pq 32 | version: 2704adc878c21e1329f46f6e56a1c387d788ff94 33 | subpackages: 34 | - hstore 35 | - oid 36 | - name: github.com/magiconair/properties 37 | version: 51463bfca2576e06c62a8504b5c0f06d61312647 38 | - name: github.com/mattes/migrate 39 | version: 4cfc0444892a8cfb1978c54eda5584d52650b019 40 | subpackages: 41 | - driver 42 | - driver/postgres 43 | - file 44 | - migrate 45 | - migrate/direction 46 | - pipe 47 | - name: github.com/mitchellh/mapstructure 48 | version: 53818660ed4955e899c0bcafa97299a388bd7c8e 49 | - name: github.com/nbari/violetear 50 | version: 4793bb4af8b6acda065b07bbf5e5723624ee9d8c 51 | - name: github.com/pelletier/go-buffruneio 52 | version: c37440a7cf42ac63b919c752ca73a85067e05992 53 | - name: github.com/pelletier/go-toml 54 | version: fe206efb84b2bc8e8cfafe6b4c1826622be969e3 55 | - name: github.com/robfig/cron 56 | version: b024fc5ea0e34bc3f83d9941c8d60b0622bfaca4 57 | - name: github.com/rubyist/circuitbreaker 58 | version: 7e3e7fbe9c62b943d487af023566a79d9eb22d3b 59 | - name: github.com/Sirupsen/logrus 60 | version: ba1b36c82c5e05c4f912a88eab0dcd91a171688f 61 | - name: github.com/spf13/afero 62 | version: 9be650865eab0c12963d8753212f4f9c66cdcf12 63 | subpackages: 64 | - mem 65 | - name: github.com/spf13/cast 66 | version: ce135a4ebeee6cfe9a26c93ee0d37825f26113c7 67 | - name: github.com/spf13/cobra 68 | version: 5deb57bbca49eb370538fc295ba4b2988f9f5e09 69 | - name: github.com/spf13/jwalterweatherman 70 | version: fa7ca7e836cf3a8bb4ebf799f472c12d7e903d66 71 | - name: github.com/spf13/pflag 72 | version: 9a906f17374922ed0f74e1b2f593d3723f2ffb00 73 | - name: github.com/spf13/viper 74 | version: 84f94806c67f59dd7ae87bc5351f7a9c94a4558d 75 | - name: golang.org/x/net 76 | version: d1e1b351919c6738fdeb9893d5c998b161464f0c 77 | subpackages: 78 | - context 79 | - name: golang.org/x/sys 80 | version: a408501be4d17ee978c04a618e7a1b22af058c0e 81 | subpackages: 82 | - unix 83 | - name: golang.org/x/text 84 | version: f4b4367115ec2de254587813edaa901bc1c723a8 85 | subpackages: 86 | - transform 87 | - unicode/norm 88 | - name: gopkg.in/h2non/gock.v1 89 | version: 2897ffde93a71060ce86518f1e7317ec8433d2d6 90 | - name: gopkg.in/yaml.v2 91 | version: 31c299268d302dd0aa9a0dcf765a3d58971ac83f 92 | testImports: [] 93 | -------------------------------------------------------------------------------- /glide.yaml: -------------------------------------------------------------------------------- 1 | package: github.com/rafaeljesus/crony 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 | -------------------------------------------------------------------------------- /pkg/checker/api.go: -------------------------------------------------------------------------------- 1 | package checker 2 | 3 | type api struct{} 4 | 5 | func NewApi() *api { 6 | return &api{} 7 | } 8 | 9 | func (a *api) IsAlive() bool { 10 | return true 11 | } 12 | -------------------------------------------------------------------------------- /pkg/checker/checker.go: -------------------------------------------------------------------------------- 1 | package checker 2 | 3 | type Checker interface { 4 | IsAlive() bool 5 | } 6 | -------------------------------------------------------------------------------- /pkg/checker/postgres.go: -------------------------------------------------------------------------------- 1 | package checker 2 | 3 | import ( 4 | "github.com/jinzhu/gorm" 5 | _ "github.com/jinzhu/gorm/dialects/postgres" 6 | ) 7 | 8 | type postgres struct { 9 | url string 10 | } 11 | 12 | func NewPostgres(url string) *postgres { 13 | return &postgres{url} 14 | } 15 | 16 | func (p *postgres) IsAlive() bool { 17 | conn, err := gorm.Open("postgres", p.url) 18 | if err != nil { 19 | return false 20 | } 21 | defer conn.Close() 22 | 23 | if err = conn.DB().Ping(); err != nil { 24 | return false 25 | } 26 | 27 | return true 28 | } 29 | -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/kelseyhightower/envconfig" 5 | "github.com/spf13/viper" 6 | ) 7 | 8 | type Config struct { 9 | Port int `envconfig:"PORT"` 10 | LogLevel string `envconfig:"LOG_LEVEL"` 11 | DatastoreURL string `envconfig:"DATASTORE_URL"` 12 | } 13 | 14 | func init() { 15 | viper.SetDefault("port", "3000") 16 | viper.SetDefault("logLevel", "info") 17 | } 18 | 19 | func LoadEnv() (*Config, error) { 20 | var instance Config 21 | if err := viper.Unmarshal(&instance); err != nil { 22 | return nil, err 23 | } 24 | 25 | err := envconfig.Process("", &instance) 26 | if err != nil { 27 | return &instance, err 28 | } 29 | 30 | return &instance, nil 31 | } 32 | -------------------------------------------------------------------------------- /pkg/datastore/datastore.go: -------------------------------------------------------------------------------- 1 | package datastore 2 | 3 | import ( 4 | "net/url" 5 | 6 | "github.com/jinzhu/gorm" 7 | ) 8 | 9 | const ( 10 | Postgres = "postgresql" 11 | ) 12 | 13 | func New(dsn string) (*gorm.DB, error) { 14 | url, err := url.Parse(dsn) 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | switch url.Scheme { 20 | case Postgres: 21 | c := PGConfig{ 22 | Url: dsn, 23 | MaxIdleConn: 10, 24 | MaxOpenConn: 100, 25 | } 26 | 27 | return NewPostgres(c) 28 | default: 29 | return nil, ErrUnknownDatabaseProvider 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /pkg/datastore/error.go: -------------------------------------------------------------------------------- 1 | package datastore 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrUnknownDatabaseProvider = errors.New("Unknown database provider type") 7 | ) 8 | -------------------------------------------------------------------------------- /pkg/datastore/postgres.go: -------------------------------------------------------------------------------- 1 | package datastore 2 | 3 | import ( 4 | "github.com/jinzhu/gorm" 5 | _ "github.com/jinzhu/gorm/dialects/postgres" 6 | "github.com/rafaeljesus/crony/pkg/models" 7 | ) 8 | 9 | type PGConfig struct { 10 | Url string 11 | MaxIdleConn int 12 | MaxOpenConn int 13 | LogMode bool 14 | } 15 | 16 | func NewPostgres(c PGConfig) (conn *gorm.DB, err error) { 17 | conn, err = gorm.Open("postgres", c.Url) 18 | if err != nil { 19 | return 20 | } 21 | 22 | if err = conn.DB().Ping(); err != nil { 23 | return 24 | } 25 | 26 | if c.MaxIdleConn == 0 { 27 | c.MaxIdleConn = 10 28 | } 29 | 30 | if c.MaxOpenConn == 0 { 31 | c.MaxOpenConn = 100 32 | } 33 | 34 | conn.DB().SetMaxIdleConns(c.MaxIdleConn) 35 | conn.DB().SetMaxOpenConns(c.MaxOpenConn) 36 | conn.LogMode(c.LogMode) 37 | 38 | // FIXME temporary 39 | conn.AutoMigrate(&models.Event{}) 40 | 41 | return 42 | } 43 | -------------------------------------------------------------------------------- /pkg/handlers/event.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/nbari/violetear" 9 | "github.com/rafaeljesus/crony/pkg/models" 10 | "github.com/rafaeljesus/crony/pkg/render" 11 | "github.com/rafaeljesus/crony/pkg/repos" 12 | "github.com/rafaeljesus/crony/pkg/scheduler" 13 | ) 14 | 15 | type EventsHandler struct { 16 | EventRepo repos.EventRepo 17 | Scheduler scheduler.Scheduler 18 | } 19 | 20 | func NewEventsHandler(r repos.EventRepo, s scheduler.Scheduler) *EventsHandler { 21 | return &EventsHandler{r, s} 22 | } 23 | 24 | func (h *EventsHandler) EventsIndex(w http.ResponseWriter, r *http.Request) { 25 | status := r.URL.Query().Get("status") 26 | expression := r.URL.Query().Get("expression") 27 | query := models.NewQuery(status, expression) 28 | 29 | events, err := h.EventRepo.Search(query) 30 | if err != nil { 31 | render.Response(w, http.StatusPreconditionFailed, err) 32 | return 33 | } 34 | 35 | render.JSON(w, http.StatusOK, events) 36 | } 37 | 38 | func (h *EventsHandler) EventsCreate(w http.ResponseWriter, r *http.Request) { 39 | event := models.NewEvent() 40 | if err := json.NewDecoder(r.Body).Decode(event); err != nil { 41 | render.Response(w, http.StatusBadRequest, "Failed to decode request body") 42 | return 43 | } 44 | 45 | if errors, valid := event.Validate(); !valid { 46 | render.Response(w, http.StatusBadRequest, errors) 47 | return 48 | } 49 | 50 | if err := h.EventRepo.Create(event); err != nil { 51 | render.Response(w, http.StatusUnprocessableEntity, "An error occurred during creating event") 52 | return 53 | } 54 | 55 | if err := h.Scheduler.Create(event); err != nil { 56 | render.Response(w, http.StatusInternalServerError, "An error occurred during scheduling event") 57 | return 58 | } 59 | 60 | render.JSON(w, http.StatusCreated, event) 61 | } 62 | 63 | func (h *EventsHandler) EventsShow(w http.ResponseWriter, r *http.Request) { 64 | params := r.Context().Value(violetear.ParamsKey).(violetear.Params) 65 | id, err := strconv.Atoi(params[":id"].(string)) 66 | if err != nil { 67 | render.Response(w, http.StatusBadRequest, "Missing param :id") 68 | return 69 | } 70 | 71 | event, err := h.EventRepo.FindById(id) 72 | if err != nil { 73 | render.Response(w, http.StatusNotFound, "Event not found") 74 | return 75 | } 76 | 77 | render.JSON(w, http.StatusOK, event) 78 | } 79 | 80 | func (h *EventsHandler) EventsUpdate(w http.ResponseWriter, r *http.Request) { 81 | params := r.Context().Value(violetear.ParamsKey).(violetear.Params) 82 | id, err := strconv.Atoi(params[":id"].(string)) 83 | if err != nil { 84 | render.Response(w, http.StatusBadRequest, "Missing param :id") 85 | return 86 | } 87 | 88 | event, err := h.EventRepo.FindById(id) 89 | if err != nil { 90 | render.Response(w, http.StatusNotFound, "Event not found") 91 | return 92 | } 93 | 94 | newEvent := models.NewEvent() 95 | if err := json.NewDecoder(r.Body).Decode(newEvent); err != nil { 96 | render.Response(w, http.StatusBadRequest, "Failed to decode request body") 97 | return 98 | } 99 | 100 | if errors, valid := newEvent.Validate(); !valid { 101 | render.Response(w, http.StatusBadRequest, errors) 102 | return 103 | } 104 | 105 | event.SetAttributes(newEvent) 106 | if err := h.EventRepo.Update(event); err != nil { 107 | render.Response(w, http.StatusUnprocessableEntity, "An error occurred during updating event") 108 | return 109 | } 110 | 111 | if err := h.Scheduler.Update(event); err != nil { 112 | render.Response(w, http.StatusInternalServerError, "An error occurred during scheduling event") 113 | return 114 | } 115 | 116 | render.JSON(w, http.StatusOK, event) 117 | } 118 | 119 | func (h *EventsHandler) EventsDelete(w http.ResponseWriter, r *http.Request) { 120 | params := r.Context().Value(violetear.ParamsKey).(violetear.Params) 121 | id, err := strconv.Atoi(params[":id"].(string)) 122 | if err != nil { 123 | render.Response(w, http.StatusBadRequest, "Missing param :id") 124 | return 125 | } 126 | 127 | event, err := h.EventRepo.FindById(id) 128 | if err != nil { 129 | render.Response(w, http.StatusNotFound, "Event not found") 130 | return 131 | } 132 | 133 | if err := h.EventRepo.Delete(event); err != nil { 134 | render.Response(w, http.StatusUnprocessableEntity, "An error occurred during deleting event") 135 | return 136 | } 137 | 138 | if err := h.Scheduler.Delete(event.Id); err != nil { 139 | render.Response(w, http.StatusInternalServerError, "An error occurred during deleting scheduled event") 140 | return 141 | } 142 | 143 | render.JSON(w, http.StatusNoContent, nil) 144 | } 145 | -------------------------------------------------------------------------------- /pkg/handlers/event_test.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "net/http/httptest" 7 | "strconv" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/nbari/violetear" 12 | "github.com/rafaeljesus/crony/pkg/mocks" 13 | "github.com/rafaeljesus/crony/pkg/models" 14 | ) 15 | 16 | func TestEventsIndex(t *testing.T) { 17 | repoMock := mocks.NewEventRepo() 18 | schedulerMock := mocks.NewScheduler() 19 | h := NewEventsHandler(repoMock, schedulerMock) 20 | 21 | res := httptest.NewRecorder() 22 | req, err := http.NewRequest("GET", "/events", nil) 23 | if err != nil { 24 | t.Fail() 25 | } 26 | 27 | r := violetear.New() 28 | r.HandleFunc("/events", h.EventsIndex, "GET") 29 | r.ServeHTTP(res, req) 30 | 31 | events := []models.Event{} 32 | if err := json.NewDecoder(res.Body).Decode(&events); err != nil { 33 | t.Fail() 34 | } 35 | 36 | if len(events) == 0 { 37 | t.Errorf("Expected response to not be empty %s", strconv.Itoa(len(events))) 38 | } 39 | 40 | if res.Code != http.StatusOK { 41 | t.Errorf("Expected status %d to be equal %d", res.Code, http.StatusOK) 42 | } 43 | } 44 | 45 | func TestEventsIndexByStatus(t *testing.T) { 46 | schedulerMock := mocks.NewScheduler() 47 | repoMock := mocks.NewEventRepo() 48 | h := NewEventsHandler(repoMock, schedulerMock) 49 | 50 | res := httptest.NewRecorder() 51 | req, err := http.NewRequest("GET", "/events?status=active", nil) 52 | if err != nil { 53 | t.Fail() 54 | } 55 | 56 | r := violetear.New() 57 | r.HandleFunc("/events", h.EventsIndex, "GET") 58 | r.ServeHTTP(res, req) 59 | 60 | if !repoMock.ByStatus { 61 | t.Errorf("Expected to search by status") 62 | } 63 | 64 | if res.Code != http.StatusOK { 65 | t.Errorf("Expected status %d to be equal %d", res.Code, http.StatusOK) 66 | } 67 | } 68 | 69 | func TestEventsIndexByExpression(t *testing.T) { 70 | schedulerMock := mocks.NewScheduler() 71 | repoMock := mocks.NewEventRepo() 72 | h := NewEventsHandler(repoMock, schedulerMock) 73 | 74 | res := httptest.NewRecorder() 75 | req, err := http.NewRequest("GET", "/events?expression=* * * * *", nil) 76 | if err != nil { 77 | t.Fail() 78 | } 79 | 80 | r := violetear.New() 81 | r.HandleFunc("/events", h.EventsIndex, "GET") 82 | r.ServeHTTP(res, req) 83 | 84 | if !repoMock.ByExpression { 85 | t.Errorf("Expected to search by expression") 86 | } 87 | 88 | if res.Code != http.StatusOK { 89 | t.Errorf("Expected status %d to be equal %d", res.Code, http.StatusOK) 90 | } 91 | } 92 | 93 | func TestEventCreate(t *testing.T) { 94 | schedulerMock := mocks.NewScheduler() 95 | repoMock := mocks.NewEventRepo() 96 | h := NewEventsHandler(repoMock, schedulerMock) 97 | 98 | res := httptest.NewRecorder() 99 | body := strings.NewReader(`{"url":"http://foo.com","expression":"* * * * *"}`) 100 | req, err := http.NewRequest("POST", "/events", body) 101 | if err != nil { 102 | t.Fail() 103 | } 104 | 105 | r := violetear.New() 106 | r.HandleFunc("/events", h.EventsCreate, "POST") 107 | r.ServeHTTP(res, req) 108 | 109 | if !repoMock.Created { 110 | t.Error("Expected repo create to be called") 111 | } 112 | 113 | if !schedulerMock.Created { 114 | t.Error("Expected scheduler create to be called") 115 | } 116 | 117 | if res.Code != http.StatusCreated { 118 | t.Errorf("Expected status %d to be equal %d", res.Code, http.StatusOK) 119 | } 120 | } 121 | 122 | func TestEventShow(t *testing.T) { 123 | schedulerMock := mocks.NewScheduler() 124 | repoMock := mocks.NewEventRepo() 125 | h := NewEventsHandler(repoMock, schedulerMock) 126 | 127 | res := httptest.NewRecorder() 128 | req, err := http.NewRequest("GET", "/events/1", nil) 129 | if err != nil { 130 | t.Fail() 131 | } 132 | 133 | r := violetear.New() 134 | r.AddRegex(":id", `^\d+$`) 135 | r.HandleFunc("/events/:id", h.EventsShow, "GET") 136 | r.ServeHTTP(res, req) 137 | 138 | if !repoMock.Found { 139 | t.Error("Expected repo findEventById to be called") 140 | } 141 | 142 | if res.Code != http.StatusOK { 143 | t.Errorf("Expected status %d to be equal %d", res.Code, http.StatusOK) 144 | } 145 | } 146 | 147 | func TestEventsUpdate(t *testing.T) { 148 | schedulerMock := mocks.NewScheduler() 149 | repoMock := mocks.NewEventRepo() 150 | h := NewEventsHandler(repoMock, schedulerMock) 151 | 152 | res := httptest.NewRecorder() 153 | body := strings.NewReader(`{"url":"http://foo.com","expression":"* * * * *"}`) 154 | req, err := http.NewRequest("PUT", "/events/1", body) 155 | if err != nil { 156 | t.Fail() 157 | } 158 | 159 | r := violetear.New() 160 | r.AddRegex(":id", `^\d+$`) 161 | r.HandleFunc("/events/:id", h.EventsUpdate, "PUT") 162 | r.ServeHTTP(res, req) 163 | 164 | if !repoMock.Updated { 165 | t.Error("Expected repo update to be called") 166 | } 167 | 168 | if !schedulerMock.Updated { 169 | t.Error("Expected scheduler update to be called") 170 | } 171 | 172 | if res.Code != http.StatusOK { 173 | t.Errorf("Expected status %d to be equal %d", res.Code, http.StatusOK) 174 | } 175 | } 176 | 177 | func TestEventsDelete(t *testing.T) { 178 | schedulerMock := mocks.NewScheduler() 179 | repoMock := mocks.NewEventRepo() 180 | h := NewEventsHandler(repoMock, schedulerMock) 181 | 182 | res := httptest.NewRecorder() 183 | req, err := http.NewRequest("DELETE", "/events/1", nil) 184 | if err != nil { 185 | t.Fail() 186 | } 187 | 188 | r := violetear.New() 189 | r.AddRegex(":id", `^\d+$`) 190 | r.HandleFunc("/events/:id", h.EventsDelete, "DELETE") 191 | r.ServeHTTP(res, req) 192 | 193 | if !repoMock.Deleted { 194 | t.Error("Expected repo update to be called") 195 | } 196 | 197 | if !schedulerMock.Deleted { 198 | t.Error("Expected scheduler update to be called") 199 | } 200 | 201 | if res.Code != http.StatusNoContent { 202 | t.Errorf("Expected status %d to be equal %d", res.Code, http.StatusNoContent) 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /pkg/handlers/healthz.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/rafaeljesus/crony/pkg/checker" 7 | "github.com/rafaeljesus/crony/pkg/render" 8 | ) 9 | 10 | type HealthzHandler struct { 11 | checkers map[string]checker.Checker 12 | } 13 | 14 | func NewHealthzHandler(checkers map[string]checker.Checker) *HealthzHandler { 15 | return &HealthzHandler{checkers} 16 | } 17 | 18 | func (h *HealthzHandler) HealthzIndex(w http.ResponseWriter, r *http.Request) { 19 | payload := make(map[string]bool) 20 | 21 | for k, v := range h.checkers { 22 | payload[k] = v.IsAlive() 23 | } 24 | 25 | render.JSON(w, http.StatusOK, payload) 26 | } 27 | -------------------------------------------------------------------------------- /pkg/handlers/healthz_test.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/nbari/violetear" 10 | "github.com/rafaeljesus/crony/pkg/checker" 11 | "github.com/rafaeljesus/crony/pkg/mocks" 12 | ) 13 | 14 | func TestHealthzIndex(t *testing.T) { 15 | checkers := map[string]checker.Checker{ 16 | "api": mocks.NewCheckerMock(), 17 | "postgres": mocks.NewCheckerMock(), 18 | } 19 | h := NewHealthzHandler(checkers) 20 | 21 | res := httptest.NewRecorder() 22 | req, err := http.NewRequest("GET", "/healthz", nil) 23 | if err != nil { 24 | t.Fail() 25 | } 26 | 27 | r := violetear.New() 28 | r.HandleFunc("/healthz", h.HealthzIndex, "GET") 29 | r.ServeHTTP(res, req) 30 | 31 | response := make(map[string]bool) 32 | if err := json.NewDecoder(res.Body).Decode(&response); err != nil { 33 | t.Fail() 34 | } 35 | 36 | if response["api"] != true { 37 | t.Fail() 38 | } 39 | 40 | if response["postgres"] != true { 41 | t.Fail() 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /pkg/migrations/migrations.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | _ "github.com/mattes/migrate/driver/postgres" 5 | "github.com/mattes/migrate/migrate" 6 | ) 7 | 8 | type Migrations struct { 9 | url string 10 | path string 11 | } 12 | 13 | func New(url, path string) *Migrations { 14 | return &Migrations{url, path} 15 | } 16 | 17 | func (m *Migrations) Up() (errs []error, ok bool) { 18 | errs, ok = migrate.UpSync(m.url, m.path) 19 | 20 | return 21 | } 22 | -------------------------------------------------------------------------------- /pkg/mocks/checker_mock.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | type CheckerMock struct{} 4 | 5 | func NewCheckerMock() *CheckerMock { 6 | return &CheckerMock{} 7 | } 8 | 9 | func (c *CheckerMock) IsAlive() bool { 10 | return true 11 | } 12 | -------------------------------------------------------------------------------- /pkg/mocks/event_repo_mock.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import ( 4 | "github.com/rafaeljesus/crony/pkg/models" 5 | ) 6 | 7 | type EventRepoMock struct { 8 | Created bool 9 | Updated bool 10 | Deleted bool 11 | Found bool 12 | Searched bool 13 | ByStatus bool 14 | ByExpression bool 15 | } 16 | 17 | func NewEventRepo() *EventRepoMock { 18 | return &EventRepoMock{ 19 | Created: false, 20 | Updated: false, 21 | Deleted: false, 22 | Found: false, 23 | Searched: false, 24 | ByStatus: false, 25 | ByExpression: false, 26 | } 27 | } 28 | 29 | func (repo *EventRepoMock) Create(event *models.Event) (err error) { 30 | repo.Created = true 31 | return 32 | } 33 | 34 | func (repo *EventRepoMock) FindById(id int) (event *models.Event, err error) { 35 | repo.Found = true 36 | event = &models.Event{Id: 1} 37 | return 38 | } 39 | 40 | func (repo *EventRepoMock) Update(event *models.Event) (err error) { 41 | repo.Updated = true 42 | return 43 | } 44 | 45 | func (repo *EventRepoMock) Delete(event *models.Event) (err error) { 46 | repo.Deleted = true 47 | return 48 | } 49 | 50 | func (repo *EventRepoMock) Search(sc *models.Query) (events []models.Event, err error) { 51 | events = append(events, models.Event{Expression: "* * * * * *"}) 52 | 53 | switch true { 54 | case sc.Status != "": 55 | repo.ByStatus = true 56 | case sc.Expression != "": 57 | repo.ByExpression = true 58 | default: 59 | repo.Searched = true 60 | } 61 | 62 | return 63 | } 64 | -------------------------------------------------------------------------------- /pkg/mocks/scheduler_mock.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import ( 4 | "github.com/rafaeljesus/crony/pkg/models" 5 | "github.com/rafaeljesus/crony/pkg/repos" 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(*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 (s *SchedulerMock) ScheduleAll(repo repos.EventRepo) { 43 | return 44 | } 45 | -------------------------------------------------------------------------------- /pkg/models/event.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "regexp" 5 | "time" 6 | ) 7 | 8 | var ( 9 | Active = "active" 10 | Inactive = "inactive" 11 | ) 12 | 13 | type Event struct { 14 | Id uint `json:"id" gorm:"primary_key"` 15 | Url string `json:"url" gorm:"not null"` 16 | Expression string `json:"expression" gorm:"not null"` 17 | Status string `json:"status" gorm:"index:idx_status" gorm:"not null"` 18 | Retries int64 `json:"retries"` 19 | RetryTimeout time.Duration `json:"retry_timeout"` 20 | CreatedAt time.Time `json:"created_at"` 21 | UpdatedAt time.Time `json:"updated_at"` 22 | DeletedAt time.Time `json:"deleted_at,omitempty"` 23 | } 24 | 25 | func NewEvent() *Event { 26 | return &Event{ 27 | Status: Active, 28 | Retries: 1, 29 | RetryTimeout: time.Second * 1, 30 | } 31 | } 32 | 33 | func (e *Event) Validate() (errors map[string]string, ok bool) { 34 | errors = make(map[string]string) 35 | if e.Url == "" { 36 | errors["url"] = "field url is mandatory" 37 | } 38 | 39 | if e.Expression == "" { 40 | errors["expression"] = "field expression is mandatory" 41 | } 42 | 43 | match, err := regexp.MatchString("^active$|^inactive$", e.Status) 44 | if err != nil || !match { 45 | errors["status"] = "field status must be active or inactive" 46 | } 47 | 48 | if e.Retries < 0 || e.Retries > 10 { 49 | errors["retries"] = "field retries must be between 0 and 10" 50 | } 51 | 52 | ok = len(errors) == 0 53 | 54 | return 55 | } 56 | 57 | func (e *Event) SetAttributes(newEvent *Event) { 58 | if newEvent.Url != "" { 59 | e.Url = newEvent.Url 60 | } 61 | 62 | if newEvent.Expression != "" { 63 | e.Expression = newEvent.Expression 64 | } 65 | 66 | if newEvent.Status != "" { 67 | e.Status = newEvent.Status 68 | } 69 | 70 | if newEvent.Retries > 0 { 71 | e.Retries = newEvent.Retries 72 | } 73 | 74 | if newEvent.RetryTimeout > 0 { 75 | e.RetryTimeout = newEvent.RetryTimeout 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /pkg/models/event_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestValidate_Defaults(t *testing.T) { 9 | e := NewEvent() 10 | errors, ok := e.Validate() 11 | 12 | if ok { 13 | t.Fail() 14 | } 15 | 16 | if len(errors) == 0 { 17 | t.Fail() 18 | } 19 | 20 | if _, ok := errors["url"]; !ok { 21 | t.Fail() 22 | } 23 | 24 | if _, ok := errors["expression"]; !ok { 25 | t.Fail() 26 | } 27 | 28 | if _, ok := errors["status"]; ok { 29 | t.Fail() 30 | } 31 | 32 | if _, ok := errors["retries"]; ok { 33 | t.Fail() 34 | } 35 | } 36 | 37 | func TestValidate_Status(t *testing.T) { 38 | e := NewEvent() 39 | e.Status = "invalid" 40 | errors, ok := e.Validate() 41 | 42 | if ok { 43 | t.Fail() 44 | } 45 | 46 | if len(errors) == 0 { 47 | t.Fail() 48 | } 49 | 50 | if _, ok := errors["status"]; !ok { 51 | t.Fail() 52 | } 53 | } 54 | 55 | func TestValidate_Retries(t *testing.T) { 56 | e := NewEvent() 57 | e.Retries = -1 58 | errors, ok := e.Validate() 59 | 60 | if ok { 61 | t.Fail() 62 | } 63 | 64 | if len(errors) == 0 { 65 | t.Fail() 66 | } 67 | 68 | if _, ok := errors["retries"]; !ok { 69 | t.Fail() 70 | } 71 | 72 | e = NewEvent() 73 | e.Retries = 11 74 | errors, ok = e.Validate() 75 | 76 | if _, ok = errors["retries"]; !ok { 77 | t.Fail() 78 | } 79 | } 80 | 81 | func TestSetAttributes(t *testing.T) { 82 | e := NewEvent() 83 | newEvent := &Event{ 84 | Url: "http://newapi.io", 85 | Expression: "1 1 1 1 1", 86 | Status: Inactive, 87 | Retries: 5, 88 | RetryTimeout: time.Second * 10, 89 | } 90 | e.SetAttributes(newEvent) 91 | 92 | if e.Url != newEvent.Url { 93 | t.Fail() 94 | } 95 | 96 | if e.Expression != newEvent.Expression { 97 | t.Fail() 98 | } 99 | 100 | if e.Status != newEvent.Status { 101 | t.Fail() 102 | } 103 | 104 | if e.Retries != newEvent.Retries { 105 | t.Fail() 106 | } 107 | 108 | if e.RetryTimeout != newEvent.RetryTimeout { 109 | t.Fail() 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /pkg/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{status, expression} 10 | } 11 | 12 | func (q *Query) IsEmpty() bool { 13 | return q.Status == "" && 14 | q.Expression == "" 15 | } 16 | -------------------------------------------------------------------------------- /pkg/models/query_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestIsEmpty(t *testing.T) { 8 | q := NewQuery(Active, "exp") 9 | 10 | if q.IsEmpty() { 11 | t.Fail() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /pkg/render/render.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | ) 7 | 8 | type response struct { 9 | Status string `json:"status"` 10 | Message interface{} `json:"message"` 11 | } 12 | 13 | func Response(w http.ResponseWriter, code int, v interface{}) (err error) { 14 | wrapDefaults(w, code) 15 | 16 | var s string 17 | switch code { 18 | case http.StatusBadRequest: 19 | s = "invalid_request" 20 | case http.StatusUnprocessableEntity: 21 | s = "invalid_event" 22 | case http.StatusNotFound: 23 | s = "event_not_found" 24 | } 25 | 26 | r := &response{s, v} 27 | 28 | err = encode(w, r) 29 | 30 | return 31 | } 32 | 33 | func JSON(w http.ResponseWriter, code int, v interface{}) (err error) { 34 | wrapDefaults(w, code) 35 | 36 | if v == nil || code == http.StatusNoContent { 37 | return 38 | } 39 | 40 | err = encode(w, v) 41 | 42 | return 43 | } 44 | 45 | func wrapDefaults(w http.ResponseWriter, code int) { 46 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 47 | w.WriteHeader(code) 48 | } 49 | 50 | func encode(w http.ResponseWriter, v interface{}) (err error) { 51 | enc := json.NewEncoder(w) 52 | enc.SetEscapeHTML(true) 53 | 54 | if err = enc.Encode(v); err != nil { 55 | http.Error(w, err.Error(), http.StatusInternalServerError) 56 | } 57 | 58 | return 59 | } 60 | -------------------------------------------------------------------------------- /pkg/repos/event.go: -------------------------------------------------------------------------------- 1 | package repos 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/jinzhu/gorm" 7 | "github.com/rafaeljesus/crony/pkg/models" 8 | ) 9 | 10 | type EventRepo interface { 11 | Create(event *models.Event) (err error) 12 | FindById(id int) (event *models.Event, err error) 13 | Update(event *models.Event) (err error) 14 | Delete(event *models.Event) (err error) 15 | Search(query *models.Query) (events []models.Event, err error) 16 | } 17 | 18 | type Event struct { 19 | db *gorm.DB 20 | } 21 | 22 | func NewEvent(db *gorm.DB) *Event { 23 | return &Event{db} 24 | } 25 | 26 | func (r *Event) Create(e *models.Event) error { 27 | e.CreatedAt = time.Now() 28 | return r.db.Create(e).Error 29 | } 30 | 31 | func (r *Event) FindById(id int) (e *models.Event, err error) { 32 | err = r.db.Find(e, id).Error 33 | return 34 | } 35 | 36 | func (r *Event) Update(e *models.Event) error { 37 | e.UpdatedAt = time.Now() 38 | return r.db.Save(e).Error 39 | } 40 | 41 | func (r *Event) Delete(e *models.Event) error { 42 | e.DeletedAt = time.Now() 43 | return r.db.Delete(e).Error 44 | } 45 | 46 | func (r *Event) Search(q *models.Query) (events []models.Event, err error) { 47 | if q.IsEmpty() { 48 | err = r.db.Find(&events).Error 49 | if err != nil { 50 | return 51 | } 52 | 53 | return 54 | } 55 | 56 | var db *gorm.DB 57 | if q.Status != "" { 58 | db = db.Where("status = ?", q.Status) 59 | } 60 | 61 | if q.Expression != "" { 62 | db = db.Where("expression = ?", q.Expression) 63 | } 64 | 65 | err = db.Find(events).Error 66 | 67 | return 68 | } 69 | -------------------------------------------------------------------------------- /pkg/runner/runner.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "time" 5 | 6 | log "github.com/Sirupsen/logrus" 7 | "github.com/rubyist/circuitbreaker" 8 | ) 9 | 10 | type Runner interface { 11 | Run() chan *Config 12 | } 13 | 14 | type Config struct { 15 | Url string 16 | Retries int64 17 | Timeout time.Duration 18 | } 19 | 20 | type runner struct { 21 | runChannel chan *Config 22 | } 23 | 24 | func New() Runner { 25 | r := &runner{ 26 | runChannel: make(chan *Config), 27 | } 28 | 29 | go r.register() 30 | 31 | return r 32 | } 33 | 34 | func (r *runner) Run() chan *Config { 35 | return r.runChannel 36 | } 37 | 38 | func (r *runner) register() { 39 | for { 40 | select { 41 | case event := <-r.runChannel: 42 | r.run(event) 43 | } 44 | } 45 | } 46 | 47 | func (r *runner) run(c *Config) { 48 | timeout := time.Second * time.Duration(c.Timeout) 49 | client := circuit.NewHTTPClient(timeout, c.Retries, nil) 50 | 51 | _, err := client.Get(c.Url) 52 | if err == nil { 53 | log.WithField("url", c.Url).Info("Event job event sent") 54 | return 55 | } 56 | 57 | log.WithField("url", c.Url).Info("Failed to send event") 58 | } 59 | -------------------------------------------------------------------------------- /pkg/runner/runner_test.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "gopkg.in/h2non/gock.v1" 8 | ) 9 | 10 | func TestRunnerRun(t *testing.T) { 11 | url := "https://github.com" 12 | 13 | defer gock.Off() 14 | gock.New(url). 15 | Get("/"). 16 | Reply(200). 17 | BodyString("foo") 18 | 19 | c := &Config{Url: url} 20 | r := New() 21 | r.Run() <- c 22 | 23 | time.Sleep(time.Second * 1) 24 | 25 | if !gock.IsDone() { 26 | t.Fail() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /pkg/scheduler/error.go: -------------------------------------------------------------------------------- 1 | package scheduler 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | var ( 8 | ErrEventNotExist = errors.New("finding a scheduled event requires a existent cron id") 9 | ) 10 | -------------------------------------------------------------------------------- /pkg/scheduler/scheduler.go: -------------------------------------------------------------------------------- 1 | package scheduler 2 | 3 | import ( 4 | "sync" 5 | 6 | log "github.com/Sirupsen/logrus" 7 | "github.com/rafaeljesus/crony/pkg/models" 8 | "github.com/rafaeljesus/crony/pkg/repos" 9 | "github.com/rafaeljesus/crony/pkg/runner" 10 | "github.com/robfig/cron" 11 | ) 12 | 13 | type Scheduler interface { 14 | Create(event *models.Event) error 15 | Update(event *models.Event) error 16 | Delete(id uint) error 17 | Find(id uint) (*cron.Cron, error) 18 | ScheduleAll(r repos.EventRepo) 19 | } 20 | 21 | type scheduler struct { 22 | sync.RWMutex 23 | Kv map[uint]*cron.Cron 24 | Cron *cron.Cron 25 | } 26 | 27 | func New() Scheduler { 28 | s := &scheduler{ 29 | Kv: make(map[uint]*cron.Cron), 30 | Cron: cron.New(), 31 | } 32 | 33 | s.Cron.Start() 34 | 35 | return s 36 | } 37 | 38 | func (s *scheduler) Create(e *models.Event) (err error) { 39 | s.Cron.AddFunc(e.Expression, func() { 40 | c := &runner.Config{ 41 | Url: e.Url, 42 | Retries: e.Retries, 43 | Timeout: e.RetryTimeout, 44 | } 45 | 46 | r := runner.New() 47 | r.Run() <- c 48 | }) 49 | 50 | s.Lock() 51 | defer s.Unlock() 52 | 53 | s.Kv[e.Id] = s.Cron 54 | 55 | return 56 | } 57 | 58 | func (s *scheduler) Find(id uint) (cron *cron.Cron, err error) { 59 | s.Lock() 60 | defer s.Unlock() 61 | 62 | cron, found := s.Kv[id] 63 | if !found { 64 | err = ErrEventNotExist 65 | return 66 | } 67 | 68 | return 69 | } 70 | 71 | func (s *scheduler) Update(e *models.Event) (err error) { 72 | if err = s.Delete(e.Id); err != nil { 73 | return 74 | } 75 | 76 | return s.Create(e) 77 | } 78 | 79 | func (s *scheduler) Delete(id uint) (err error) { 80 | s.Lock() 81 | defer s.Unlock() 82 | 83 | _, found := s.Kv[id] 84 | if !found { 85 | err = ErrEventNotExist 86 | return 87 | } 88 | 89 | s.Kv[id].Stop() 90 | s.Kv[id] = nil 91 | 92 | return 93 | } 94 | 95 | func (s *scheduler) ScheduleAll(r repos.EventRepo) { 96 | events, err := r.Search(&models.Query{}) 97 | if err != nil { 98 | log.Error("Failed to find events!") 99 | return 100 | } 101 | 102 | for _, e := range events { 103 | if err = s.Create(&e); err != nil { 104 | log.Error("Failed to create event!") 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /pkg/scheduler/scheduler_test.go: -------------------------------------------------------------------------------- 1 | package scheduler 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/rafaeljesus/crony/pkg/mocks" 7 | "github.com/rafaeljesus/crony/pkg/models" 8 | ) 9 | 10 | func TestScheduleAll(t *testing.T) { 11 | repoMock := mocks.NewEventRepo() 12 | s := New() 13 | s.ScheduleAll(repoMock) 14 | } 15 | 16 | func TestSchedulerCreate(t *testing.T) { 17 | s := New() 18 | c := &models.Event{Id: 1, Expression: "* * * * *"} 19 | if err := s.Create(c); err != nil { 20 | t.Fail() 21 | } 22 | } 23 | 24 | func TestSchedulerFind(t *testing.T) { 25 | s := New() 26 | c := &models.Event{Id: 1, Expression: "* * * * *"} 27 | if err := s.Create(c); err != nil { 28 | t.Fail() 29 | } 30 | 31 | _, err := s.Find(c.Id) 32 | if err != nil { 33 | t.Fail() 34 | } 35 | } 36 | 37 | func TestSchedulerUpdate(t *testing.T) { 38 | s := New() 39 | c := &models.Event{Id: 1, Expression: "* * * * *"} 40 | if err := s.Create(c); err != nil { 41 | t.Fail() 42 | } 43 | 44 | c.Status = "active" 45 | if err := s.Update(c); err != nil { 46 | t.Fail() 47 | } 48 | } 49 | 50 | func TestSchedulerDelete(t *testing.T) { 51 | s := New() 52 | c := &models.Event{Id: 1, Expression: "* * * * *"} 53 | if err := s.Create(c); err != nil { 54 | t.Fail() 55 | } 56 | 57 | if err := s.Delete(c.Id); err != nil { 58 | t.Fail() 59 | } 60 | } 61 | --------------------------------------------------------------------------------