├── .gitignore ├── .travis.yml ├── Dockerfile ├── Dockerfile.docs ├── Dockerfile.test ├── Jenkinsfile ├── LICENSE.md ├── README.md ├── cron ├── cron.go └── cron_test.go ├── docker-compose-test.yml ├── docker-compose.example.yml ├── docker ├── service.go └── service_test.go ├── docs ├── docker-jobs.md ├── feedback-and-contribution.md ├── index.md ├── license.md ├── release-notes.md ├── requirements.md ├── swagger.yml ├── tutorial.md └── usage.md ├── helm └── docker-flow-cron │ ├── Chart.yaml │ ├── README.md │ ├── templates │ ├── NOTES.txt │ ├── _helpers.tpl │ ├── deployment.yaml │ ├── ing.yaml │ └── svc.yaml │ └── values.yaml ├── main.go ├── mkdocs.yml ├── proxy-key.enc ├── server ├── server.go └── server_test.go ├── stack-docs.yml └── stack.yml /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /*.iml 3 | /docker-flow-cron 4 | /df-cron.yml 5 | /proxy-key 6 | /proxy-key.pub -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | env: 2 | global: 3 | - VERSION=0.${TRAVIS_BUILD_NUMBER} 4 | - DOCKER_HUB_USER=vfarcic 5 | - TAG=0.${TRAVIS_BUILD_NUMBER} 6 | - PORT=8081 7 | 8 | sudo: required 9 | 10 | services: 11 | - docker 12 | 13 | before_install: 14 | - 'if [ "$TRAVIS_PULL_REQUEST" = "false" ]; then openssl aes-256-cbc -K $encrypted_62b14f38a520_key -iv $encrypted_62b14f38a520_iv -in proxy-key.enc -out proxy-key -d; fi' 15 | - 'if [ "$TRAVIS_PULL_REQUEST" = "false" ]; then chmod 600 proxy-key; fi' 16 | 17 | script: 18 | - set -e 19 | - docker swarm init 20 | - docker-compose -f docker-compose-test.yml run --rm unit 21 | - docker build -t vfarcic/docker-flow-cron . 22 | - docker tag vfarcic/docker-flow-cron vfarcic/docker-flow-cron:${VERSION} 23 | - 'if [ "$TRAVIS_PULL_REQUEST" = "false" ]; then docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD; fi' 24 | - 'if [ "$TRAVIS_PULL_REQUEST" = "false" ]; then docker push vfarcic/docker-flow-cron:${VERSION}; fi' 25 | - 'if [ "$TRAVIS_PULL_REQUEST" = "false" ]; then docker push vfarcic/docker-flow-cron; fi' 26 | - docker-compose -f docker-compose-test.yml run --rm docs 27 | - docker build -t vfarcic/docker-flow-cron-docs -f Dockerfile.docs . 28 | - docker tag vfarcic/docker-flow-cron-docs vfarcic/docker-flow-cron-docs:${VERSION} 29 | - 'if [ "$TRAVIS_PULL_REQUEST" = "false" ]; then docker push vfarcic/docker-flow-cron-docs:${VERSION}; fi' 30 | - 'if [ "$TRAVIS_PULL_REQUEST" = "false" ]; then docker push vfarcic/docker-flow-cron-docs; fi' 31 | # - 'if [ "$TRAVIS_PULL_REQUEST" = "false" ]; then ssh -o "StrictHostKeyChecking no" -i proxy-key root@${SWARM_MANAGER_1_PUBLIC_IP} curl -i cron-docs.yml https://github.com/vfarcic/docker-flow-cron/blob/master/stack-docs.yml; fi' 32 | # - 'if [ "$TRAVIS_PULL_REQUEST" = "false" ]; then ssh -o "StrictHostKeyChecking no" -i proxy-key root@${SWARM_MANAGER_1_PUBLIC_IP} export TAG=${VERSION} PORT=8081 docker stack deploy -c cron-docs.yml cron-docs; fi' 33 | 34 | branches: 35 | only: 36 | - master 37 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.7 AS build 2 | ADD . /src 3 | WORKDIR /src 4 | RUN go get -d -v -t ./... 5 | RUN go build -v -o docker-flow-cron 6 | 7 | FROM alpine:3.5 8 | MAINTAINER Viktor Farcic 9 | 10 | RUN mkdir /lib64 && ln -s /lib/libc.musl-x86_64.so.1 /lib64/ld-linux-x86-64.so.2 11 | 12 | EXPOSE 8080 13 | 14 | CMD ["docker-flow-cron"] 15 | 16 | ENV DOCKER_VERSION 1.13.1 17 | RUN set -x \ 18 | && apk add --no-cache curl \ 19 | && curl -fSL "https://get.docker.com/builds/Linux/x86_64/docker-${DOCKER_VERSION}.tgz" -o docker.tgz \ 20 | && tar -xzvf docker.tgz \ 21 | && mv docker/* /usr/local/bin/ \ 22 | && rmdir docker \ 23 | && rm docker.tgz \ 24 | && apk del curl 25 | 26 | COPY --from=build /src/docker-flow-cron /usr/local/bin/docker-flow-cron 27 | RUN chmod +x /usr/local/bin/docker-flow-cron 28 | -------------------------------------------------------------------------------- /Dockerfile.docs: -------------------------------------------------------------------------------- 1 | FROM cilerler/mkdocs AS build 2 | MAINTAINER Viktor Farcic 3 | ADD . /docs 4 | RUN pip install pygments && pip install pymdown-extensions 5 | RUN mkdocs build --site-dir /site 6 | 7 | FROM nginx:1.11-alpine 8 | MAINTAINER Viktor Farcic 9 | COPY --from=build /site /usr/share/nginx/html 10 | -------------------------------------------------------------------------------- /Dockerfile.test: -------------------------------------------------------------------------------- 1 | FROM golang:1.7.5 2 | 3 | MAINTAINER Viktor Farcic 4 | 5 | RUN apt-get update && \ 6 | apt-get install -y apt-transport-https ca-certificates curl software-properties-common expect && \ 7 | curl -fsSL https://download.docker.com/linux/ubuntu/gpg | apt-key add - && \ 8 | add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/debian $(lsb_release -cs) stable" && \ 9 | apt-get update && \ 10 | apt-get -y install docker-ce 11 | 12 | RUN go get github.com/docker/docker/api/types && \ 13 | go get github.com/docker/docker/api/types/filters && \ 14 | go get github.com/docker/docker/api/types/swarm && \ 15 | go get github.com/docker/docker/client && \ 16 | go get gopkg.in/robfig/cron.v2 && \ 17 | go get golang.org/x/net/context && \ 18 | go get github.com/gorilla/mux && \ 19 | go get github.com/stretchr/testify/suite 20 | 21 | COPY . /src 22 | WORKDIR /src -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | import java.text.SimpleDateFormat 2 | 3 | pipeline { 4 | agent { 5 | label "test" 6 | } 7 | options { 8 | buildDiscarder(logRotator(numToKeepStr: '2')) 9 | disableConcurrentBuilds() 10 | } 11 | stages { 12 | stage("build") { 13 | steps { 14 | script { 15 | def dateFormat = new SimpleDateFormat("yy.MM") 16 | currentBuild.displayName = dateFormat.format(new Date()) + "." + env.BUILD_NUMBER 17 | } 18 | sh "docker image build -t vfarcic/docker-flow-cron ." 19 | sh "docker tag vfarcic/docker-flow-cron vfarcic/docker-flow-cron:beta" 20 | withCredentials([usernamePassword( 21 | credentialsId: "docker", 22 | usernameVariable: "USER", 23 | passwordVariable: "PASS" 24 | )]) { 25 | sh "docker login -u $USER -p $PASS" 26 | } 27 | sh "docker push vfarcic/docker-flow-cron:beta" 28 | sh "docker image build -t vfarcic/docker-flow-cron-test -f Dockerfile.test ." 29 | sh "docker push vfarcic/docker-flow-cron-test" 30 | sh "docker image build -t vfarcic/docker-flow-cron-docs -f Dockerfile.docs ." 31 | } 32 | } 33 | stage("test") { 34 | environment { 35 | HOST_IP = "build.dockerflow.com" 36 | DOCKER_HUB_USER = "vfarcic" 37 | } 38 | steps { 39 | sh "docker-compose -f docker-compose-test.yml run --rm unit" 40 | } 41 | } 42 | stage("release") { 43 | when { 44 | branch "master" 45 | } 46 | steps { 47 | withCredentials([usernamePassword( 48 | credentialsId: "docker", 49 | usernameVariable: "USER", 50 | passwordVariable: "PASS" 51 | )]) { 52 | sh "docker login -u $USER -p $PASS" 53 | } 54 | sh "docker tag vfarcic/docker-flow-cron vfarcic/docker-flow-cron:${currentBuild.displayName}" 55 | sh "docker push vfarcic/docker-flow-cron:${currentBuild.displayName}" 56 | sh "docker push vfarcic/docker-flow-cron" 57 | sh "docker tag vfarcic/docker-flow-cron-docs vfarcic/docker-flow-cron-docs:${currentBuild.displayName}" 58 | sh "docker push vfarcic/docker-flow-cron-docs:${currentBuild.displayName}" 59 | sh "docker push vfarcic/docker-flow-cron-docs" 60 | } 61 | } 62 | stage("deploy") { 63 | when { 64 | branch "master" 65 | } 66 | agent { 67 | label "prod" 68 | } 69 | steps { 70 | sh "helm upgrade -i docker-flow-cron helm/docker-flow-cron --namespace df --set image.tag=${currentBuild.displayName}" 71 | } 72 | } 73 | } 74 | post { 75 | always { 76 | sh "docker system prune -f" 77 | } 78 | failure { 79 | slackSend( 80 | color: "danger", 81 | message: "${env.JOB_NAME} failed: ${env.RUN_DISPLAY_URL}" 82 | ) 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Viktor Farcic & Leo Starcevic 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Docker Flow Cron 2 | 3 | **THIS PROJECT IS IN DESIGN PHASE, DO NOT USE IT IN PRODUCTION YET!** 4 | 5 | Docker Swarm services are designed to the long lasting processes that, potentially, live forever. Docker does not have a mechanism to schedule jobs based on time interval or, to put it in other words, it does not have the ability to use cron-like syntax for time-based scheduling. 6 | 7 | *Docker Flow Cron* is designed to overcome some of the limitations behind Docker Swarm services and provide cron-like time-based scheduling while maintaining fault tolerance features available in Docker Swarm. 8 | 9 | Please visit the **[project documentation](http://cron.dockerflow.com)** for more info or join the #df-cron Slack channel in [DevOps20](http://slack.devops20toolkit.com/) if you have any questions, suggestions, or problems. 10 | 11 | # Usage 12 | 13 | There is currently two different ways of using Docker Flow Cron 14 | 15 | - Using the [Docker Flow Cron API](#docker-flow-cron-api) directly to manage (add, list, delete) scheduled jobs 16 | 17 | - Using the [*Docker Flow Swarm Listener*](#docker-flow-swarm-listener-support) support to manage jobs by creating/deleting regular Docker Services. 18 | 19 | 20 | ## Docker Flow Cron API 21 | #### Put Job 22 | 23 | > Adds a job to docker-flow-cron 24 | 25 | The following body parameters can be used to send a *create job* `PUT` request to *Docker Flow Cron*. They should be added to the base address **[CRON_IP]:[CRON_PORT]/v1/docker-flow-cron/job/[jobName]**. 26 | 27 | |param |Description |Mandatory|Example | 28 | |----------------|-------------------------------------------------------------------|---------|---------| 29 | |image |Docker image. |yes |alpine | 30 | |serviceName |Docker service name |no |my-cronjob | 31 | |command |The command that will be executed when a job is created. |no |echo "hello World"| 32 | |schedule |The schedule that defines the frequency of the job execution.Check the [scheduling section](#scheduling) for more info. |yes|@every 15s| 33 | |args |The list of arguments that can be used with the `docker service create` command.

`--restart-condition` cannot be set to `any`. If not specified, it will be set to `none`.
`--name` argument is not allowed. Use serviceName param instead

Any other argument supported by `docker service create` is allowed.|no|TODO| 34 | 35 | TODO: Example 36 | 37 | #### Get All Jobs 38 | 39 | > Gets all scheduled jobs 40 | 41 | The following `GET` request **[CRON_IP]:[CRON_PORT]/v1/docker-flow-cron/job**. can be used to get all scheduled jobs from Docker Flow Cron. 42 | 43 | 44 | #### Get Job 45 | 46 | > Gets a job from docker-flow-cron 47 | 48 | The following `GET` request **[CRON_IP]:[CRON_PORT]/v1/docker-flow-cron/[jobName]**. can be used to get a job from Docker Flow Cron. 49 | 50 | #### Delete Job 51 | 52 | > Deletes a job from docker-flow-cron 53 | 54 | The following `DELETE` request **[CRON_IP]:[CRON_PORT]/v1/docker-flow-cron/[jobName]**. can be used to delete a job from Docker Flow Cron. 55 | 56 | 57 | ## *Docker Flow Swarm Listener* support 58 | 59 | Using the *Docker Flow Swarm Listener* support, Docker Services can schedule jobs. 60 | Docker Flow Swarm Listener listens to Docker Swarm events and sends requests to Docker Flow Cron when changes occurs, 61 | every time a service is created or deleted Docker Flow Cron gets notified and manages job scheduling. 62 | 63 | 64 | A Docker Service is created with the following syntax: 65 | 66 | ```docker service create [OPTIONS] IMAGE [COMMAND] [ARG...]``` 67 | 68 | > Check the [documentation](https://docs.docker.com/engine/reference/commandline/service_create/) for more information. 69 | 70 | The same syntax is used to schedule a job in Docker Flow Cron: 71 | 72 | - ```IMAGE``` specifies the Docker Image 73 | - ```[COMMAND]``` specifies the command to schedule. 74 | - ```[OPTIONS]``` specifies some necessary options making scheduling Docker Services possible. 75 | 76 | 77 | The following Docker Service args ```[OPTIONS]``` should be used for scheduled Docker Services: 78 | 79 | |arg |Description |Mandatory|Example | 80 | |--------------------|-------------------------------------------------------------------|---------|----------| 81 | |--replicas | Set 0 to prevent the service from running immedietely. Set to 1 to run command on service creation. |yes |0 or 1 | 82 | |--restart-condition | Set to ```none``` to prevent the service from using Docker Swarm's ability to autorestart exited services. |yes |none | 83 | 84 | 85 | 86 | The following Docker Service labels ```[OPTIONS]``` needs to be used for scheduled Docker Services: 87 | 88 | |label |Description |Prefix|Mandatory|Example | 89 | |----------------|-------------------------------------------------------------------|------|---------|----------| 90 | |cron |Enable scheduling |com.df|yes |true | 91 | |image |Docker image. |com.df.cron|yes |alpine | 92 | |name |Cronjob name. |com.df.cron|yes |my-cronjob| 93 | |schedule |The schedule that defines the frequency of the job execution. Check the [scheduling section](#scheduling) for more info.|com.df.cron|yes|@every 15s| 94 | |command |The command that is scheduled, only used for Docker Flow Cron registration. Use the same command you set for your docker service to run.|com.df.cron|No |echo Hello World| 95 | 96 | **All labels needs to be prefixed** 97 | 98 | > Examples: 99 | - ```--labels "com.df.cron=true"``` 100 | - ```--labels "com.df.cron.name=my-job"``` 101 | 102 | 103 | ## Tutorial 104 | 105 | #### Docker Flow Cron using Docker Stacks 106 | 107 | > Create the overlay network 108 | 109 | ```docker network create -d overlay cron``` 110 | 111 | > Create the cron stack 112 | 113 | docker-compose.yml 114 | ``` 115 | version: "3" 116 | 117 | services: 118 | cron: 119 | image: vfarcic/docker-flow-cron 120 | networks: 121 | - cron 122 | volumes: 123 | - /var/run/docker.sock:/var/run/docker.sock 124 | ports: 125 | - 8080:8080 126 | deploy: 127 | placement: 128 | constraints: [node.role == manager] 129 | 130 | swarm-listener: 131 | image: vfarcic/docker-flow-swarm-listener 132 | networks: 133 | - cron 134 | volumes: 135 | - /var/run/docker.sock:/var/run/docker.sock 136 | environment: 137 | - DF_NOTIFY_CREATE_SERVICE_URL=http://cron:8080/v1/docker-flow-cron/job/create 138 | - DF_NOTIFY_REMOVE_SERVICE_URL=http://cron:8080/v1/docker-flow-cron/job/remove 139 | deploy: 140 | placement: 141 | constraints: [node.role == manager] 142 | networks: 143 | cron: 144 | external: true 145 | ``` 146 | 147 | Deploy the stack above by executing ```docker stack deploy -c docker-compose.yml cron``` 148 | 149 | 150 | 151 | #### Docker Flow Cron using Docker Services 152 | 153 | > Create Docker Flow Swarm Listener 154 | 155 | ``` 156 | docker service create --name swarm-listener \ 157 | --network cron \ 158 | --mount "type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock" \ 159 | -e DF_NOTIFY_CREATE_SERVICE_URL=http://cron:8080/v1/docker-flow-cron/create \ 160 | -e DF_NOTIFY_REMOVE_SERVICE_URL=http://cron:8080/v1/docker-flow-cron/remove \ 161 | --constraint 'node.role==manager' \ 162 | vfarcic/docker-flow-swarm-listener 163 | ``` 164 | 165 | > Create Docker Flow Cron 166 | 167 | ``` 168 | docker service create --name cron \ 169 | --network cron \ 170 | --mount "type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock" \ 171 | -p 8080:8080 \ 172 | --constraint 'node.role==manager' \ 173 | vfarcic/docker-flow-cron 174 | ``` 175 | 176 | #### Example: Scheduling a Docker Service 177 | 178 | Now that the cron stack is up and running we can add our first scheduled Docker Service. 179 | The example service below will run ```echo "Hello World"``` every 10 seconds using an Alpine image. 180 | 181 | > Example: Scheduled job using Docker Services 182 | 183 | ``` 184 | docker service create --name cronjob \ 185 | --network cron --replicas 0 \ 186 | --restart-condition=none \ 187 | -l "com.df.notify=true" \ 188 | -l "com.df.cron=true" \ 189 | -l "com.df.cron.name=cronjob" \ 190 | -l "com.df.cron.image=alpine" \ 191 | -l "com.df.cron.command=echo Hello World" \ 192 | -l "com.df.cron.schedule=@every 10s" \ 193 | alpine \ 194 | echo Hello world 195 | ``` 196 | 197 | The example below is a more realistic use case for *Docker Flow Cron*. 198 | It will remove unusued docker data by running ```docker system prune``` every day. 199 | 200 | > Example: Scheduling a docker command to run on the host 201 | ``` 202 | docker service create --name docker_cleanup \ 203 | --network cron --replicas 0 \ 204 | --restart-condition=none \ 205 | --mount "type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock" \ 206 | -l "com.df.notify=true" \ 207 | -l "com.df.cron=true" \ 208 | -l "com.df.cron.name=docker_cleanup" \ 209 | -l "com.df.cron.image=docker" \ 210 | -l "com.df.cron.command=docker system prune -f" \ 211 | -l "com.df.cron.schedule=@daily" \ 212 | docker \ 213 | docker system prune -f 214 | ``` 215 | 216 | ## Scheduling 217 | Docker Flow Cron uses the library [robfig/cron](https://godoc.org/github.com/robfig/cron) to provide a simple cron syntax for scheduling. 218 | 219 | > Examples 220 | ``` 221 | 0 30 * * * * Every hour on the half hour 222 | @hourly Every hour 223 | @every 1h30m Every hour thirty 224 | ``` 225 | 226 | #### Predefined schedules 227 | You may use one of several pre-defined schedules in place of a cron expression. 228 | ``` 229 | Entry | Description | Equivalent To 230 | ----- | ----------- | ------------- 231 | @yearly (or @annually) | Run once a year, midnight, Jan. 1st | 0 0 0 1 1 * 232 | @monthly | Run once a month, midnight, first of month | 0 0 0 1 * * 233 | @weekly | Run once a week, midnight on Sunday | 0 0 0 * * 0 234 | @daily (or @midnight) | Run once a day, midnight | 0 0 0 * * * 235 | @hourly | Run once an hour, beginning of hour | 0 0 * * * * 236 | ``` 237 | 238 | #### Intervals 239 | You may also schedule a job to execute at fixed intervals 240 | 241 | ``` 242 | @every 243 | @every 2h30m15s 244 | ``` 245 | 246 | Check the library [documentation](https://godoc.org/github.com/robfig/cron) for more information. 247 | -------------------------------------------------------------------------------- /cron/cron.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import ( 4 | "../docker" 5 | "fmt" 6 | "github.com/docker/docker/api/types/swarm" 7 | rcron "gopkg.in/robfig/cron.v2" 8 | "os/exec" 9 | "strings" 10 | ) 11 | 12 | const dockerApiVersion = "v1.24" 13 | 14 | type Croner interface { 15 | AddJob(data JobData) error 16 | Stop() 17 | GetJobs() (map[string]JobData, error) 18 | RemoveJob(jobName string) error 19 | RescheduleJobs() error 20 | } 21 | 22 | type Cron struct { 23 | Cron *rcron.Cron 24 | Service docker.Servicer 25 | Jobs map[string]rcron.EntryID 26 | } 27 | 28 | var rCronAddFunc = func(c *rcron.Cron, spec string, cmd func()) (rcron.EntryID, error) { 29 | return c.AddFunc(spec, cmd) 30 | } 31 | 32 | type JobData struct { 33 | Name string `json:"name"` 34 | ServiceName string `json:"servicename"` 35 | Image string `json:"image"` 36 | Command string `json:"command"` 37 | Schedule string `json:"schedule"` 38 | Args []string `json:"args"` 39 | Created bool `json:"created` 40 | } 41 | 42 | var New = func(dockerHost string) (Croner, error) { 43 | service, err := docker.New(dockerHost) 44 | if err != nil { 45 | return &Cron{}, err 46 | } 47 | c := rcron.New() 48 | c.Start() 49 | return &Cron{Cron: c, Service: service, Jobs: map[string]rcron.EntryID{}}, nil 50 | } 51 | 52 | func (c *Cron) AddJob(data JobData) error { 53 | fmt.Println("Scheduling", data.Name) 54 | if data.Args == nil { 55 | data.Args = []string{} 56 | } 57 | if len(data.Name) == 0 { 58 | return fmt.Errorf("name is mandatory") 59 | } 60 | if len(data.Image) == 0 { 61 | return fmt.Errorf("image is mandatory") 62 | } 63 | /*if len(data.Schedule) == 0 { 64 | return fmt.Errorf("schedule is mandatory") 65 | }*/ 66 | 67 | serviceName := data.Name 68 | if data.ServiceName != "" { 69 | serviceName = data.ServiceName 70 | } 71 | 72 | if !data.Created{ 73 | cmdPrefix := "docker service create" 74 | hasRestartCondition := false 75 | cmdSuffix := "" 76 | for _, v := range data.Args { 77 | if strings.HasPrefix(v, "--restart-condition") { 78 | if strings.Contains(v, "any") { 79 | return fmt.Errorf("--restart-condition cannot be set to any") 80 | } 81 | hasRestartCondition = true 82 | } else if strings.HasPrefix(v, "--name") { 83 | return fmt.Errorf("--name argument is not allowed") 84 | } 85 | cmdSuffix = fmt.Sprintf("%s %s", cmdSuffix, v) 86 | } 87 | if !hasRestartCondition { 88 | cmdSuffix = fmt.Sprintf("%s --restart-condition none", cmdSuffix) 89 | } 90 | cmdSuffix = fmt.Sprintf("%s %s %s", cmdSuffix, data.Image, data.Command) 91 | cmdLabel := fmt.Sprintf( 92 | `-l 'com.df.cron.command=%s%s'`, 93 | cmdPrefix, 94 | cmdSuffix, 95 | ) 96 | cmd := fmt.Sprintf( 97 | `%s -l "com.df.cron=true" -l "com.df.cron.name=%s" -l "com.df.cron.schedule=%s" --name %s --replicas 0 %s %s`, 98 | cmdPrefix, 99 | data.Name, 100 | data.Schedule, 101 | serviceName, 102 | cmdLabel, 103 | strings.Trim(cmdSuffix, " "), 104 | ) 105 | 106 | fmt.Println("Executing command:", cmd) 107 | 108 | _, err := exec.Command("/bin/sh", "-c", cmd).CombinedOutput() 109 | if err != nil { // TODO: Test 110 | fmt.Println("Could not execute command: ", cmd, err.Error()) 111 | } 112 | } 113 | 114 | cronCmd := func() { 115 | scale := fmt.Sprintf(`docker service scale %s=1`, serviceName) 116 | fmt.Println(scale) 117 | _, err := exec.Command("/bin/sh", "-c", scale).CombinedOutput() 118 | if err != nil { // TODO: Test 119 | fmt.Println("Could not execute command: ", scale) 120 | } 121 | } 122 | entryId, err := rCronAddFunc(c.Cron, data.Schedule, cronCmd) 123 | c.Jobs[data.Name] = entryId 124 | return err 125 | } 126 | 127 | func (c *Cron) GetJobs() (map[string]JobData, error) { 128 | jobs := map[string]JobData{} 129 | services, err := c.Service.GetServices("") 130 | if err != nil { 131 | return jobs, err 132 | } 133 | for _, service := range services { 134 | command := "" 135 | for _, v := range service.Spec.TaskTemplate.ContainerSpec.Args { 136 | if strings.Contains(v, " ") { 137 | command = fmt.Sprintf(`%s "%s"`, command, v) 138 | } else { 139 | command = fmt.Sprintf(`%s %s`, command, v) 140 | } 141 | } 142 | name := service.Spec.Annotations.Labels["com.df.cron.name"] 143 | jobs[name] = c.getJob(service) 144 | } 145 | return jobs, nil 146 | } 147 | 148 | func (c *Cron) RemoveJob(jobName string) error { 149 | fmt.Println("Removing job", jobName) 150 | c.Cron.Remove(c.Jobs[jobName]) 151 | if err := c.Service.RemoveServices(jobName); err != nil { 152 | return err 153 | } 154 | return nil 155 | } 156 | 157 | func (c *Cron) RescheduleJobs() error { 158 | fmt.Println("Rescheduling jobs") 159 | jobs, err := c.GetJobs() 160 | if err != nil { 161 | return err 162 | } 163 | for _, job := range jobs { 164 | c.AddJob(job) 165 | } 166 | c.Cron.Start() 167 | return nil 168 | } 169 | 170 | func (c *Cron) Stop() { 171 | c.Cron.Stop() 172 | } 173 | 174 | func (c *Cron) getJob(service swarm.Service) JobData { 175 | command := "" 176 | for _, v := range service.Spec.TaskTemplate.ContainerSpec.Args { 177 | if strings.Contains(v, " ") { 178 | command = fmt.Sprintf(`%s "%s"`, command, v) 179 | } else { 180 | command = fmt.Sprintf(`%s %s`, command, v) 181 | } 182 | } 183 | name := service.Spec.Annotations.Labels["com.df.cron.name"] 184 | return JobData{ 185 | Name: name, 186 | ServiceName: service.Spec.Name, 187 | Image: service.Spec.TaskTemplate.ContainerSpec.Image, 188 | Command: service.Spec.Annotations.Labels["com.df.cron.command"], 189 | Schedule: service.Spec.Annotations.Labels["com.df.cron.schedule"], 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /cron/cron_test.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | "github.com/docker/docker/api/types/swarm" 11 | "github.com/stretchr/testify/suite" 12 | rcron "gopkg.in/robfig/cron.v2" 13 | ) 14 | 15 | type CronTestSuite struct { 16 | suite.Suite 17 | Service ServicerMock 18 | } 19 | 20 | func (s *CronTestSuite) SetupTest() { 21 | s.Service = ServicerMock{ 22 | GetServicesMock: func(jobName string) ([]swarm.Service, error) { 23 | return []swarm.Service{}, nil 24 | }, 25 | GetTasksMock: func(jobName string) ([]swarm.Task, error) { 26 | return []swarm.Task{}, nil 27 | }, 28 | } 29 | } 30 | 31 | func TestCronUnitTestSuite(t *testing.T) { 32 | s := new(CronTestSuite) 33 | suite.Run(t, s) 34 | time.Sleep(1 * time.Second) 35 | s.removeAllServices() 36 | } 37 | 38 | // New 39 | 40 | func (s *CronTestSuite) Test_New_ReturnsError_WhenDockerClientFails() { 41 | _, err := New("this-is-not-a-socket") 42 | 43 | s.Error(err) 44 | } 45 | 46 | // AddJob 47 | 48 | func (s CronTestSuite) Test_AddJob_InvokesRCronAddFuncWithSpec() { 49 | rCronAddFuncOrig := rCronAddFunc 50 | defer func() { rCronAddFunc = rCronAddFuncOrig }() 51 | actualSpec := "" 52 | rCronAddFunc = func(c *rcron.Cron, spec string, cmd func()) (rcron.EntryID, error) { 53 | actualSpec = spec 54 | return 0, nil 55 | } 56 | data := JobData{Image: "my-image", Name: "my-job", Schedule: "@yearly"} 57 | c := Cron{ 58 | Cron: rcron.New(), 59 | Jobs: map[string]rcron.EntryID{}, 60 | } 61 | 62 | c.AddJob(data) 63 | 64 | s.Equal(data.Schedule, actualSpec) 65 | } 66 | 67 | func (s CronTestSuite) Test_AddJob_CreatesService() { 68 | data := JobData{ 69 | Name: "my-job", 70 | Image: "alpine", 71 | Command: `echo "Hello Cron!"`, 72 | } 73 | 74 | c := s.addJob1s(data) 75 | defer func() { 76 | c.Stop() 77 | c.RemoveJob("my-job") 78 | }() 79 | 80 | s.verifyServicesAreCreated("my-job", 1) 81 | } 82 | 83 | func (s CronTestSuite) Test_AddJob_ThrowsAnError_WhenRestartConditionIsSetToAny() { 84 | data := JobData{ 85 | Name: "my-job", 86 | Image: "alpine", 87 | Schedule: "@yearly", 88 | Args: []string{"--restart-condition any"}, 89 | Command: `echo "Hello Cron!"`, 90 | } 91 | c, _ := New("unix:///var/run/docker.sock") 92 | 93 | err := c.AddJob(data) 94 | 95 | s.Error(err) 96 | } 97 | 98 | func (s CronTestSuite) Test_AddJob_AddsRestartConditionNone_WhenNotSet() { 99 | data := JobData{ 100 | Name: "my-job", 101 | Image: "alpine", 102 | Args: []string{}, 103 | Command: `echo "Hello Cron!"`, 104 | } 105 | 106 | c := s.addJob1s(data) 107 | defer func() { 108 | c.Stop() 109 | c.RemoveJob("my-job") 110 | }() 111 | 112 | counter := 0 113 | for { 114 | id, _ := exec.Command("/bin/sh", "-c", `docker service ls -q -f label=com.df.cron=true`).CombinedOutput() 115 | idString := strings.Trim(string(id), "\n") 116 | if len(id) > 0 { 117 | out, _ := exec.Command("/bin/sh", "-c", `docker service inspect `+idString).CombinedOutput() 118 | s.Contains(string(out), `"Condition": "none",`) 119 | break 120 | } 121 | counter++ 122 | if counter >= 100 { 123 | s.Fail("Service was not created") 124 | break 125 | } 126 | time.Sleep(100 * time.Millisecond) 127 | } 128 | 129 | } 130 | 131 | func (s CronTestSuite) Test_AddJob_ThrowsAnError_WhenNameArgumentIsSet() { 132 | data := JobData{ 133 | Name: "my-job", 134 | Image: "alpine", 135 | Schedule: "@yearly", 136 | Args: []string{"--name some-name"}, 137 | Command: `echo "Hello Cron!"`, 138 | } 139 | c, _ := New("unix:///var/run/docker.sock") 140 | 141 | err := c.AddJob(data) 142 | 143 | s.Error(err) 144 | } 145 | 146 | func (s CronTestSuite) Test_AddJob_AddsCommandLabel() { 147 | data := JobData{ 148 | Name: "my-job", 149 | Image: "alpine", 150 | Args: []string{}, 151 | Command: `echo "Hello Cron!"`, 152 | } 153 | c := s.addJob1s(data) 154 | defer func() { 155 | c.Stop() 156 | c.RemoveJob("my-job") 157 | }() 158 | 159 | counter := 0 160 | for { 161 | id, _ := exec.Command("/bin/sh", "-c", `docker service ls -q -f label=com.df.cron=true`).CombinedOutput() 162 | idString := strings.Trim(string(id), "\n") 163 | if len(id) > 0 { 164 | out, _ := exec.Command("/bin/sh", "-c", `docker service inspect `+idString).CombinedOutput() 165 | s.Contains( 166 | string(out), 167 | `"com.df.cron.command": "docker service create --restart-condition none alpine echo \"Hello Cron!\""`, 168 | ) 169 | break 170 | } 171 | counter++ 172 | if counter >= 100 { 173 | s.Fail("Service was not created") 174 | break 175 | } 176 | time.Sleep(100 * time.Millisecond) 177 | } 178 | 179 | } 180 | 181 | func (s CronTestSuite) Test_AddJob_ThrowsAnError_WhenImageIsEmpty() { 182 | data := JobData{ 183 | Name: "my-job", 184 | Image: "", 185 | Schedule: "@yearly", 186 | Command: `echo "Hello Cron!"`, 187 | } 188 | c, _ := New("unix:///var/run/docker.sock") 189 | 190 | err := c.AddJob(data) 191 | 192 | s.Error(err) 193 | } 194 | 195 | func (s CronTestSuite) Test_AddJob_ThrowsAnError_WhenNameIsEmpty() { 196 | data := JobData{ 197 | Image: "my-image", 198 | Schedule: "@yearly", 199 | Command: `echo "Hello Cron!"`, 200 | } 201 | c, _ := New("unix:///var/run/docker.sock") 202 | 203 | err := c.AddJob(data) 204 | 205 | s.Error(err) 206 | } 207 | 208 | // GetJobs 209 | 210 | func (s CronTestSuite) Test_GetJobs_ReturnsListOfJobs() { 211 | expected := map[string]JobData{} 212 | for i := 1; i <= 3; i++ { 213 | name := fmt.Sprintf("my-job-%d", i) 214 | cmd := fmt.Sprintf( 215 | `docker service create \ 216 | -l 'com.df.cron=true' \ 217 | -l 'com.df.cron.name=%s' \ 218 | -l 'com.df.cron.schedule=@every 1s' \ 219 | -l 'com.df.cron.command=docker service create --restart-condition none alpine echo "Hello World!"' \ 220 | --constraint "node.labels.env != does-not-exist" \ 221 | --container-label 'container=label' \ 222 | --restart-condition none \ 223 | --name %s \ 224 | alpine:3.5@sha256:dfbd4a3a8ebca874ebd2474f044a0b33600d4523d03b0df76e5c5986cb02d7e8 \ 225 | echo "Hello world!"`, 226 | name, 227 | name, 228 | ) 229 | exec.Command("/bin/sh", "-c", cmd).CombinedOutput() 230 | expected[name] = JobData{ 231 | Name: name, 232 | ServiceName: name, 233 | Image: "alpine:3.5@sha256:dfbd4a3a8ebca874ebd2474f044a0b33600d4523d03b0df76e5c5986cb02d7e8", 234 | Command: `docker service create --restart-condition none alpine echo "Hello World!"`, 235 | Schedule: "@every 1s", 236 | } 237 | } 238 | 239 | c, _ := New("unix:///var/run/docker.sock") 240 | 241 | c.RemoveJob("my-job") 242 | 243 | actual, _ := c.GetJobs() 244 | defer func() { 245 | c.Stop() 246 | c.RemoveJob("my-job-1") 247 | c.RemoveJob("my-job-2") 248 | c.RemoveJob("my-job-3") 249 | }() 250 | 251 | s.Equal(expected, actual) 252 | } 253 | 254 | func (s *CronTestSuite) Test_GetJobs_ReturnsError_WhenGetServicesFail() { 255 | message := "This is an error" 256 | mock := ServicerMock{ 257 | GetServicesMock: func(jobName string) ([]swarm.Service, error) { 258 | return []swarm.Service{}, fmt.Errorf(message) 259 | }, 260 | } 261 | 262 | c := Cron{Cron: rcron.New(), Service: mock} 263 | _, err := c.GetJobs() 264 | 265 | s.Error(err) 266 | } 267 | 268 | // RemoveJob 269 | 270 | func (s CronTestSuite) Test_RemoveJob_RemovesService() { 271 | data := JobData{ 272 | Name: "my-job", 273 | Image: "alpine", 274 | Command: `echo "Hello Cron!"`, 275 | Schedule: "@every 1s", 276 | } 277 | 278 | c, _ := New("unix:///var/run/docker.sock") 279 | defer func() { 280 | c.Stop() 281 | c.RemoveJob("my-job") 282 | }() 283 | c.AddJob(data) 284 | s.verifyServicesAreCreated("my-job", 1) 285 | 286 | c.RemoveJob("my-job") 287 | 288 | counter := 0 289 | for { 290 | count := s.getServiceCount("my-job") 291 | if count == 0 { 292 | break 293 | } 294 | counter++ 295 | if counter >= 50 { 296 | s.Fail("Services were not removed") 297 | break 298 | } 299 | time.Sleep(100 * time.Millisecond) 300 | } 301 | } 302 | 303 | func (s CronTestSuite) Test_RemoveJob_DoesNotRemoveOtherServices() { 304 | data := JobData{ 305 | Name: "my-job", 306 | Image: "alpine", 307 | Command: `echo "Hello Cron!"`, 308 | Schedule: "@every 1s", 309 | } 310 | 311 | c, _ := New("unix:///var/run/docker.sock") 312 | defer func() { 313 | c.Stop() 314 | c.RemoveJob("my-job") 315 | c.RemoveJob("my-job-2") 316 | }() 317 | c.AddJob(data) 318 | data.Name = "my-job-2" 319 | c.AddJob(data) 320 | 321 | s.verifyServicesAreCreated("my-job", 2) 322 | 323 | before := s.getServiceCount("my-job") 324 | c.RemoveJob("my-job") 325 | 326 | counter := 0 327 | for { 328 | after := s.getServiceCount("my-job") 329 | if after == (before - 1) { 330 | break 331 | } 332 | counter++ 333 | if counter >= 50 { 334 | s.Fail(fmt.Sprintf("Found %d services. The number should be bigger then %d.", after, before)) 335 | break 336 | } 337 | time.Sleep(100 * time.Millisecond) 338 | } 339 | } 340 | 341 | func (s CronTestSuite) Test_RemoveJob_ReturnsError_WhenRemoveServicesFail() { 342 | mock := ServicerMock{ 343 | RemoveServicesMock: func(jobName string) error { 344 | return fmt.Errorf("This is an error") 345 | }, 346 | GetServicesMock: func(jobName string) ([]swarm.Service, error) { 347 | return []swarm.Service{}, nil 348 | }, 349 | } 350 | c := Cron{Cron: rcron.New(), Service: mock} 351 | 352 | err := c.RemoveJob("my-job") 353 | 354 | s.Error(err) 355 | } 356 | 357 | // RescheduleJobs 358 | 359 | func (s CronTestSuite) Test_RescheduleJobs_AddsAllJobs() { 360 | data := JobData{ 361 | Name: "my-job", 362 | Image: "alpine", 363 | Command: `echo "Hello Cron!"`, 364 | Schedule: "@every 1s", 365 | } 366 | 367 | c, _ := New("unix:///var/run/docker.sock") 368 | defer func() { 369 | c.Stop() 370 | c.RemoveJob("my-job") 371 | }() 372 | c.AddJob(data) 373 | for { 374 | if s.getServiceCount("my-job") > 0 { 375 | break 376 | } 377 | } 378 | s.verifyServicesAreCreated("my-job", 1) 379 | c.Stop() 380 | 381 | c.RescheduleJobs() 382 | 383 | s.verifyServicesAreCreated("my-job", 1) 384 | } 385 | 386 | func (s CronTestSuite) Test_RescheduleJobs_ReturnsError_WhenGetServicesFail() { 387 | mock := ServicerMock{ 388 | GetServicesMock: func(jobName string) ([]swarm.Service, error) { 389 | return []swarm.Service{}, fmt.Errorf("This is an error") 390 | }, 391 | } 392 | c := Cron{Cron: rcron.New(), Service: mock} 393 | 394 | err := c.RescheduleJobs() 395 | 396 | s.Error(err) 397 | } 398 | 399 | // Util 400 | 401 | func (s CronTestSuite) getServiceCount(jobName string) int { 402 | command := fmt.Sprintf( 403 | `docker service ls -f label=com.df.cron=true -f "label=com.df.cron.name=" | grep %s | awk '{print $1}'`, 404 | jobName, 405 | ) 406 | out, _ := exec.Command( 407 | "/bin/sh", 408 | "-c", 409 | command, 410 | ).CombinedOutput() 411 | servicesString := strings.TrimRight(string(out), "\n") 412 | if len(servicesString) > 0 { 413 | return len(strings.Split(servicesString, "\n")) 414 | } else { 415 | return 0 416 | } 417 | } 418 | 419 | func (s CronTestSuite) verifyServicesAreCreated(serviceName string, replicas int) { 420 | counter := 0 421 | for { 422 | count := s.getServiceCount(serviceName) 423 | if count >= replicas { 424 | break 425 | } 426 | counter++ 427 | if counter >= 50 { 428 | s.Fail("Services were not created") 429 | break 430 | } 431 | time.Sleep(100 * time.Millisecond) 432 | } 433 | } 434 | 435 | func (s CronTestSuite) addJob1s(d JobData) Croner { 436 | c, _ := New("unix:///var/run/docker.sock") 437 | d.Schedule = "@every 1s" 438 | c.AddJob(d) 439 | return c 440 | } 441 | 442 | func (s CronTestSuite) removeAllServices() { 443 | exec.Command( 444 | "/bin/sh", 445 | "-c", 446 | `docker service rm $(docker service ls -q -f label=com.df.cron=true)`, 447 | ).CombinedOutput() 448 | } 449 | 450 | type ServicerMock struct { 451 | GetServicesMock func(jobName string) ([]swarm.Service, error) 452 | GetTasksMock func(jobName string) ([]swarm.Task, error) 453 | RemoveServicesMock func(jobName string) error 454 | } 455 | 456 | func (m ServicerMock) GetServices(jobName string) ([]swarm.Service, error) { 457 | return m.GetServicesMock(jobName) 458 | } 459 | 460 | func (m ServicerMock) GetTasks(jobName string) ([]swarm.Task, error) { 461 | return m.GetTasksMock(jobName) 462 | } 463 | 464 | func (m ServicerMock) RemoveServices(jobName string) error { 465 | return m.RemoveServicesMock(jobName) 466 | } 467 | -------------------------------------------------------------------------------- /docker-compose-test.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | 4 | services: 5 | 6 | unit: 7 | image: vfarcic/docker-flow-cron-test 8 | volumes: 9 | - /var/run/docker.sock:/var/run/docker.sock 10 | working_dir: /src 11 | command: bash -c "go get -d -v -t ./... && go test --cover ./... -p=1 && go build -v -o docker-flow-cron" 12 | 13 | docs: 14 | image: cilerler/mkdocs 15 | volumes: 16 | - .:/docs 17 | command: bash -c "pip install pygments && pip install pymdown-extensions && mkdocs build" 18 | -------------------------------------------------------------------------------- /docker-compose.example.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | 5 | cron: 6 | image: vfarcic/docker-flow-cron 7 | networks: 8 | - cron 9 | volumes: 10 | - /var/run/docker.sock:/var/run/docker.sock 11 | ports: 12 | - ${PORT:-8080}:8080 13 | deploy: 14 | placement: 15 | constraints: [node.role == manager] 16 | 17 | swarm-listener: 18 | image: vfarcic/docker-flow-swarm-listener 19 | networks: 20 | - cron 21 | volumes: 22 | - /var/run/docker.sock:/var/run/docker.sock 23 | environment: 24 | - DF_NOTIFY_CREATE_SERVICE_URL=http://cron:8080/v1/docker-flow-cron/job/create 25 | - DF_NOTIFY_REMOVE_SERVICE_URL=http://cron:8080/v1/docker-flow-cron/job/remove 26 | deploy: 27 | placement: 28 | constraints: [node.role == manager] 29 | 30 | cronjob: 31 | image: alpine 32 | command: echo hello world 33 | depends_on: 34 | - swarm-listener 35 | - cron 36 | networks: 37 | - cron 38 | deploy: 39 | replicas: 0 40 | restart_policy: 41 | condition: none 42 | labels: 43 | - com.df.notify=true 44 | - com.df.cron=true 45 | - com.df.cron.name=cron_cronjob 46 | - com.df.cron.image=alpine 47 | - com.df.cron.command=echo hello world 48 | - com.df.cron.schedule=@every 10s 49 | networks: 50 | cron: 51 | external: true -------------------------------------------------------------------------------- /docker/service.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "fmt" 5 | "github.com/docker/docker/api/types" 6 | "github.com/docker/docker/api/types/filters" 7 | "github.com/docker/docker/api/types/swarm" 8 | "github.com/docker/docker/client" 9 | "golang.org/x/net/context" 10 | ) 11 | 12 | const dockerApiVersion = "v1.24" 13 | 14 | type Servicer interface { 15 | GetServices(jobName string) ([]swarm.Service, error) 16 | GetTasks(jobName string) ([]swarm.Task, error) 17 | RemoveServices(jobName string) error 18 | } 19 | 20 | type Service struct { 21 | Client *client.Client 22 | } 23 | 24 | func New(host string) (*Service, error) { 25 | defaultHeaders := map[string]string{"User-Agent": "engine-api-cli-1.0"} 26 | c, err := client.NewClient(host, dockerApiVersion, nil, defaultHeaders) 27 | if err != nil { 28 | return &Service{}, err 29 | } 30 | return &Service{Client: c}, nil 31 | } 32 | 33 | func (s *Service) GetServices(jobName string) ([]swarm.Service, error) { 34 | filter := filters.NewArgs() 35 | filter.Add("label", "com.df.cron=true") 36 | if len(jobName) > 0 { 37 | filter.Add("label", fmt.Sprintf("com.df.cron.name=%s", jobName)) 38 | } 39 | services, err := s.Client.ServiceList(context.Background(), types.ServiceListOptions{Filters: filter}) 40 | if err != nil { 41 | return []swarm.Service{}, err 42 | } 43 | return services, nil 44 | } 45 | 46 | func (s *Service) GetTasks(jobName string) ([]swarm.Task, error) { 47 | filter := filters.NewArgs() 48 | filter.Add("label", "com.df.cron=true") 49 | filter.Add("label", fmt.Sprintf("com.df.cron.name=%s", jobName)) 50 | tasks, err := s.Client.TaskList(context.Background(), types.TaskListOptions{Filters: filter}) 51 | if err != nil { 52 | return []swarm.Task{}, err 53 | } 54 | return tasks, nil 55 | } 56 | 57 | func (s *Service) RemoveServices(jobName string) error { 58 | services, err := s.GetServices(jobName) 59 | if err != nil { 60 | return err 61 | } 62 | for _, service := range services { 63 | s.Client.ServiceRemove(context.Background(), service.Spec.Name) 64 | } 65 | return nil 66 | } 67 | -------------------------------------------------------------------------------- /docker/service_test.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/suite" 10 | ) 11 | 12 | type ServiceTestSuite struct { 13 | suite.Suite 14 | } 15 | 16 | func (s *ServiceTestSuite) SetupTest() { 17 | } 18 | 19 | func TestServiceUnitTestSuite(t *testing.T) { 20 | s := new(ServiceTestSuite) 21 | suite.Run(t, s) 22 | } 23 | 24 | // New 25 | 26 | func (s *ServiceTestSuite) Test_New_ReturnsError_WhenClientFails() { 27 | expected := "this-is-a-host" 28 | 29 | _, err := New(expected) 30 | 31 | s.Error(err) 32 | } 33 | 34 | func (s *ServiceTestSuite) Test_New_SetsClient() { 35 | expected := "unix:///var/run/docker.sock" 36 | 37 | srv, _ := New(expected) 38 | 39 | s.NotNil(srv.Client) 40 | } 41 | 42 | // GetServices 43 | 44 | func (s *ServiceTestSuite) Test_GetServices_ReturnsServices() { 45 | defer s.removeAllServices() 46 | s.createTestService("util-1", "-l com.df.cron=true") 47 | s.createTestService("util-2", "-l com.df.cron.test=true") 48 | services, _ := New("unix:///var/run/docker.sock") 49 | 50 | actual, _ := services.GetServices("") 51 | 52 | if len(actual) == 0 { 53 | s.Fail("No services found") 54 | } else { 55 | s.Equal(1, len(actual)) 56 | s.Equal("util-1", actual[0].Spec.Name) 57 | s.Equal("true", actual[0].Spec.Labels["com.df.cron"]) 58 | } 59 | } 60 | 61 | func (s *ServiceTestSuite) Test_GetServices_ReturnsError_WhenServiceListFails() { 62 | services, _ := New("unix:///this/socket/does/not/exist") 63 | 64 | _, err := services.GetServices("") 65 | 66 | s.Error(err) 67 | } 68 | 69 | func (s *ServiceTestSuite) Test_GetServices_ReturnsFilteredServices() { 70 | s.removeAllServices() 71 | defer s.removeAllServices() 72 | s.createTestService("util-3", "-l com.df.cron.name=my-job -l com.df.cron=true") 73 | s.createTestService("util-4", "-l com.df.cron.name=my-job -l com.df.cron=true") 74 | s.createTestService("util-5", "-l com.df.cron.name=some-other-job -l com.df.cron=true") 75 | services, _ := New("unix:///var/run/docker.sock") 76 | 77 | actual, _ := services.GetServices("my-job") 78 | 79 | if len(actual) == 0 { 80 | s.Fail("No services found") 81 | } else { 82 | s.Equal(2, len(actual)) 83 | } 84 | } 85 | 86 | // GetTasks 87 | 88 | func (s *ServiceTestSuite) Test_GetTasks_ReturnsFilteredTasks() { 89 | defer s.removeAllServices() 90 | s.createTestService("util-6", "-l com.df.cron.name=my-job -l com.df.cron=true") 91 | s.createTestService("util-7", "-l com.df.cron.name=my-job -l com.df.cron=true") 92 | s.createTestService("util-8", "-l com.df.cron.name=some-other-job -l com.df.cron=true") 93 | services, _ := New("unix:///var/run/docker.sock") 94 | actual := 0 95 | 96 | for i := 0; i < 100; i++ { 97 | t, _ := services.GetTasks("my-job") 98 | actual = len(t) 99 | if actual == 2 { 100 | break 101 | } 102 | time.Sleep(100 * time.Millisecond) 103 | } 104 | 105 | s.Equal(2, actual) 106 | } 107 | 108 | func (s *ServiceTestSuite) Test_GetTasks_ReturnsError_WhenServiceListFails() { 109 | services, _ := New("unix:///this/socket/does/not/exist") 110 | 111 | _, err := services.GetTasks("some-job") 112 | 113 | s.Error(err) 114 | } 115 | 116 | // RemoveServices 117 | 118 | func (s *ServiceTestSuite) Test_RemoveServices_RemovesAllServicesRelatedToACronJob() { 119 | defer s.removeAllServices() 120 | s.createTestService("util-9", "-l com.df.cron.name=my-job -l com.df.cron=true") 121 | s.createTestService("util-10", "-l com.df.cron.name=my-job -l com.df.cron=true") 122 | s.createTestService("util-11", "-l com.df.cron.name=some-other-job -l com.df.cron=true") 123 | services, _ := New("unix:///var/run/docker.sock") 124 | 125 | services.RemoveServices("my-job") 126 | 127 | myJobServices, _ := services.GetServices("my-job") 128 | s.Equal(0, len(myJobServices)) 129 | 130 | someOtherJobServices, _ := services.GetServices("some-other-job") 131 | s.Equal(1, len(someOtherJobServices)) 132 | } 133 | 134 | func (s *ServiceTestSuite) Test_RemoveServices_ReturnsAnError_WhenClientFails() { 135 | defer s.removeAllServices() 136 | services, _ := New("unix:///this/socket/does/not/exist") 137 | 138 | err := services.RemoveServices("my-job") 139 | 140 | s.Error(err) 141 | } 142 | 143 | // Util 144 | 145 | func (s *ServiceTestSuite) createTestService(name, args string) { 146 | cmd := fmt.Sprintf( 147 | `docker service create --name %s --restart-condition none %s alpine sleep 10000000`, 148 | name, 149 | args, 150 | ) 151 | exec.Command("/bin/sh", "-c", cmd).CombinedOutput() 152 | } 153 | 154 | func (s *ServiceTestSuite) removeAllServices() { 155 | exec.Command( 156 | "/bin/sh", 157 | "-c", 158 | `docker service rm $(docker service ls -q -f label=com.df.cron=true)`, 159 | ).CombinedOutput() 160 | exec.Command( 161 | "/bin/sh", 162 | "-c", 163 | `docker service rm $(docker service ls -q -f label=com.df.cron.test=true)`, 164 | ).CombinedOutput() 165 | } 166 | -------------------------------------------------------------------------------- /docs/docker-jobs.md: -------------------------------------------------------------------------------- 1 | ```bash 2 | # TODO: Write text 3 | 4 | docker container run -it --rm \ 5 | -v /var/run/docker.sock:/var/run/docker.sock \ 6 | docker docker image prune -f 7 | 8 | docker service create --name cron-prune-images \ 9 | --mount "type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock" \ 10 | docker docker image prune -f 11 | 12 | docker service ps cron-prune-images 13 | 14 | docker service logs -t cron-prune-images 15 | 16 | docker service rm cron-prune-images 17 | 18 | docker service create --name cron-prune-images \ 19 | --mount "type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock" \ 20 | --restart-delay 10s \ 21 | docker docker image prune -f 22 | 23 | docker service ps cron-prune-images 24 | 25 | docker service logs -t cron-prune-images 26 | 27 | docker service rm cron-prune-images 28 | 29 | docker service create --name cron-prune-images \ 30 | --mount "type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock" \ 31 | --restart-delay 10s \ 32 | --mode global \ 33 | docker docker image prune -f 34 | 35 | docker service ps cron-prune-images 36 | 37 | docker service logs -t cron-prune-images 38 | 39 | docker service rm cron-prune-images 40 | 41 | # NOTE: No flexibility expressing the desired time. 42 | 43 | # NOTE: No feature to restart imediatelly on failure. 44 | 45 | # NOTE: What is missing? 46 | ``` -------------------------------------------------------------------------------- /docs/feedback-and-contribution.md: -------------------------------------------------------------------------------- 1 | # Feedback and Contribution 2 | 3 | TODO: Review 4 | 5 | The *Docker Flow: Proxy* project welcomes, and depends, on contributions from developers and users in the open source community. Contributions can be made in a number of ways, a few examples are: 6 | 7 | * Code patches or new features via pull requests 8 | * Documentation improvements 9 | * Bug reports and patch reviews 10 | 11 | ## Reporting an Issue 12 | 13 | Feel fee to [create a new issue](https://github.com/vfarcic/docker-flow-cron/issues). Include as much detail as you can. 14 | 15 | If an issue is a bug, please provide steps to reproduce it. 16 | 17 | If an issue is a request for a new feature, please specify the use-case behind it. 18 | 19 | ## Discussion 20 | 21 | Please join the [DevOps20](http://slack.devops20toolkit.com/) Slack channel if you'd like to discuss the project or have a problem you'd like us to solve. 22 | 23 | ## Contributing To The Project 24 | 25 | I encourage you to contribute to the *Docker Flow Cron* project. 26 | 27 | The project is developed using *Test Driven Development* and *Continuous Deployment* process. Test are divided into unit and integration tests. Every code file has an equivalent with tests (e.g. `reconfigure.go` and `reconfigure_test.go`). Ideally, I expect you to write a test that defines that should be developed, run all the unit tests and confirm that the test fails, write just enough code to make the test pass, repeat. If you are new to testing, feel free to create a pull request indicating that tests are missing and I'll help you out. 28 | 29 | Once you are finish implementing a new feature or fixing a bug, run the *Complete Cycle*. You'll find the instructions below. 30 | 31 | ### Repository 32 | 33 | Fork [docker-flow-cron](https://github.com/vfarcic/docker-flow-cron). 34 | 35 | TODO: Remove 36 | 37 | ```bash 38 | go build -v -o docker-flow-cron && ./docker-flow-cron 39 | ``` 40 | 41 | ### Unit Testing 42 | 43 | ```bash 44 | docker-compose -f docker-compose-test.yml run --rm unit 45 | ``` 46 | 47 | ### Building 48 | 49 | ```bash 50 | docker-compose -f docker-compose-test.yml run --rm unit 51 | 52 | docker build -t $DOCKER_HUB_USER/docker-flow-cron . 53 | ``` 54 | 55 | ### The Complete Cycle (Unit, Build, Staging) 56 | 57 | TODO 58 | 59 | ### Pull Request 60 | 61 | TODO 62 | 63 | Once the feature is done, create a pull request. -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Running Docker Flow Cron In a Swarm Cluster 2 | 3 | TODO: Review 4 | 5 | Docker Swarm services are designed to the long lasting processes that, potentially, live forever. Docker does not have a mechanism to schedule jobs based on time interval or, to put it in other words, it does not have the ability to use cron-like syntax for time-based scheduling. 6 | 7 | *Docker Flow Cron* is designed overcome some of the limitations behind Docker Swarm services and provide cron-like time-based scheduling while maintaining fault tolerance features available in Docker Swarm. 8 | -------------------------------------------------------------------------------- /docs/license.md: -------------------------------------------------------------------------------- 1 | # Docker Flow Cron License (MIT) 2 | 3 | Copyright (c) 2017 Viktor Farcic 4 | 5 | The MIT License (MIT) 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. -------------------------------------------------------------------------------- /docs/release-notes.md: -------------------------------------------------------------------------------- 1 | # Release Notes 2 | 3 | Please visit [project releases](https://github.com/vfarcic/docker-flow-cron/releases). -------------------------------------------------------------------------------- /docs/requirements.md: -------------------------------------------------------------------------------- 1 | # Docker Flow Cron Requirements 2 | 3 | The following set of items are required for *Docker Flow Cron*. 4 | 5 | ## A Cluster Running in Docker Swarm Mode 6 | 7 | *Docker Flow Cron* creates Docker Swarm services for each scheduled job execution. 8 | 9 | ## SH 10 | 11 | Operating system used to host Docker Swarm must be able to run `/bin/sh` commands. 12 | 13 | ## The service running on one of the Swarm manager nodes 14 | 15 | *Docker Flow Cron* requires interaction with one of the Swarm managers to schedule services that run as scheduled jobs. 16 | -------------------------------------------------------------------------------- /docs/swagger.yml: -------------------------------------------------------------------------------- 1 | swagger: '2.0' 2 | info: 3 | title: Docker Flow Cron API 4 | description: TODO 5 | version: "TODO" 6 | host: cron.dockerflow.com 7 | schemes: 8 | - http 9 | basePath: /v1/docker-flow-cron 10 | produces: 11 | - application/json 12 | paths: 13 | /job/{jobName}: 14 | put: 15 | summary: Reconfigures the proxy 16 | description: | 17 | TODO 18 | parameters: 19 | - name: jobName 20 | in: path 21 | description: The name of the job. 22 | required: true 23 | type: string 24 | - name: Job 25 | in: body 26 | description: Job definition 27 | required: true 28 | schema: 29 | $ref: '#/definitions/Job' 30 | responses: 31 | 200: 32 | description: An array of products 33 | schema: 34 | type: array 35 | items: 36 | $ref: '#/definitions/Job' 37 | default: 38 | description: Unexpected error 39 | schema: 40 | $ref: '#/definitions/Error' 41 | definitions: 42 | Job: 43 | type: object 44 | properties: 45 | image: 46 | type: string 47 | description: Docker image 48 | command: 49 | type: string 50 | description: The command that will be executed when a job is created. 51 | schedule: 52 | type: string 53 | description: The schedule that defines the frequency of the job execution. TODO Link to a reference from https://godoc.org/github.com/robfig/cron 54 | args: 55 | type: array 56 | items: 57 | type: string 58 | description: The list of arguments that can be used with the `docker service create` command. `--restart-condition` cannot be set to `any`. If not specified, it will be set to `none`. `--name` argument is not allowed. Any other argument supported by Docker service is allowed. 59 | Error: 60 | type: object 61 | properties: 62 | code: 63 | type: integer 64 | format: int32 65 | message: 66 | type: string 67 | fields: 68 | type: string 69 | -------------------------------------------------------------------------------- /docs/tutorial.md: -------------------------------------------------------------------------------- 1 | # Examples of Running Docker Flow Cron In a Swarm Cluster 2 | 3 | The examples that follow assume that you already have a Swarm cluster and that you are logged into one of the managers. 4 | 5 | ## Creating Jobs 6 | 7 | We'll start by downloading a stack that fill deploy the `docker-flow-cron` service. 8 | 9 | ```bash 10 | curl -o cron.yml \ 11 | https://raw.githubusercontent.com/vfarcic/docker-flow-cron/master/stack.yml 12 | ``` 13 | 14 | The definition of the stack is as follows. 15 | 16 | ``` 17 | version: "3" 18 | 19 | services: 20 | 21 | main: 22 | image: vfarcic/docker-flow-cron 23 | volumes: 24 | - /var/run/docker.sock:/var/run/docker.sock 25 | ports: 26 | - ${PORT:-8080}:8080 27 | deploy: 28 | placement: 29 | constraints: [node.role == manager] 30 | ``` 31 | 32 | As you can see, it is a very simple stack. It contains a single service. It mounts `/var/run/docker.sock` as a volume. The `cron` will use it for communication with Docker Engine. The internal port `8080` will be exposed as `8080` on the host unless the environment variable `PORT` is specified. Finally, we're using a constraint that will limit the `cron` to one of the manager nodes. 33 | 34 | Let us deploy the stack. 35 | 36 | ```bash 37 | docker stack deploy -c cron.yml cron 38 | ``` 39 | 40 | A few moments later, the service will be up and running. We can confirm that with the `stack ps` command. 41 | 42 | ```bash 43 | docker stack ps cron 44 | ``` 45 | 46 | The output is as follows. 47 | 48 | ``` 49 | ID NAME IMAGE NODE DESIRED STATE CURRENT STATE ERROR PORTS 50 | auy9ajs8mgyn cron_main.1 vfarcic/docker-flow-cron:latest moby Running Running 4 seconds ago 51 | ``` 52 | 53 | Now that the service is running, we can schedule the first job. 54 | 55 | TODO: Continue 56 | 57 | TODO: Add at least two args 58 | 59 | ```bash 60 | curl -XPUT \ 61 | -d '{ 62 | "image": "alpine", 63 | "command": "echo \"hello World\"", 64 | "schedule": "@every 15s" 65 | "args": {"--restart-condition on-failure", "--constraint node.role==manager"} 66 | }' "http://localhost:8080/v1/docker-flow-cron/job/my-job" 67 | ``` 68 | 69 | ``` 70 | { 71 | "Status": "OK", 72 | "Message": "Job my-job has been scheduled", 73 | "Job": { 74 | "Name": "my-job", 75 | "Image": "alpine", 76 | "Command": "echo \"hello World\"", 77 | "Schedule": "@every 15s", 78 | "Args": null 79 | }, 80 | "Executions": null 81 | } 82 | ``` 83 | 84 | ```bash 85 | # Wait for 15 seconds 86 | 87 | docker service ls 88 | ``` 89 | 90 | ``` 91 | ID NAME MODE REPLICAS IMAGE 92 | 7lfroifdmw00 my-job replicated 0/1 alpine:latest 93 | vp1bcimbwsj5 cron_main replicated 1/1 vfarcic/docker-flow-cron:latest 94 | ``` 95 | 96 | ```bash 97 | curl -XGET \ 98 | "http://localhost:8080/v1/docker-flow-cron/job" 99 | ``` 100 | 101 | ``` 102 | { 103 | "Status": "OK", 104 | "Message": "", 105 | "Jobs": { 106 | "my-job": { 107 | "Name": "my-job", 108 | "Image": "alpine:latest@sha256:dfbd4a3a8ebca874ebd2474f044a0b33600d4523d03b0df76e5c5986cb02d7e8", 109 | "Command": "docker service create --restart-condition none alpine echo \"hello World\"", 110 | "Schedule": "@every 15s", 111 | "Args": null 112 | } 113 | } 114 | } 115 | ``` 116 | 117 | ```bash 118 | curl -XGET \ 119 | "http://localhost:8080/v1/docker-flow-cron/job/my-job" 120 | ``` 121 | 122 | ``` 123 | { 124 | "Status": "OK", 125 | "Message": "", 126 | "Job": { 127 | "Name": "my-job", 128 | "Image": "alpine:latest@sha256:dfbd4a3a8ebca874ebd2474f044a0b33600d4523d03b0df76e5c5986cb02d7e8", 129 | "Command": "docker service create --restart-condition none alpine echo \"hello World\"", 130 | "Schedule": "@every 15s", 131 | "Args": null 132 | }, 133 | "Executions": [ 134 | { 135 | "ServiceId": "85iyzvg00utszweglyle59i1y", 136 | "CreatedAt": "2017-02-28T22:22:24.786950411Z", 137 | "Status": { 138 | "Timestamp": "2017-02-28T22:22:26.556923596Z", 139 | "State": "complete", 140 | "Message": "finished", 141 | "ContainerStatus": { 142 | "ContainerID": "b415243a6e98204fb55fd6ce8ab378174cd58a550fa212514020ccb34bc61677" 143 | }, 144 | "PortStatus": {} 145 | } 146 | }, 147 | { 148 | "ServiceId": "j7s37k0jy12u6203pne4v50xz", 149 | "CreatedAt": "2017-02-28T22:23:25.33680838Z", 150 | "Status": { 151 | "Timestamp": "2017-02-28T22:23:27.118679889Z", 152 | "State": "complete", 153 | "Message": "finished", 154 | "ContainerStatus": { 155 | "ContainerID": "d608c4cb5f9594020f5067406c1cb50ccfe691d9b9ec220d4b12ffa05e59a777" 156 | }, 157 | "PortStatus": {} 158 | } 159 | }, 160 | ... 161 | ] 162 | } 163 | ``` 164 | 165 | ```bash 166 | curl -XPUT \ 167 | -d '{ 168 | "Image": "alpine", 169 | "Command": "echo \"hello World\"", 170 | "Schedule": "@every 15s" 171 | }' "http://localhost:8080/v1/docker-flow-cron/job/my-other-job" 172 | ``` 173 | 174 | ``` 175 | { 176 | "Status": "OK", 177 | "Message": "Job my-other-job has been scheduled", 178 | "Job": { 179 | "Name": "my-other-job", 180 | "Image": "alpine", 181 | "Command": "echo \"hello World\"", 182 | "Schedule": "@every 15s", 183 | "Args": null 184 | }, 185 | "Executions": null 186 | } 187 | ``` 188 | 189 | ```bash 190 | # Wait for 15 seconds 191 | 192 | curl -XGET \ 193 | "http://localhost:8080/v1/docker-flow-cron/job/my-other-job" 194 | ``` 195 | 196 | ``` 197 | { 198 | "Status": "OK", 199 | "Message": "", 200 | "Job": { 201 | "Name": "my-other-job", 202 | "Image": "alpine:latest@sha256:dfbd4a3a8ebca874ebd2474f044a0b33600d4523d03b0df76e5c5986cb02d7e8", 203 | "Command": "docker service create --restart-condition none alpine echo \"hello World\"", 204 | "Schedule": "@every 15s", 205 | "Args": null 206 | }, 207 | "Executions": [ 208 | { 209 | "ServiceId": "k92tbts7itviky14ucaxnozds", 210 | "CreatedAt": "2017-02-28T22:25:26.518009461Z", 211 | "Status": { 212 | "Timestamp": "2017-02-28T22:25:38.064998905Z", 213 | "State": "complete", 214 | "Message": "finished", 215 | "ContainerStatus": { 216 | "ContainerID": "72517cd1d328ee24c630fd0ec230473f4d2be49baf76aa4d1b4f36b7d9186d1b" 217 | }, 218 | "PortStatus": {} 219 | } 220 | }, 221 | ... 222 | ] 223 | } 224 | ``` 225 | 226 | ```bash 227 | # NOTE: Requires Docker 1.13+ with experimental features enabled 228 | docker service logs k92tbts7itviky14ucaxnozds 229 | ``` 230 | 231 | ``` 232 | tender_bhabha.1.6yozcuzf9abf@moby | hello World 233 | ``` 234 | 235 | ```bash 236 | curl -XGET \ 237 | "http://localhost:8080/v1/docker-flow-cron/job" 238 | ``` 239 | 240 | ``` 241 | { 242 | "Status": "OK", 243 | "Message": "", 244 | "Jobs": { 245 | "my-job": { 246 | "Name": "my-job", 247 | "Image": "alpine:latest@sha256:dfbd4a3a8ebca874ebd2474f044a0b33600d4523d03b0df76e5c5986cb02d7e8", 248 | "Command": "docker service create --restart-condition none alpine echo \"hello World\"", 249 | "Schedule": "@every 15s", 250 | "Args": null 251 | }, 252 | "my-other-job": { 253 | "Name": "my-other-job", 254 | "Image": "alpine:latest@sha256:dfbd4a3a8ebca874ebd2474f044a0b33600d4523d03b0df76e5c5986cb02d7e8", 255 | "Command": "docker service create --restart-condition none alpine echo \"hello World\"", 256 | "Schedule": "@every 15s", 257 | "Args": null 258 | } 259 | } 260 | } 261 | ``` 262 | 263 | ```bash 264 | curl -XDELETE \ 265 | "http://localhost:8080/v1/docker-flow-cron/job/my-other-job" 266 | ``` 267 | 268 | ``` 269 | { 270 | "Status": "OK", 271 | "Message": "my-other-job was deleted", 272 | "Job": { 273 | "Name": "", 274 | "Image": "", 275 | "Command": "", 276 | "Schedule": "", 277 | "Args": null 278 | }, 279 | "Executions": null 280 | } 281 | ``` 282 | 283 | ```bash 284 | curl -XGET \ 285 | "http://localhost:8080/v1/docker-flow-cron/job" 286 | ``` 287 | 288 | ``` 289 | { 290 | "Status": "OK", 291 | "Message": "", 292 | "Jobs": { 293 | "my-job": { 294 | "Name": "my-job", 295 | "Image": "alpine:latest@sha256:dfbd4a3a8ebca874ebd2474f044a0b33600d4523d03b0df76e5c5986cb02d7e8", 296 | "Command": "docker service create --restart-condition none alpine echo \"hello World\"", 297 | "Schedule": "@every 15s", 298 | "Args": null 299 | } 300 | } 301 | } 302 | ``` 303 | 304 | ```bash 305 | docker stack rm cron 306 | 307 | docker stack deploy -c cron.yml cron 308 | 309 | curl -XGET \ 310 | "http://localhost:8080/v1/docker-flow-cron/job" 311 | ``` 312 | 313 | ``` 314 | { 315 | "Status": "OK", 316 | "Message": "", 317 | "Jobs": { 318 | "my-job": { 319 | "Name": "my-job", 320 | "Image": "alpine:latest@sha256:dfbd4a3a8ebca874ebd2474f044a0b33600d4523d03b0df76e5c5986cb02d7e8", 321 | "Command": "docker service create --restart-condition none alpine echo \"hello World\"", 322 | "Schedule": "@every 15s", 323 | "Args": null 324 | } 325 | } 326 | } 327 | ``` 328 | 329 | ```bash 330 | curl -XDELETE \ 331 | "http://localhost:8080/v1/docker-flow-cron/job/my-job" 332 | 333 | curl -XGET \ 334 | "http://localhost:8080/v1/docker-flow-cron/job" 335 | ``` 336 | 337 | ``` 338 | { 339 | "Status": "OK", 340 | "Message": "", 341 | "Jobs": {} 342 | } 343 | ``` 344 | 345 | ```bash 346 | docker service ls 347 | ``` 348 | 349 | ``` 350 | ID NAME MODE REPLICAS IMAGE 351 | nvmq69qthhqz cron_main replicated 1/1 vfarcic/docker-flow-cron:latest 352 | ``` 353 | 354 | #### Docker Flow Cron using Docker Stacks 355 | 356 | > Create the overlay network 357 | 358 | ```docker network create -d overlay cron``` 359 | 360 | > Create the cron stack 361 | 362 | docker-compose.yml 363 | ``` 364 | version: "3" 365 | 366 | services: 367 | cron: 368 | image: vfarcic/docker-flow-cron 369 | networks: 370 | - cron 371 | volumes: 372 | - /var/run/docker.sock:/var/run/docker.sock 373 | ports: 374 | - 8080:8080 375 | deploy: 376 | placement: 377 | constraints: [node.role == manager] 378 | 379 | swarm-listener: 380 | image: vfarcic/docker-flow-swarm-listener 381 | networks: 382 | - cron 383 | volumes: 384 | - /var/run/docker.sock:/var/run/docker.sock 385 | environment: 386 | - DF_NOTIFY_CREATE_SERVICE_URL=http://cron:8080/v1/docker-flow-cron/job/create 387 | - DF_NOTIFY_REMOVE_SERVICE_URL=http://cron:8080/v1/docker-flow-cron/job/remove 388 | deploy: 389 | placement: 390 | constraints: [node.role == manager] 391 | networks: 392 | cron: 393 | external: true 394 | ``` 395 | 396 | Deploy the stack above by executing ```docker stack deploy -c docker-compose.yml cron``` 397 | 398 | 399 | ## Docker Flow Swarm Listener 400 | 401 | #### Docker Flow Cron using Docker Services 402 | 403 | > Create Docker Flow Swarm Listener 404 | 405 | ``` 406 | docker service create --name swarm-listener \ 407 | --network cron \ 408 | --mount "type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock" \ 409 | -e DF_NOTIFY_CREATE_SERVICE_URL=http://cron:8080/v1/docker-flow-cron/create \ 410 | -e DF_NOTIFY_REMOVE_SERVICE_URL=http://cron:8080/v1/docker-flow-cron/remove \ 411 | --constraint 'node.role==manager' \ 412 | vfarcic/docker-flow-swarm-listener 413 | ``` 414 | 415 | > Create Docker Flow Cron 416 | 417 | ``` 418 | docker service create --name cron \ 419 | --network cron \ 420 | --mount "type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock" \ 421 | -p 8080:8080 \ 422 | --constraint 'node.role==manager' \ 423 | vfarcic/docker-flow-cron 424 | ``` 425 | 426 | #### Example: Scheduling a Docker Service 427 | 428 | Now that the cron stack is up and running we can add our first scheduled Docker Service. 429 | The example service below will run ```echo "Hello World"``` every 10 seconds using an Alpine image. 430 | 431 | > Example: Scheduled job using Docker Services 432 | 433 | ``` 434 | docker service create --name cronjob \ 435 | --network cron --replicas 0 \ 436 | --restart-condition=none \ 437 | -l "com.df.notify=true" \ 438 | -l "com.df.cron=true" \ 439 | -l "com.df.cron.name=cronjob" \ 440 | -l "com.df.cron.image=alpine" \ 441 | -l "com.df.cron.command=echo Hello World" \ 442 | -l "com.df.cron.schedule=@every 10s" \ 443 | alpine \ 444 | echo Hello world 445 | ``` 446 | 447 | The example below is a more realistic use case for *Docker Flow Cron*. 448 | It will remove unusued docker data by running ```docker system prune``` every day. 449 | 450 | > Example: Scheduling a docker command to run on the host 451 | ``` 452 | docker service create --name docker_cleanup \ 453 | --network cron --replicas 0 \ 454 | --restart-condition=none \ 455 | --mount "type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock" \ 456 | -l "com.df.notify=true" \ 457 | -l "com.df.cron=true" \ 458 | -l "com.df.cron.name=docker_cleanup" \ 459 | -l "com.df.cron.image=docker" \ 460 | -l "com.df.cron.command=docker system prune -f" \ 461 | -l "com.df.cron.schedule=@daily" \ 462 | docker \ 463 | docker system prune -f 464 | ``` -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | There is currently two different ways of using Docker Flow Cron 4 | 5 | - Using the [Docker Flow Cron API](#docker-flow-cron-api) directly to manage (add, list, delete) scheduled jobs 6 | 7 | - Using the [*Docker Flow Swarm Listener*](#docker-flow-swarm-listener-support) support to manage jobs by creating/deleting regular Docker Services. 8 | 9 | 10 | ## Docker Flow Cron API 11 | #### Put Job 12 | 13 | > Adds a job to docker-flow-cron 14 | 15 | The following body parameters can be used to send a *create job* `PUT` request to *Docker Flow Cron*. They should be added to the base address **[CRON_IP]:[CRON_PORT]/v1/docker-flow-cron/job/[jobName]**. 16 | 17 | |param |Description |Mandatory|Example | 18 | |----------------|-------------------------------------------------------------------|---------|---------| 19 | |image |Docker image. |yes |alpine | 20 | |serviceName |Docker service name |no |my-cronjob | 21 | |command |The command that will be executed when a job is created. |no |echo "hello World"| 22 | |schedule |The schedule that defines the frequency of the job execution.Check the [scheduling section](#scheduling) for more info. |yes|@every 15s| 23 | |args |The list of arguments that can be used with the `docker service create` command.

`--restart-condition` cannot be set to `any`. If not specified, it will be set to `none`.
`--name` argument is not allowed. Use serviceName param instead

Any other argument supported by `docker service create` is allowed.|no|TODO| 24 | 25 | TODO: Example 26 | 27 | #### Get All Jobs 28 | 29 | > Gets all scheduled jobs 30 | 31 | The following `GET` request **[CRON_IP]:[CRON_PORT]/v1/docker-flow-cron/job**. can be used to get all scheduled jobs from Docker Flow Cron. 32 | 33 | 34 | #### Get Job 35 | 36 | > Gets a job from docker-flow-cron 37 | 38 | The following `GET` request **[CRON_IP]:[CRON_PORT]/v1/docker-flow-cron/[jobName]**. can be used to get a job from Docker Flow Cron. 39 | 40 | #### Delete Job 41 | 42 | > Deletes a job from docker-flow-cron 43 | 44 | The following `DELETE` request **[CRON_IP]:[CRON_PORT]/v1/docker-flow-cron/[jobName]**. can be used to delete a job from Docker Flow Cron. 45 | 46 | 47 | ## *Docker Flow Swarm Listener* support 48 | 49 | Using the *Docker Flow Swarm Listener* support, Docker Services can schedule jobs. 50 | Docker Flow Swarm Listener listens to Docker Swarm events and sends requests to Docker Flow Cron when changes occurs, 51 | every time a service is created or deleted Docker Flow Cron gets notified and manages job scheduling. 52 | 53 | 54 | A Docker Service is created with the following syntax: 55 | 56 | ```docker service create [OPTIONS] IMAGE [COMMAND] [ARG...]``` 57 | 58 | > Check the [documentation](https://docs.docker.com/engine/reference/commandline/service_create/) for more information. 59 | 60 | The same syntax is used to schedule a job in Docker Flow Cron: 61 | 62 | - ```IMAGE``` specifies the Docker Image 63 | - ```[COMMAND]``` specifies the command to schedule. 64 | - ```[OPTIONS]``` specifies some necessary options making scheduling Docker Services possible. 65 | 66 | 67 | The following Docker Service args ```[OPTIONS]``` should be used for scheduled Docker Services: 68 | 69 | |arg |Description |Mandatory|Example | 70 | |--------------------|-------------------------------------------------------------------|---------|----------| 71 | |--replicas | Set 0 to prevent the service from running immedietely. Set to 1 to run command on service creation. |yes |0 or 1 | 72 | |--restart-condition | Set to ```none``` to prevent the service from using Docker Swarm's ability to autorestart exited services. |yes |none | 73 | 74 | 75 | 76 | The following Docker Service labels ```[OPTIONS]``` needs to be used for scheduled Docker Services: 77 | 78 | |label |Description |Prefix|Mandatory|Example | 79 | |----------------|-------------------------------------------------------------------|------|---------|----------| 80 | |cron |Enable scheduling |com.df|yes |true | 81 | |image |Docker image. |com.df.cron|yes |alpine | 82 | |name |Cronjob name. |com.df.cron|yes |my-cronjob| 83 | |schedule |The schedule that defines the frequency of the job execution. Check the [scheduling section](#scheduling) for more info.|com.df.cron|yes|@every 15s| 84 | |command |The command that is scheduled, only used for Docker Flow Cron registration. Use the same command you set for your docker service to run.|com.df.cron|No |echo Hello World| 85 | 86 | **All labels needs to be prefixed** 87 | 88 | > Examples: 89 | - ```--labels "com.df.cron=true"``` 90 | - ```--labels "com.df.cron.name=my-job"``` 91 | 92 | 93 | ## Scheduling 94 | Docker Flow Cron uses the library [robfig/cron](https://godoc.org/github.com/robfig/cron) to provide a simple cron syntax for scheduling. 95 | 96 | > Examples 97 | ``` 98 | 0 30 * * * * Every hour on the half hour 99 | @hourly Every hour 100 | @every 1h30m Every hour thirty 101 | ``` 102 | 103 | #### Predefined schedules 104 | You may use one of several pre-defined schedules in place of a cron expression. 105 | ``` 106 | Entry | Description | Equivalent To 107 | ----- | ----------- | ------------- 108 | @yearly (or @annually) | Run once a year, midnight, Jan. 1st | 0 0 0 1 1 * 109 | @monthly | Run once a month, midnight, first of month | 0 0 0 1 * * 110 | @weekly | Run once a week, midnight on Sunday | 0 0 0 * * 0 111 | @daily (or @midnight) | Run once a day, midnight | 0 0 0 * * * 112 | @hourly | Run once an hour, beginning of hour | 0 0 * * * * 113 | ``` 114 | 115 | #### Intervals 116 | You may also schedule a job to execute at fixed intervals 117 | 118 | ``` 119 | @every 120 | @every 2h30m15s 121 | ``` 122 | 123 | Check the library [documentation](https://godoc.org/github.com/robfig/cron) for more information. -------------------------------------------------------------------------------- /helm/docker-flow-cron/Chart.yaml: -------------------------------------------------------------------------------- 1 | name: docker-flow-cron 2 | version: 0.0.1 3 | apiVersion: v1 4 | description: Docker Flow Cron 5 | keywords: 6 | - docker 7 | - swarm 8 | - docker-flow 9 | home: https://swarmlistener.dockerflow.com 10 | sources: 11 | - https://github.com/vfarcic/docker-flow-cron 12 | maintainers: 13 | - name: Viktor Farcic 14 | email: viktor@farcic.com 15 | -------------------------------------------------------------------------------- /helm/docker-flow-cron/README.md: -------------------------------------------------------------------------------- 1 | # Docker Flow Cron -------------------------------------------------------------------------------- /helm/docker-flow-cron/templates/NOTES.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vfarcic/docker-flow-cron/576d0f4b1460f7a71d5f8ae978f3f15487327e0b/helm/docker-flow-cron/templates/NOTES.txt -------------------------------------------------------------------------------- /helm/docker-flow-cron/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | {{/* 3 | Expand the name of the chart. 4 | */}} 5 | {{- define "helm.name" -}} 6 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} 7 | {{- end -}} 8 | 9 | {{/* 10 | Create a default fully qualified app name. 11 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 12 | If release name contains chart name it will be used as a full name. 13 | */}} 14 | {{- define "helm.fullname" -}} 15 | {{- $name := default .Chart.Name .Values.nameOverride -}} 16 | {{- if contains $name .Release.Name -}} 17 | {{- .Release.Name | trunc 63 | trimSuffix "-" -}} 18 | {{- else -}} 19 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} 20 | {{- end -}} 21 | {{- end -}} 22 | -------------------------------------------------------------------------------- /helm/docker-flow-cron/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ template "helm.fullname" . }} 5 | labels: 6 | app: {{ template "helm.name" . }} 7 | chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} 8 | release: {{ .Release.Name }} 9 | heritage: {{ .Release.Service }} 10 | spec: 11 | selector: 12 | matchLabels: 13 | app: {{ template "helm.name" . }} 14 | release: {{ .Release.Name }} 15 | template: 16 | metadata: 17 | labels: 18 | app: {{ template "helm.name" . }} 19 | release: {{ .Release.Name }} 20 | spec: 21 | containers: 22 | - name: ui 23 | image: vfarcic/docker-flow-cron-docs:{{ .Values.image.tag }} 24 | readinessProbe: 25 | httpGet: 26 | path: / 27 | port: 80 28 | periodSeconds: 1 29 | livenessProbe: 30 | httpGet: 31 | path: / 32 | port: 80 33 | resources: 34 | {{ toYaml .Values.resources | indent 10 }} 35 | -------------------------------------------------------------------------------- /helm/docker-flow-cron/templates/ing.yaml: -------------------------------------------------------------------------------- 1 | {{- $serviceName := include "helm.fullname" . -}} 2 | apiVersion: extensions/v1beta1 3 | kind: Ingress 4 | metadata: 5 | name: {{ template "helm.fullname" . }} 6 | labels: 7 | app: {{ template "helm.name" . }} 8 | chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} 9 | release: {{ .Release.Name }} 10 | heritage: {{ .Release.Service }} 11 | annotations: 12 | ingress.kubernetes.io/ssl-redirect: "true" 13 | nginx.ingress.kubernetes.io/ssl-redirect: "true" 14 | kubernetes.io/tls-acme: "true" 15 | spec: 16 | rules: 17 | {{- range .Values.ingress.host }} 18 | {{- $url := splitList "/" . }} 19 | - host: {{ first $url }} 20 | http: 21 | paths: 22 | - path: /{{ rest $url | join "/" }} 23 | backend: 24 | serviceName: {{ $serviceName }} 25 | servicePort: 80 26 | {{- end -}} 27 | {{- range .Values.ingress.host }} 28 | {{- $url := splitList "/" . }} 29 | tls: 30 | - hosts: 31 | - {{ first $url }} 32 | secretName: letsencrypt-secret-cron 33 | {{- end -}} 34 | -------------------------------------------------------------------------------- /helm/docker-flow-cron/templates/svc.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ template "helm.fullname" . }} 5 | labels: 6 | app: {{ template "helm.name" . }} 7 | chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} 8 | release: {{ .Release.Name }} 9 | heritage: {{ .Release.Service }} 10 | spec: 11 | ports: 12 | - port: 80 13 | type: NodePort 14 | selector: 15 | app: {{ template "helm.name" . }} 16 | release: {{ .Release.Name }} -------------------------------------------------------------------------------- /helm/docker-flow-cron/values.yaml: -------------------------------------------------------------------------------- 1 | image: 2 | tag: latest 3 | ingress: 4 | host: 5 | - cron.dockerflow.com 6 | resources: 7 | limits: 8 | cpu: 10m 9 | memory: 6Mi 10 | requests: 11 | cpu: 5m 12 | memory: 3Mi 13 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "./server" 5 | "log" 6 | ) 7 | 8 | // TODO: Test 9 | func main() { 10 | s, err := server.New("0.0.0.0", "8080", "unix:///var/run/docker.sock") 11 | if err != nil { 12 | log.Fatal(err.Error()) 13 | } 14 | s.Cron.RescheduleJobs() 15 | s.Execute() 16 | } 17 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Docker Flow Cron 2 | pages: 3 | - Home: index.md 4 | - Requirements: requirements.md 5 | - Tutorial: tutorial.md 6 | - Usage: usage.md 7 | - Release Notes: release-notes.md 8 | - Feedback and Contribution: feedback-and-contribution.md 9 | - License: license.md 10 | repo_url: https://github.com/vfarcic/docker-flow-cron 11 | site_author: Viktor Farcic 12 | copyright: Copyright © 2017 Viktor Farcic 13 | strict: true 14 | theme: 'material' 15 | extra: 16 | palette: 17 | primary: 'blue' 18 | accent: 'light blue' 19 | markdown_extensions: 20 | - toc 21 | - admonition 22 | - codehilite(guess_lang=false) 23 | - toc(permalink=true) 24 | - footnotes 25 | - pymdownx.arithmatex 26 | - pymdownx.betterem(smart_enable=all) 27 | - pymdownx.caret 28 | - pymdownx.critic 29 | - pymdownx.emoji: 30 | emoji_generator: !!python/name:pymdownx.emoji.to_svg 31 | - pymdownx.inlinehilite 32 | - pymdownx.magiclink 33 | - pymdownx.mark 34 | - pymdownx.smartsymbols 35 | - pymdownx.superfences 36 | - pymdownx.tasklist(custom_checkbox=true) 37 | - pymdownx.tilde 38 | -------------------------------------------------------------------------------- /proxy-key.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vfarcic/docker-flow-cron/576d0f4b1460f7a71d5f8ae978f3f15487327e0b/proxy-key.enc -------------------------------------------------------------------------------- /server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "../cron" 5 | "../docker" 6 | "encoding/json" 7 | "fmt" 8 | "github.com/docker/docker/api/types/swarm" 9 | "github.com/gorilla/mux" 10 | "io/ioutil" 11 | "net/http" 12 | "strings" 13 | "time" 14 | ) 15 | 16 | var muxVars = mux.Vars 17 | 18 | type Serve struct { 19 | IP string 20 | Port string 21 | Cron cron.Croner 22 | Service docker.Servicer 23 | } 24 | 25 | type Response struct { 26 | Status string 27 | Message string 28 | Jobs map[string]cron.JobData 29 | } 30 | 31 | type Execution struct { 32 | ServiceId string 33 | CreatedAt time.Time 34 | Status swarm.TaskStatus 35 | } 36 | 37 | type ResponseDetails struct { 38 | Status string 39 | Message string 40 | Job cron.JobData 41 | Executions []Execution 42 | } 43 | 44 | var httpListenAndServe = http.ListenAndServe 45 | var httpWriterSetContentType = func(w http.ResponseWriter, value string) { 46 | w.Header().Set("Content-Type", value) 47 | } 48 | 49 | var New = func(ip, port, dockerHost string) (*Serve, error) { 50 | service, err := docker.New(dockerHost) 51 | if err != nil { 52 | return &Serve{}, err 53 | } 54 | cron, _ := cron.New(dockerHost) 55 | return &Serve{ 56 | IP: ip, 57 | Port: port, 58 | Cron: cron, 59 | Service: service, 60 | }, nil 61 | } 62 | 63 | func (s *Serve) Execute() error { 64 | fmt.Printf("Starting Web server running on %s:%s\n", s.IP, s.Port) 65 | address := fmt.Sprintf("%s:%s", s.IP, s.Port) 66 | // TODO: Test routes 67 | r := mux.NewRouter().StrictSlash(true) 68 | //swarm-listener 69 | r.HandleFunc("/v1/docker-flow-cron/job/create", s.JobPutHandler).Methods("GET") 70 | r.HandleFunc("/v1/docker-flow-cron/job/remove", s.JobDeleteHandler).Methods("GET") 71 | 72 | r.HandleFunc("/v1/docker-flow-cron/job", s.JobGetHandler).Methods("GET") 73 | r.HandleFunc("/v1/docker-flow-cron/job/{jobName}", s.JobPutHandler).Methods("PUT") 74 | r.HandleFunc("/v1/docker-flow-cron/job/{jobName}", s.JobDetailsHandler).Methods("GET") 75 | // TODO: Document 76 | r.HandleFunc("/v1/docker-flow-cron/job/{jobName}", s.JobDeleteHandler).Methods("DELETE") 77 | if err := httpListenAndServe(address, r); err != nil { 78 | return err 79 | } 80 | return nil 81 | } 82 | 83 | func (s *Serve) JobDeleteHandler(w http.ResponseWriter, req *http.Request) { 84 | jobName := req.URL.Query().Get("serviceName") 85 | if muxVars(req)["jobName"] != "" { 86 | jobName = muxVars(req)["jobName"] 87 | } 88 | 89 | response := ResponseDetails{ 90 | Status: "OK", 91 | Message: fmt.Sprintf("%s was deleted", jobName), 92 | } 93 | err := s.Cron.RemoveJob(jobName) 94 | if err != nil { 95 | response.Status = "NOK" 96 | response.Message = err.Error() 97 | w.WriteHeader(http.StatusInternalServerError) 98 | } 99 | js, _ := json.Marshal(response) 100 | w.Write(js) 101 | } 102 | 103 | func (s *Serve) JobDetailsHandler(w http.ResponseWriter, req *http.Request) { 104 | jobName := muxVars(req)["jobName"] 105 | services, err := s.Service.GetServices(jobName) 106 | httpWriterSetContentType(w, "application/json") 107 | response := ResponseDetails{ 108 | Status: "OK", 109 | Message: "", 110 | Job: cron.JobData{}, 111 | Executions: []Execution{}, 112 | } 113 | if err != nil { 114 | response.Status = "NOK" 115 | response.Message = err.Error() 116 | w.WriteHeader(http.StatusInternalServerError) 117 | } else { 118 | index := len(services) - 1 119 | if index < 0 { 120 | response.Status = "NOK" 121 | response.Message = "Could not find the job" 122 | w.WriteHeader(http.StatusNotFound) 123 | } else { 124 | service := services[index] 125 | tasks, err := s.Service.GetTasks(jobName) 126 | if err != nil { 127 | response.Status = "NOK" 128 | response.Message = err.Error() 129 | w.WriteHeader(http.StatusInternalServerError) 130 | } else { 131 | executions := []Execution{} 132 | for _, t := range tasks { 133 | execution := Execution{ 134 | ServiceId: t.ServiceID, 135 | CreatedAt: t.CreatedAt, 136 | Status: t.Status, 137 | } 138 | executions = append(executions, execution) 139 | } 140 | response.Job = s.getJob(service) 141 | response.Executions = executions 142 | } 143 | } 144 | } 145 | js, _ := json.Marshal(response) 146 | w.Write(js) 147 | } 148 | 149 | func (s *Serve) JobGetHandler(w http.ResponseWriter, req *http.Request) { 150 | response := Response{ 151 | Status: "OK", 152 | } 153 | jobs, err := s.Cron.GetJobs() 154 | if err != nil { 155 | response.Status = "NOK" 156 | response.Message = err.Error() 157 | w.WriteHeader(http.StatusInternalServerError) 158 | } 159 | response.Jobs = jobs 160 | httpWriterSetContentType(w, "application/json") 161 | js, _ := json.Marshal(response) 162 | w.Write(js) 163 | } 164 | 165 | func (s *Serve) JobPutHandler(w http.ResponseWriter, req *http.Request) { 166 | 167 | response := ResponseDetails{ 168 | Status: "OK", 169 | } 170 | if req.Body == nil { 171 | w.WriteHeader(http.StatusBadRequest) 172 | response.Status = "NOK" 173 | response.Message = "Request body is mandatory" 174 | } else { 175 | defer func() { req.Body.Close() }() 176 | body, _ := ioutil.ReadAll(req.Body) 177 | data := cron.JobData{} 178 | 179 | if req.Method == "GET" { 180 | data.Name = req.URL.Query().Get("cron.name") 181 | data.ServiceName = req.URL.Query().Get("cron.serviceName") 182 | data.Image = req.URL.Query().Get("cron.image") 183 | data.Command = req.URL.Query().Get("cron.command") 184 | data.Schedule = req.URL.Query().Get("cron.schedule") 185 | data.Created = true 186 | } else { 187 | jobName := muxVars(req)["jobName"] 188 | json.Unmarshal(body, &data) 189 | data.Name = jobName 190 | data.Created = false 191 | } 192 | 193 | response.Job = data 194 | if err := s.Cron.AddJob(data); err != nil { 195 | w.WriteHeader(http.StatusInternalServerError) 196 | response.Status = "NOK" 197 | response.Message = err.Error() 198 | } else { 199 | response.Message = fmt.Sprintf("Job %s has been scheduled", data.Name) 200 | } 201 | } 202 | httpWriterSetContentType(w, "application/json") 203 | js, _ := json.Marshal(response) 204 | w.Write(js) 205 | } 206 | 207 | // TODO: Remove when JobDetailsHandler is refactored to use cron 208 | func (s *Serve) getJob(service swarm.Service) cron.JobData { 209 | command := "" 210 | for _, v := range service.Spec.TaskTemplate.ContainerSpec.Args { 211 | if strings.Contains(v, " ") { 212 | command = fmt.Sprintf(`%s "%s"`, command, v) 213 | } else { 214 | command = fmt.Sprintf(`%s %s`, command, v) 215 | } 216 | } 217 | name := service.Spec.Annotations.Labels["com.df.cron.name"] 218 | return cron.JobData{ 219 | Name: name, 220 | ServiceName: service.Spec.Name, 221 | Image: service.Spec.TaskTemplate.ContainerSpec.Image, 222 | Command: service.Spec.Annotations.Labels["com.df.cron.command"], 223 | Schedule: service.Spec.Annotations.Labels["com.df.cron.schedule"], 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /server/server_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "os/exec" 9 | "strings" 10 | "testing" 11 | "time" 12 | 13 | "../cron" 14 | "../docker" 15 | "github.com/docker/docker/api/types/swarm" 16 | "github.com/stretchr/testify/suite" 17 | ) 18 | 19 | type ServerTestSuite struct { 20 | suite.Suite 21 | ResponseWriter ResponseWriterMock 22 | Service ServicerMock 23 | } 24 | 25 | func (s *ServerTestSuite) SetupTest() { 26 | s.ResponseWriter = ResponseWriterMock{ 27 | WriteHeaderMock: func(header int) { 28 | }, 29 | HeaderMock: func() http.Header { 30 | return http.Header{} 31 | }, 32 | WriteMock: func(content []byte) (int, error) { 33 | return 0, nil 34 | }, 35 | } 36 | s.Service = ServicerMock{ 37 | GetServicesMock: func(jobName string) ([]swarm.Service, error) { 38 | return []swarm.Service{}, nil 39 | }, 40 | GetTasksMock: func(jobName string) ([]swarm.Task, error) { 41 | return []swarm.Task{}, nil 42 | }, 43 | } 44 | } 45 | 46 | func TestCronUnitTestSuite(t *testing.T) { 47 | s := new(ServerTestSuite) 48 | suite.Run(t, s) 49 | } 50 | 51 | // New 52 | 53 | func (s *ServerTestSuite) Test_New_ReturnsError_WhenDockerClientFails() { 54 | _, err := New("myIp", "1234", "this-is-not-a-socket") 55 | 56 | s.Error(err) 57 | } 58 | 59 | // Execute 60 | 61 | func (s *ServerTestSuite) Test_Execute_InvokesHTTPListenAndServe() { 62 | serve, _ := New("myIp", "1234", "unix:///var/run/docker.sock") 63 | var actual string 64 | expected := fmt.Sprintf("%s:%s", serve.IP, serve.Port) 65 | httpListenAndServe = func(addr string, handler http.Handler) error { 66 | actual = addr 67 | return nil 68 | } 69 | 70 | serve.Execute() 71 | time.Sleep(1 * time.Millisecond) 72 | 73 | s.Equal(expected, actual) 74 | } 75 | 76 | func (s *ServerTestSuite) Test_Execute_ReturnsError_WhenHTTPListenAndServeFails() { 77 | orig := httpListenAndServe 78 | defer func() { httpListenAndServe = orig }() 79 | httpListenAndServe = func(addr string, handler http.Handler) error { 80 | return fmt.Errorf("This is an error") 81 | } 82 | 83 | serve, _ := New("myIp", "1234", "unix:///var/run/docker.sock") 84 | actual := serve.Execute() 85 | 86 | s.Error(actual) 87 | } 88 | 89 | // JobPutHandler (PUT) 90 | 91 | func (s *ServerTestSuite) Test_JobPutHandler_InvokesCronAddJob() { 92 | muxVarsOrig := muxVars 93 | defer func() { muxVars = muxVarsOrig }() 94 | muxVars = func(r *http.Request) map[string]string { 95 | return map[string]string{"jobName": "my-job"} 96 | } 97 | expectedData := cron.JobData{ 98 | Image: "my-image", 99 | Schedule: "@yearly", 100 | } 101 | actualData := cron.JobData{} 102 | js, _ := json.Marshal(expectedData) 103 | expectedData.Name = "my-job" 104 | req, _ := http.NewRequest( 105 | "PUT", 106 | "/v1/docker-flow-cron/job", 107 | strings.NewReader(string(js)), 108 | ) 109 | cMock := CronerMock{ 110 | AddJobMock: func(data cron.JobData) error { 111 | actualData = data 112 | return nil 113 | }, 114 | } 115 | 116 | srv := Serve{Cron: cMock} 117 | srv.JobPutHandler(s.ResponseWriter, req) 118 | 119 | s.Equal(expectedData, actualData) 120 | } 121 | 122 | // JobPutHandler (GET) 123 | 124 | func (s *ServerTestSuite) Test_JobPutHandler_GetRequest_ReturnsJobDetails() { 125 | var body string = `{}` 126 | req, _ := http.NewRequest( 127 | "GET", 128 | "/v1/docker-flow-cron/job/create?cron=true&cron.command=echo+hello+world&cron.image=alpine&cron.name=my-job&cron.schedule=%40every+10s", 129 | bytes.NewBufferString(body), 130 | ) 131 | job := cron.JobData{ 132 | Name: "my-job", 133 | Image: "alpine", 134 | Command: "echo hello world", 135 | Schedule: "@every 10s", 136 | Created: true, 137 | } 138 | 139 | expected := ResponseDetails{ 140 | Status: "OK", 141 | Message: "Job my-job has been scheduled", 142 | Job: job, 143 | } 144 | actual := ResponseDetails{} 145 | rwMock := ResponseWriterMock{ 146 | WriteHeaderMock: func(header int) {}, 147 | HeaderMock: func() http.Header { 148 | return http.Header{} 149 | }, 150 | WriteMock: func(content []byte) (int, error) { 151 | json.Unmarshal(content, &actual) 152 | return 0, nil 153 | }, 154 | } 155 | cMock := CronerMock{ 156 | AddJobMock: func(data cron.JobData) error { 157 | return nil 158 | }, 159 | } 160 | srv := Serve{Service: s.Service, Cron: cMock} 161 | srv.JobPutHandler(rwMock, req) 162 | 163 | s.Equal(expected, actual) 164 | } 165 | 166 | func (s *ServerTestSuite) Test_JobPutHandler_ReturnsBadRequestWhenBodyIsNil() { 167 | req, _ := http.NewRequest("PUT", "/v1/docker-flow-cron/job", nil) 168 | cMock := CronerMock{ 169 | AddJobMock: func(data cron.JobData) error { 170 | return nil 171 | }, 172 | } 173 | actual := 0 174 | mock := ResponseWriterMock{ 175 | WriteHeaderMock: func(header int) { 176 | actual = header 177 | }, 178 | HeaderMock: func() http.Header { 179 | return http.Header{} 180 | }, 181 | WriteMock: func(content []byte) (int, error) { 182 | return 0, nil 183 | }, 184 | } 185 | 186 | srv := Serve{Cron: cMock} 187 | srv.JobPutHandler(mock, req) 188 | 189 | s.Equal(400, actual) 190 | } 191 | 192 | func (s *ServerTestSuite) Test_JobPutHandler_InvokesInternalServerError_WhenAddJobFails() { 193 | expectedData := cron.JobData{ 194 | Name: "my-job", 195 | Image: "my-image", 196 | Schedule: "@yearly", 197 | } 198 | js, _ := json.Marshal(expectedData) 199 | req, _ := http.NewRequest( 200 | "PUT", 201 | "/v1/docker-flow-cron/job", 202 | strings.NewReader(string(js)), 203 | ) 204 | cMock := CronerMock{ 205 | AddJobMock: func(data cron.JobData) error { 206 | return fmt.Errorf("This is an error") 207 | }, 208 | } 209 | actualStatus := 0 210 | mock := ResponseWriterMock{ 211 | WriteHeaderMock: func(header int) { 212 | actualStatus = header 213 | }, 214 | HeaderMock: func() http.Header { 215 | return http.Header{} 216 | }, 217 | WriteMock: func(content []byte) (int, error) { 218 | return 0, nil 219 | }, 220 | } 221 | 222 | srv := Serve{Cron: cMock} 223 | srv.JobPutHandler(mock, req) 224 | 225 | s.Equal(500, actualStatus) 226 | } 227 | 228 | // JobGetHandler 229 | 230 | func (s *ServerTestSuite) Test_JobGetHandler_ReturnsListOfServices() { 231 | jobs := map[string]cron.JobData{ 232 | "my-job-1": {}, 233 | "my-job-2": {}, 234 | } 235 | req, _ := http.NewRequest("GET", "/v1/docker-flow-cron/job", nil) 236 | expected := Response{ 237 | Status: "OK", 238 | Jobs: jobs, 239 | } 240 | actual := Response{} 241 | rwMock := ResponseWriterMock{ 242 | WriteHeaderMock: func(header int) {}, 243 | HeaderMock: func() http.Header { 244 | return http.Header{} 245 | }, 246 | WriteMock: func(content []byte) (int, error) { 247 | json.Unmarshal(content, &actual) 248 | return 0, nil 249 | }, 250 | } 251 | cMock := CronerMock{ 252 | GetJobsMock: func() (map[string]cron.JobData, error) { 253 | return jobs, nil 254 | }, 255 | } 256 | 257 | srv := Serve{Service: s.Service, Cron: cMock} 258 | srv.JobGetHandler(rwMock, req) 259 | 260 | s.Equal(expected, actual) 261 | } 262 | 263 | func (s *ServerTestSuite) Test_JobGetHandler_ReturnsError_WhenGetJobsFail() { 264 | message := "This is an error" 265 | actual := Response{} 266 | actualStatus := 0 267 | rwMock := ResponseWriterMock{ 268 | WriteHeaderMock: func(header int) { 269 | actualStatus = header 270 | }, 271 | HeaderMock: func() http.Header { 272 | return http.Header{} 273 | }, 274 | WriteMock: func(content []byte) (int, error) { 275 | json.Unmarshal(content, &actual) 276 | return 0, nil 277 | }, 278 | } 279 | cMock := CronerMock{ 280 | GetJobsMock: func() (map[string]cron.JobData, error) { 281 | return map[string]cron.JobData{}, fmt.Errorf("This is an error") 282 | }, 283 | } 284 | req, _ := http.NewRequest("GET", "/v1/docker-flow-cron/job", nil) 285 | expected := Response{ 286 | Status: "NOK", 287 | Message: message, 288 | Jobs: map[string]cron.JobData{}, 289 | } 290 | 291 | srv := Serve{Service: s.Service, Cron: cMock} 292 | srv.JobGetHandler(rwMock, req) 293 | 294 | s.Equal(expected, actual) 295 | s.Equal(500, actualStatus) 296 | } 297 | 298 | // JobDeleteHandler 299 | 300 | func (s *ServerTestSuite) Test_JobDeleteHandler_ReturnsJobDetails() { 301 | muxVarsOrig := muxVars 302 | defer func() { muxVars = muxVarsOrig }() 303 | muxVars = func(r *http.Request) map[string]string { 304 | return map[string]string{"jobName": "my-job"} 305 | } 306 | name := "my-job" 307 | req, _ := http.NewRequest( 308 | "DELETE", 309 | fmt.Sprintf("/v1/docker-flow-cron/job/%s", name), 310 | nil, 311 | ) 312 | expected := ResponseDetails{ 313 | Status: "OK", 314 | Message: "my-job was deleted", 315 | } 316 | actual := ResponseDetails{} 317 | rwMock := ResponseWriterMock{ 318 | WriteHeaderMock: func(header int) {}, 319 | HeaderMock: func() http.Header { 320 | return http.Header{} 321 | }, 322 | WriteMock: func(content []byte) (int, error) { 323 | json.Unmarshal(content, &actual) 324 | return 0, nil 325 | }, 326 | } 327 | actualName := "" 328 | cMock := CronerMock{ 329 | RemoveJobMock: func(jobName string) error { 330 | actualName = jobName 331 | return nil 332 | }, 333 | } 334 | 335 | srv := Serve{Service: s.Service, Cron: cMock} 336 | srv.JobDeleteHandler(rwMock, req) 337 | 338 | s.Equal(expected, actual) 339 | s.Equal("my-job", actualName) 340 | } 341 | 342 | func (s *ServerTestSuite) Test_JobDeleteHandler_ReturnsNok_WhenRemoveJobFails() { 343 | muxVarsOrig := muxVars 344 | defer func() { muxVars = muxVarsOrig }() 345 | muxVars = func(r *http.Request) map[string]string { 346 | return map[string]string{"jobName": "my-job"} 347 | } 348 | name := "my-job" 349 | req, _ := http.NewRequest( 350 | "DELETE", 351 | fmt.Sprintf("/v1/docker-flow-cron/job/%s", name), 352 | nil, 353 | ) 354 | expected := ResponseDetails{ 355 | Status: "NOK", 356 | Message: "This is an error", 357 | } 358 | actual := ResponseDetails{} 359 | actualStatus := 0 360 | rwMock := ResponseWriterMock{ 361 | WriteHeaderMock: func(header int) { 362 | actualStatus = header 363 | }, 364 | HeaderMock: func() http.Header { 365 | return http.Header{} 366 | }, 367 | WriteMock: func(content []byte) (int, error) { 368 | json.Unmarshal(content, &actual) 369 | return 0, nil 370 | }, 371 | } 372 | cMock := CronerMock{ 373 | RemoveJobMock: func(jobName string) error { 374 | return fmt.Errorf("This is an error") 375 | }, 376 | } 377 | 378 | srv := Serve{Service: s.Service, Cron: cMock} 379 | srv.JobDeleteHandler(rwMock, req) 380 | 381 | s.Equal(expected, actual) 382 | s.Equal(500, actualStatus) 383 | } 384 | 385 | // JobDetailsHandler 386 | 387 | func (s *ServerTestSuite) Test_JobDetailsHandler_ReturnsJobDetails() { 388 | muxVarsOrig := muxVars 389 | defer func() { muxVars = muxVarsOrig }() 390 | muxVars = func(r *http.Request) map[string]string { 391 | return map[string]string{"jobName": "my-job"} 392 | } 393 | defer exec.Command("/bin/sh", "-c", `docker service rm $(docker service ls -q -f label=com.df.cron=true)`).CombinedOutput() 394 | name := "my-job" 395 | req, _ := http.NewRequest( 396 | "GET", 397 | fmt.Sprintf("/v1/docker-flow-cron/job/%s", name), 398 | nil, 399 | ) 400 | image := "alpine:3.5@sha256:dfbd4a3a8ebca874ebd2474f044a0b33600d4523d03b0df76e5c5986cb02d7e8" 401 | executions := []Execution{} 402 | cmdf := `docker service create \ 403 | -l 'com.df.cron=true' \ 404 | -l 'com.df.cron.name=%s' \ 405 | -l 'com.df.cron.schedule=@every 1s' \ 406 | -l 'com.df.cron.command=docker service create --restart-condition none alpine echo "Hello World!"' \ 407 | --constraint "node.labels.env != does-not-exist" \ 408 | --container-label 'container=label' \ 409 | --name %s \ 410 | --restart-condition none %s \ 411 | echo "Hello world!"` 412 | for _, jobName := range []string{"my-job", "my-job", "some-other-job"} { 413 | cmd := fmt.Sprintf( 414 | cmdf, 415 | jobName, 416 | jobName, 417 | image, 418 | ) 419 | exec.Command("/bin/sh", "-c", cmd).CombinedOutput() 420 | if jobName == "my-job" { 421 | executions = append(executions, Execution{}) 422 | } 423 | } 424 | job := cron.JobData{ 425 | Name: name, 426 | Image: image, 427 | ServiceName: name, 428 | Command: `docker service create --restart-condition none alpine echo "Hello World!"`, 429 | Schedule: "@every 1s", 430 | } 431 | expected := ResponseDetails{ 432 | Status: "OK", 433 | Job: job, 434 | Executions: executions, 435 | } 436 | actual := ResponseDetails{} 437 | rwMock := ResponseWriterMock{ 438 | WriteHeaderMock: func(header int) {}, 439 | HeaderMock: func() http.Header { 440 | return http.Header{} 441 | }, 442 | WriteMock: func(content []byte) (int, error) { 443 | json.Unmarshal(content, &actual) 444 | return 0, nil 445 | }, 446 | } 447 | service, _ := docker.New("unix:///var/run/docker.sock") 448 | 449 | srv := Serve{Service: service} 450 | srv.JobDetailsHandler(rwMock, req) 451 | 452 | s.Equal(expected.Job, actual.Job) 453 | s.Equal(1, len(actual.Executions)) 454 | s.False(actual.Executions[0].CreatedAt.IsZero()) 455 | s.NotNil(actual.Executions[0].Status) 456 | s.NotNil(actual.Executions[0].ServiceId) 457 | } 458 | 459 | func (s *ServerTestSuite) Test_JobDetailsHandler_ReturnsError_WhenGetServicesFail() { 460 | message := "This is an get services error" 461 | mock := ServicerMock{ 462 | GetServicesMock: func(jobName string) ([]swarm.Service, error) { 463 | return []swarm.Service{}, fmt.Errorf(message) 464 | }, 465 | GetTasksMock: func(jobName string) ([]swarm.Task, error) { 466 | return []swarm.Task{}, nil 467 | }, 468 | } 469 | actual := ResponseDetails{} 470 | actualStatus := 0 471 | rwMock := ResponseWriterMock{ 472 | WriteHeaderMock: func(header int) { 473 | actualStatus = header 474 | }, 475 | HeaderMock: func() http.Header { 476 | return http.Header{} 477 | }, 478 | WriteMock: func(content []byte) (int, error) { 479 | json.Unmarshal(content, &actual) 480 | return 0, nil 481 | }, 482 | } 483 | req, _ := http.NewRequest("GET", "/v1/docker-flow-cron/job/my-job", nil) 484 | expected := ResponseDetails{ 485 | Status: "NOK", 486 | Message: message, 487 | Job: cron.JobData{}, 488 | Executions: []Execution{}, 489 | } 490 | 491 | srv := Serve{Service: mock} 492 | srv.JobDetailsHandler(rwMock, req) 493 | 494 | s.Equal(expected, actual) 495 | s.Equal(500, actualStatus) 496 | } 497 | 498 | func (s *ServerTestSuite) Test_JobDetailsHandler_ReturnsError_WhenServiceDoesNotExist() { 499 | actual := ResponseDetails{} 500 | actualStatus := 0 501 | rwMock := ResponseWriterMock{ 502 | WriteHeaderMock: func(header int) { 503 | actualStatus = header 504 | }, 505 | HeaderMock: func() http.Header { 506 | return http.Header{} 507 | }, 508 | WriteMock: func(content []byte) (int, error) { 509 | json.Unmarshal(content, &actual) 510 | return 0, nil 511 | }, 512 | } 513 | req, _ := http.NewRequest("GET", "/v1/docker-flow-cron/job/my-job", nil) 514 | expected := ResponseDetails{ 515 | Status: "NOK", 516 | Message: "Could not find the job", 517 | Job: cron.JobData{}, 518 | Executions: []Execution{}, 519 | } 520 | 521 | srv := Serve{Service: s.Service} 522 | srv.JobDetailsHandler(rwMock, req) 523 | 524 | s.Equal(expected, actual) 525 | s.Equal(404, actualStatus) 526 | } 527 | 528 | func (s *ServerTestSuite) Test_JobDetailsHandler_ReturnsError_WhenGetTasksFail() { 529 | message := "This is an get tasks error" 530 | mock := ServicerMock{ 531 | GetServicesMock: func(jobName string) ([]swarm.Service, error) { 532 | return []swarm.Service{{}}, nil 533 | }, 534 | GetTasksMock: func(jobName string) ([]swarm.Task, error) { 535 | return []swarm.Task{}, fmt.Errorf(message) 536 | }, 537 | } 538 | actual := ResponseDetails{} 539 | actualStatus := 0 540 | rwMock := ResponseWriterMock{ 541 | WriteHeaderMock: func(header int) { 542 | actualStatus = header 543 | }, 544 | HeaderMock: func() http.Header { 545 | return http.Header{} 546 | }, 547 | WriteMock: func(content []byte) (int, error) { 548 | json.Unmarshal(content, &actual) 549 | return 0, nil 550 | }, 551 | } 552 | req, _ := http.NewRequest("GET", "/v1/docker-flow-cron/job/my-job", nil) 553 | expected := ResponseDetails{ 554 | Status: "NOK", 555 | Message: message, 556 | Job: cron.JobData{}, 557 | Executions: []Execution{}, 558 | } 559 | 560 | srv := Serve{Service: mock} 561 | srv.JobDetailsHandler(rwMock, req) 562 | 563 | s.Equal(expected, actual) 564 | s.Equal(500, actualStatus) 565 | } 566 | 567 | // Mock 568 | 569 | type ResponseWriterMock struct { 570 | HeaderMock func() http.Header 571 | WriteMock func([]byte) (int, error) 572 | WriteHeaderMock func(int) 573 | } 574 | 575 | func (m ResponseWriterMock) Header() http.Header { 576 | return m.HeaderMock() 577 | } 578 | 579 | func (m ResponseWriterMock) Write(content []byte) (int, error) { 580 | return m.WriteMock(content) 581 | } 582 | 583 | func (m ResponseWriterMock) WriteHeader(header int) { 584 | m.WriteHeaderMock(header) 585 | } 586 | 587 | type CronerMock struct { 588 | AddJobMock func(data cron.JobData) error 589 | StopMock func() 590 | GetJobsMock func() (map[string]cron.JobData, error) 591 | RemoveJobMock func(jobName string) error 592 | RescheduleJobsMock func() error 593 | } 594 | 595 | func (m CronerMock) AddJob(data cron.JobData) error { 596 | return m.AddJobMock(data) 597 | } 598 | 599 | func (m CronerMock) Stop() { 600 | m.StopMock() 601 | } 602 | 603 | func (m CronerMock) GetJobs() (map[string]cron.JobData, error) { 604 | return m.GetJobsMock() 605 | } 606 | 607 | func (m CronerMock) RemoveJob(jobName string) error { 608 | return m.RemoveJobMock(jobName) 609 | } 610 | 611 | func (m CronerMock) RescheduleJobs() error { 612 | return m.RescheduleJobsMock() 613 | } 614 | 615 | type ServicerMock struct { 616 | GetServicesMock func(jobName string) ([]swarm.Service, error) 617 | GetTasksMock func(jobName string) ([]swarm.Task, error) 618 | RemoveServicesMock func(jobName string) error 619 | } 620 | 621 | func (m ServicerMock) GetServices(jobName string) ([]swarm.Service, error) { 622 | return m.GetServicesMock(jobName) 623 | } 624 | 625 | func (m ServicerMock) GetTasks(jobName string) ([]swarm.Task, error) { 626 | return m.GetTasksMock(jobName) 627 | } 628 | 629 | func (m ServicerMock) RemoveServices(jobName string) error { 630 | return m.RemoveServicesMock(jobName) 631 | } 632 | -------------------------------------------------------------------------------- /stack-docs.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | 5 | main: 6 | image: vfarcic/docker-flow-cron-docs:${TAG:-latest} 7 | networks: 8 | - proxy 9 | deploy: 10 | replicas: 2 11 | update_config: 12 | parallelism: 1 13 | delay: 10s 14 | labels: 15 | - com.df.notify=true 16 | - com.df.distribute=true 17 | - com.df.servicePath=/ 18 | - com.df.port=80 19 | - com.df.serviceDomain=cron.dockerflow.com 20 | 21 | networks: 22 | proxy: 23 | external: true 24 | -------------------------------------------------------------------------------- /stack.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | 5 | # main: 6 | # image: vfarcic/docker-flow-cron:${TAG:-latest} 7 | # volumes: 8 | # - /var/run/docker.sock:/var/run/docker.sock 9 | # ports: 10 | # - ${PORT:-8080}:8080 11 | # deploy: 12 | # placement: 13 | # constraints: [node.role == manager] 14 | 15 | docs: 16 | image: vfarcic/docker-flow-cron-docs:${TAG:-latest} 17 | networks: 18 | - proxy 19 | deploy: 20 | labels: 21 | - com.df.distribute=true 22 | - com.df.notify=true 23 | - com.df.port=80 24 | - com.df.serviceDomain=cron.dockerflow.com 25 | - com.df.servicePath=/ 26 | - com.df.alertName=memlimit 27 | - com.df.alertIf=@service_mem_limit:0.8 28 | - com.df.alertFor=30s 29 | replicas: 2 30 | resources: 31 | reservations: 32 | memory: 5M 33 | limits: 34 | memory: 10M 35 | 36 | networks: 37 | proxy: 38 | external: true 39 | --------------------------------------------------------------------------------