├── .dockerignore ├── .editorconfig ├── .gitignore ├── .gometalinter.json ├── .travis.yml ├── CONTRIBUTING.md ├── Dockerfile ├── Gopkg.lock ├── Gopkg.toml ├── LICENSE ├── Makefile ├── README.md ├── default.yaml ├── doc.go ├── docker-compose.yml ├── icon.svg └── src ├── aggregator ├── aggregator.go ├── cmd │ ├── root.go │ └── version.go └── consumers │ ├── state.go │ └── task.go ├── api ├── api.go ├── cmd │ ├── initDatabase.go │ ├── root.go │ └── version.go ├── controllers │ ├── auth │ │ ├── auth_controller.go │ │ └── schema │ │ │ ├── accessQuery.json │ │ │ ├── authQuery.json │ │ │ ├── definitions.json │ │ │ └── loginQuery.json │ ├── task │ │ ├── schema │ │ │ ├── create.json │ │ │ └── definitions.json │ │ └── task.go │ ├── tasks │ │ └── tasks.go │ ├── user │ │ ├── schema │ │ │ ├── create.json │ │ │ ├── definitions.json │ │ │ └── edit.json │ │ └── user_controller.go │ └── ws │ │ └── ws_controller.go ├── core │ ├── JSON.go │ ├── assets.go │ ├── io │ │ ├── in │ │ │ └── JSON.go │ │ └── out │ │ │ └── JSON.go │ ├── kafka.go │ ├── oauth │ │ ├── accessToken.go │ │ └── refreshToken.go │ ├── requestLogger.go │ └── ws │ │ └── client.go ├── factories │ └── errors.go ├── models │ ├── bearerToken.go │ ├── error.go │ ├── taskAns.go │ ├── token.go │ └── user.go ├── routers │ ├── auth.go │ ├── router.go │ ├── task.go │ ├── tasks.go │ ├── user.go │ └── ws.go └── services │ ├── auth │ └── auth_service.go │ ├── task │ └── task_service.go │ ├── tasks │ └── tasks_service.go │ └── user │ └── user_service.go ├── metronome ├── core │ ├── pbkdf2.go │ └── sha256.go ├── kafka │ └── kafka.go ├── metrics │ └── metrics.go ├── models │ ├── job.go │ ├── state.go │ └── task.go ├── pg │ ├── assets.go │ ├── pg.go │ └── schema │ │ ├── extensions.sql │ │ ├── tasks.sql │ │ ├── tokens.sql │ │ └── users.sql └── redis │ └── redis.go ├── scheduler ├── cmd │ ├── root.go │ └── version.go ├── core │ ├── core_suite_test.go │ ├── duration.go │ ├── entry.go │ └── entry_test.go ├── routines │ ├── jobConsumer.go │ ├── jobProducer.go │ ├── taskConsumer.go │ └── taskScheduler.go └── scheduler.go └── worker ├── cmd ├── root.go └── version.go ├── consumers └── jobs.go └── worker.go /.dockerignore: -------------------------------------------------------------------------------- 1 | build 2 | docs 3 | vendor 4 | scripts 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs 2 | # editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | build 10 | vendor 11 | 12 | # Architecture specific extensions/prefixes 13 | *.[568vq] 14 | [568vq].out 15 | 16 | *.cgo1.go 17 | *.cgo2.c 18 | _cgo_defun.c 19 | _cgo_gotypes.go 20 | _cgo_export.* 21 | 22 | _testmain.go 23 | 24 | .sync-config.cson 25 | 26 | *.exe 27 | *.test 28 | *.bench 29 | 30 | # Packr generated files 31 | *-packr.go 32 | 33 | # coverage files 34 | coverage.txt 35 | *.coverprofile -------------------------------------------------------------------------------- /.gometalinter.json: -------------------------------------------------------------------------------- 1 | { 2 | "Vendor": true, 3 | "DeadLine": "180s", 4 | "Enable": [ 5 | "gofmt", 6 | "vet", 7 | "golint", 8 | "ineffassign", 9 | "misspell", 10 | "staticcheck", 11 | "unused", 12 | "interfacer", 13 | "deadcode" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.10.x 5 | 6 | # this is necessary for fork builds to work 7 | go_import_path: github.com/ovh/metronome 8 | 9 | before_install: 10 | - make init 11 | 12 | script: 13 | - make dep 14 | - make format 15 | - make lint 16 | - make release 17 | - make test 18 | - make cover 19 | 20 | after_success: 21 | - bash <(curl -s https://codecov.io/bash) 22 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Metronome 2 | 3 | This project accepts contributions. In order to contribute, you should pay attention to a few things: 4 | 5 | 1. your code must follow the coding style rules 6 | 2. your code must be unit-tested 7 | 3. your code must be documented 8 | 4. your work must be signed (see below) 9 | 5. you may contribute through GitHub Pull Requests 10 | 11 | # Coding and documentation Style 12 | 13 | ## LANGUAGE_GUIDELINES 14 | 15 | # Submitting Modifications 16 | 17 | The contributions should be submitted through Github Pull Requests and follow the DCO which is defined below. 18 | 19 | # Licensing for new files 20 | 21 | Metronome is licensed under a Modified 3-Clause BSD license. Anything contributed to Metronome must be released under this license. 22 | 23 | When introducing a new file into the project, please make sure it has a copyright header making clear under which license it's being released. 24 | 25 | # Developer Certificate of Origin (DCO) 26 | 27 | To improve tracking of contributions to this project we will use a process modeled on the modified DCO 1.1 and use a "sign-off" procedure on patches that are being emailed around or contributed in any other way. 28 | 29 | The sign-off is a simple line at the end of the explanation for the patch, which certifies that you wrote it or otherwise have the right to pass it on as an open-source patch. The rules are pretty simple: if you can certify the below: 30 | 31 | By making a contribution to this project, I certify that: 32 | 33 | (a) The contribution was created in whole or in part by me and I have the right to submit it under the open source license indicated in the file; or 34 | 35 | (b) The contribution is based upon previous work that, to the best of my knowledge, is covered under an appropriate open source License and I have the right under that license to submit that work with modifications, whether created in whole or in part by me, under the same open source license (unless I am permitted to submit under a different license), as indicated in the file; or 36 | 37 | (c) The contribution was provided directly to me by some other person who certified (a), (b) or (c) and I have not modified it. 38 | 39 | (d) The contribution is made free of any other party's intellectual property claims or rights. 40 | 41 | (e) I understand and agree that this project and the contribution are public and that a record of the contribution (including all personal information I submit with it, including my sign-off) is maintained indefinitely and may be redistributed consistent with this project or the open source license(s) involved. 42 | 43 | then you just add a line saying 44 | 45 | ``` 46 | Signed-off-by: Random J Developer 47 | ``` 48 | 49 | using your real name (sorry, no pseudonyms or anonymous contributions.) 50 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.10.1 2 | MAINTAINER d33d33 3 | 4 | EXPOSE 8080 5 | 6 | # Setup work directory 7 | RUN mkdir -p /go/src/github.com/ovh/metronome 8 | WORKDIR /go/src/github.com/ovh/metronome 9 | 10 | # Get wait-for-it 11 | ADD https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh ./wait-for-it.sh 12 | RUN chmod +x ./wait-for-it.sh 13 | 14 | # Install dep 15 | RUN go get -u github.com/golang/dep/... 16 | RUN go get -u github.com/gobuffalo/packr/... 17 | 18 | # Setup GO ENV 19 | ENV GOPATH /go 20 | ENV PATH $PATH:/usr/local/go/bin:$GOPATH/bin 21 | 22 | # Copy source 23 | COPY src ./src 24 | 25 | # Install dependencies 26 | COPY Gopkg.toml ./Gopkg.toml 27 | COPY Gopkg.lock ./Gopkg.lock 28 | RUN dep ensure -v 29 | 30 | # Build metronome 31 | COPY Makefile ./Makefile 32 | RUN make install 33 | 34 | # Use default config 35 | COPY default.yaml ./default.yaml 36 | -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | name = "github.com/Shopify/sarama" 6 | packages = ["."] 7 | revision = "f7be6aa2bc7b2e38edf816b08b582782194a1c02" 8 | version = "v1.16.0" 9 | 10 | [[projects]] 11 | branch = "master" 12 | name = "github.com/beorn7/perks" 13 | packages = ["quantile"] 14 | revision = "3a771d992973f24aa725d07868b467d1ddfceafb" 15 | 16 | [[projects]] 17 | name = "github.com/bsm/sarama-cluster" 18 | packages = ["."] 19 | revision = "cf455bc755fe41ac9bb2861e7a961833d9c2ecc3" 20 | version = "v2.1.13" 21 | 22 | [[projects]] 23 | name = "github.com/davecgh/go-spew" 24 | packages = ["spew"] 25 | revision = "346938d642f2ec3594ed81d874461961cd0faa76" 26 | version = "v1.1.0" 27 | 28 | [[projects]] 29 | name = "github.com/dgrijalva/jwt-go" 30 | packages = ["."] 31 | revision = "06ea1031745cb8b3dab3f6a236daf2b0aa468b7e" 32 | version = "v3.2.0" 33 | 34 | [[projects]] 35 | name = "github.com/eapache/go-resiliency" 36 | packages = ["breaker"] 37 | revision = "ea41b0fad31007accc7f806884dcdf3da98b79ce" 38 | version = "v1.1.0" 39 | 40 | [[projects]] 41 | branch = "master" 42 | name = "github.com/eapache/go-xerial-snappy" 43 | packages = ["."] 44 | revision = "bb955e01b9346ac19dc29eb16586c90ded99a98c" 45 | 46 | [[projects]] 47 | name = "github.com/eapache/queue" 48 | packages = ["."] 49 | revision = "44cc805cf13205b55f69e14bcb69867d1ae92f98" 50 | version = "v1.1.0" 51 | 52 | [[projects]] 53 | name = "github.com/fsnotify/fsnotify" 54 | packages = ["."] 55 | revision = "c2828203cd70a50dcccfb2761f8b1f8ceef9a8e9" 56 | version = "v1.4.7" 57 | 58 | [[projects]] 59 | name = "github.com/gobuffalo/packr" 60 | packages = ["."] 61 | revision = "7f4074995d431987caaa35088199f13c44b24440" 62 | version = "v1.11.0" 63 | 64 | [[projects]] 65 | name = "github.com/golang/protobuf" 66 | packages = ["proto"] 67 | revision = "925541529c1fa6821df4e44ce2723319eb2be768" 68 | version = "v1.0.0" 69 | 70 | [[projects]] 71 | branch = "master" 72 | name = "github.com/golang/snappy" 73 | packages = ["."] 74 | revision = "553a641470496b2327abcac10b36396bd98e45c9" 75 | 76 | [[projects]] 77 | name = "github.com/gorilla/context" 78 | packages = ["."] 79 | revision = "1ea25387ff6f684839d82767c1733ff4d4d15d0a" 80 | version = "v1.1" 81 | 82 | [[projects]] 83 | name = "github.com/gorilla/mux" 84 | packages = ["."] 85 | revision = "53c1911da2b537f792e7cafcb446b05ffe33b996" 86 | version = "v1.6.1" 87 | 88 | [[projects]] 89 | name = "github.com/gorilla/websocket" 90 | packages = ["."] 91 | revision = "ea4d1f681babbce9545c9c5f3d5194a789c89f5b" 92 | version = "v1.2.0" 93 | 94 | [[projects]] 95 | branch = "master" 96 | name = "github.com/hashicorp/hcl" 97 | packages = [ 98 | ".", 99 | "hcl/ast", 100 | "hcl/parser", 101 | "hcl/printer", 102 | "hcl/scanner", 103 | "hcl/strconv", 104 | "hcl/token", 105 | "json/parser", 106 | "json/scanner", 107 | "json/token" 108 | ] 109 | revision = "ef8a98b0bbce4a65b5aa4c368430a80ddc533168" 110 | 111 | [[projects]] 112 | name = "github.com/inconshreveable/mousetrap" 113 | packages = ["."] 114 | revision = "76626ae9c91c4f2a10f34cad8ce83ea42c93bb75" 115 | version = "v1.0" 116 | 117 | [[projects]] 118 | branch = "master" 119 | name = "github.com/jinzhu/inflection" 120 | packages = ["."] 121 | revision = "04140366298a54a039076d798123ffa108fff46c" 122 | 123 | [[projects]] 124 | name = "github.com/magiconair/properties" 125 | packages = ["."] 126 | revision = "c3beff4c2358b44d0493c7dda585e7db7ff28ae6" 127 | version = "v1.7.6" 128 | 129 | [[projects]] 130 | name = "github.com/matttproud/golang_protobuf_extensions" 131 | packages = ["pbutil"] 132 | revision = "3247c84500bff8d9fb6d579d800f20b3e091582c" 133 | version = "v1.0.0" 134 | 135 | [[projects]] 136 | branch = "master" 137 | name = "github.com/mitchellh/mapstructure" 138 | packages = ["."] 139 | revision = "00c29f56e2386353d58c599509e8dc3801b0d716" 140 | 141 | [[projects]] 142 | name = "github.com/onsi/ginkgo" 143 | packages = [ 144 | ".", 145 | "config", 146 | "extensions/table", 147 | "internal/codelocation", 148 | "internal/containernode", 149 | "internal/failer", 150 | "internal/leafnodes", 151 | "internal/remote", 152 | "internal/spec", 153 | "internal/spec_iterator", 154 | "internal/specrunner", 155 | "internal/suite", 156 | "internal/testingtproxy", 157 | "internal/writer", 158 | "reporters", 159 | "reporters/stenographer", 160 | "reporters/stenographer/support/go-colorable", 161 | "reporters/stenographer/support/go-isatty", 162 | "types" 163 | ] 164 | revision = "9eda700730cba42af70d53180f9dcce9266bc2bc" 165 | version = "v1.4.0" 166 | 167 | [[projects]] 168 | name = "github.com/onsi/gomega" 169 | packages = [ 170 | ".", 171 | "format", 172 | "internal/assertion", 173 | "internal/asyncassertion", 174 | "internal/oraclematcher", 175 | "internal/testingtsupport", 176 | "matchers", 177 | "matchers/support/goraph/bipartitegraph", 178 | "matchers/support/goraph/edge", 179 | "matchers/support/goraph/node", 180 | "matchers/support/goraph/util", 181 | "types" 182 | ] 183 | revision = "003f63b7f4cff3fc95357005358af2de0f5fe152" 184 | version = "v1.3.0" 185 | 186 | [[projects]] 187 | name = "github.com/pelletier/go-toml" 188 | packages = ["."] 189 | revision = "acdc4509485b587f5e675510c4f2c63e90ff68a8" 190 | version = "v1.1.0" 191 | 192 | [[projects]] 193 | name = "github.com/pierrec/lz4" 194 | packages = ["."] 195 | revision = "2fcda4cb7018ce05a25959d2fe08c83e3329f169" 196 | version = "v1.1" 197 | 198 | [[projects]] 199 | name = "github.com/pierrec/xxHash" 200 | packages = ["xxHash32"] 201 | revision = "f051bb7f1d1aaf1b5a665d74fb6b0217712c69f7" 202 | version = "v0.1.1" 203 | 204 | [[projects]] 205 | name = "github.com/pkg/errors" 206 | packages = ["."] 207 | revision = "645ef00459ed84a119197bfb8d8205042c6df63d" 208 | version = "v0.8.0" 209 | 210 | [[projects]] 211 | name = "github.com/prometheus/client_golang" 212 | packages = [ 213 | "prometheus", 214 | "prometheus/promhttp" 215 | ] 216 | revision = "c5b7fccd204277076155f10851dad72b76a49317" 217 | version = "v0.8.0" 218 | 219 | [[projects]] 220 | branch = "master" 221 | name = "github.com/prometheus/client_model" 222 | packages = ["go"] 223 | revision = "99fa1f4be8e564e8a6b613da7fa6f46c9edafc6c" 224 | 225 | [[projects]] 226 | branch = "master" 227 | name = "github.com/prometheus/common" 228 | packages = [ 229 | "expfmt", 230 | "internal/bitbucket.org/ww/goautoneg", 231 | "model" 232 | ] 233 | revision = "d0f7cd64bda49e08b22ae8a730aa57aa0db125d6" 234 | 235 | [[projects]] 236 | branch = "master" 237 | name = "github.com/prometheus/procfs" 238 | packages = [ 239 | ".", 240 | "internal/util", 241 | "nfs", 242 | "xfs" 243 | ] 244 | revision = "8b1c2da0d56deffdbb9e48d4414b4e674bd8083e" 245 | 246 | [[projects]] 247 | branch = "master" 248 | name = "github.com/rcrowley/go-metrics" 249 | packages = ["."] 250 | revision = "d932a24a8ccb8fcadc993e5c6c58f93dac168294" 251 | 252 | [[projects]] 253 | name = "github.com/rs/cors" 254 | packages = ["."] 255 | revision = "feef513b9575b32f84bafa580aad89b011259019" 256 | version = "v1.3.0" 257 | 258 | [[projects]] 259 | name = "github.com/satori/go.uuid" 260 | packages = ["."] 261 | revision = "f58768cc1a7a7e77a3bd49e98cdd21419399b6a3" 262 | version = "v1.2.0" 263 | 264 | [[projects]] 265 | name = "github.com/sirupsen/logrus" 266 | packages = ["."] 267 | revision = "c155da19408a8799da419ed3eeb0cb5db0ad5dbc" 268 | version = "v1.0.5" 269 | 270 | [[projects]] 271 | name = "github.com/spf13/afero" 272 | packages = [ 273 | ".", 274 | "mem" 275 | ] 276 | revision = "63644898a8da0bc22138abf860edaf5277b6102e" 277 | version = "v1.1.0" 278 | 279 | [[projects]] 280 | name = "github.com/spf13/cast" 281 | packages = ["."] 282 | revision = "8965335b8c7107321228e3e3702cab9832751bac" 283 | version = "v1.2.0" 284 | 285 | [[projects]] 286 | name = "github.com/spf13/cobra" 287 | packages = ["."] 288 | revision = "a1f051bc3eba734da4772d60e2d677f47cf93ef4" 289 | version = "v0.0.2" 290 | 291 | [[projects]] 292 | branch = "master" 293 | name = "github.com/spf13/jwalterweatherman" 294 | packages = ["."] 295 | revision = "7c0cea34c8ece3fbeb2b27ab9b59511d360fb394" 296 | 297 | [[projects]] 298 | name = "github.com/spf13/pflag" 299 | packages = ["."] 300 | revision = "583c0c0531f06d5278b7d917446061adc344b5cd" 301 | version = "v1.0.1" 302 | 303 | [[projects]] 304 | name = "github.com/spf13/viper" 305 | packages = ["."] 306 | revision = "b5e8006cbee93ec955a89ab31e0e3ce3204f3736" 307 | version = "v1.0.2" 308 | 309 | [[projects]] 310 | branch = "master" 311 | name = "github.com/unrolled/render" 312 | packages = ["."] 313 | revision = "65450fb6b2d3595beca39f969c411db8f8d5c806" 314 | 315 | [[projects]] 316 | name = "github.com/urfave/negroni" 317 | packages = ["."] 318 | revision = "5dbbc83f748fc3ad38585842b0aedab546d0ea1e" 319 | version = "v0.3.0" 320 | 321 | [[projects]] 322 | branch = "master" 323 | name = "github.com/xeipuuv/gojsonpointer" 324 | packages = ["."] 325 | revision = "4e3ac2762d5f479393488629ee9370b50873b3a6" 326 | 327 | [[projects]] 328 | branch = "master" 329 | name = "github.com/xeipuuv/gojsonreference" 330 | packages = ["."] 331 | revision = "bd5ef7bd5415a7ac448318e64f11a24cd21e594b" 332 | 333 | [[projects]] 334 | branch = "master" 335 | name = "github.com/xeipuuv/gojsonschema" 336 | packages = ["."] 337 | revision = "2c8e4be869c17540b336bab0ea18b8d73e6a28b7" 338 | 339 | [[projects]] 340 | branch = "master" 341 | name = "golang.org/x/crypto" 342 | packages = [ 343 | "bcrypt", 344 | "blowfish", 345 | "pbkdf2", 346 | "ssh/terminal" 347 | ] 348 | revision = "d6449816ce06963d9d136eee5a56fca5b0616e7e" 349 | 350 | [[projects]] 351 | branch = "master" 352 | name = "golang.org/x/net" 353 | packages = [ 354 | "html", 355 | "html/atom", 356 | "html/charset" 357 | ] 358 | revision = "d41e8174641f662c5a2d1c7a5f9e828788eb8706" 359 | 360 | [[projects]] 361 | branch = "master" 362 | name = "golang.org/x/sys" 363 | packages = [ 364 | "unix", 365 | "windows" 366 | ] 367 | revision = "a2a45943ae67364d56c5d7d62dee78cff16c8dc8" 368 | 369 | [[projects]] 370 | name = "golang.org/x/text" 371 | packages = [ 372 | "encoding", 373 | "encoding/charmap", 374 | "encoding/htmlindex", 375 | "encoding/internal", 376 | "encoding/internal/identifier", 377 | "encoding/japanese", 378 | "encoding/korean", 379 | "encoding/simplifiedchinese", 380 | "encoding/traditionalchinese", 381 | "encoding/unicode", 382 | "internal/gen", 383 | "internal/tag", 384 | "internal/triegen", 385 | "internal/ucd", 386 | "internal/utf8internal", 387 | "language", 388 | "runes", 389 | "transform", 390 | "unicode/cldr", 391 | "unicode/norm" 392 | ] 393 | revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0" 394 | version = "v0.3.0" 395 | 396 | [[projects]] 397 | name = "gopkg.in/pg.v5" 398 | packages = [ 399 | ".", 400 | "internal", 401 | "internal/parser", 402 | "internal/pool", 403 | "orm", 404 | "types" 405 | ] 406 | revision = "2246060a2a43f8282ad53295d56d780dbc930b7f" 407 | version = "v5.3.3" 408 | 409 | [[projects]] 410 | name = "gopkg.in/redis.v5" 411 | packages = [ 412 | ".", 413 | "internal", 414 | "internal/consistenthash", 415 | "internal/hashtag", 416 | "internal/pool", 417 | "internal/proto" 418 | ] 419 | revision = "a16aeec10ff407b1e7be6dd35797ccf5426ef0f0" 420 | version = "v5.2.9" 421 | 422 | [[projects]] 423 | name = "gopkg.in/yaml.v2" 424 | packages = ["."] 425 | revision = "5420a8b6744d3b0345ab293f6fcba19c978f1183" 426 | version = "v2.2.1" 427 | 428 | [solve-meta] 429 | analyzer-name = "dep" 430 | analyzer-version = 1 431 | inputs-digest = "e510d9a4dce2b81f71552783858626ea7376a2e078a108b111768988ce9d82a7" 432 | solver-name = "gps-cdcl" 433 | solver-version = 1 434 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | # Gopkg.toml example 2 | # 3 | # Refer to https://golang.github.io/dep/docs/Gopkg.toml.html 4 | # for detailed Gopkg.toml documentation. 5 | # 6 | # required = ["github.com/user/thing/cmd/thing"] 7 | # ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] 8 | # 9 | # [[constraint]] 10 | # name = "github.com/user/project" 11 | # version = "1.0.0" 12 | # 13 | # [[constraint]] 14 | # name = "github.com/user/project2" 15 | # branch = "dev" 16 | # source = "github.com/myfork/project2" 17 | # 18 | # [[override]] 19 | # name = "github.com/x/y" 20 | # version = "2.4.0" 21 | # 22 | # [prune] 23 | # non-go = false 24 | # go-tests = true 25 | # unused-packages = true 26 | 27 | 28 | [[constraint]] 29 | name = "github.com/Shopify/sarama" 30 | version = "1.16.0" 31 | 32 | [[constraint]] 33 | name = "github.com/bsm/sarama-cluster" 34 | version = "2.1.13" 35 | 36 | [[constraint]] 37 | name = "github.com/dgrijalva/jwt-go" 38 | version = "3.2.0" 39 | 40 | [[constraint]] 41 | name = "github.com/gorilla/mux" 42 | version = "1.6.1" 43 | 44 | [[constraint]] 45 | name = "github.com/gorilla/websocket" 46 | version = "1.2.0" 47 | 48 | [[constraint]] 49 | name = "github.com/onsi/ginkgo" 50 | version = "1.4.0" 51 | 52 | [[constraint]] 53 | name = "github.com/onsi/gomega" 54 | version = "1.3.0" 55 | 56 | [[constraint]] 57 | name = "github.com/prometheus/client_golang" 58 | version = "0.8.0" 59 | 60 | [[constraint]] 61 | name = "github.com/rs/cors" 62 | version = "1.3.0" 63 | 64 | [[constraint]] 65 | name = "github.com/sirupsen/logrus" 66 | version = "1.0.5" 67 | 68 | [[constraint]] 69 | name = "github.com/spf13/cobra" 70 | version = "0.0.2" 71 | 72 | [[constraint]] 73 | name = "github.com/spf13/viper" 74 | version = "1.0.2" 75 | 76 | [[constraint]] 77 | branch = "master" 78 | name = "github.com/unrolled/render" 79 | 80 | [[constraint]] 81 | name = "github.com/urfave/negroni" 82 | version = "0.3.0" 83 | 84 | [[constraint]] 85 | branch = "master" 86 | name = "github.com/xeipuuv/gojsonschema" 87 | 88 | [[constraint]] 89 | branch = "master" 90 | name = "golang.org/x/crypto" 91 | 92 | [[constraint]] 93 | name = "gopkg.in/pg.v5" 94 | version = "5.3.3" 95 | 96 | [[constraint]] 97 | name = "gopkg.in/redis.v5" 98 | version = "5.2.9" 99 | 100 | [prune] 101 | go-tests = true 102 | unused-packages = true 103 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013-2016, OVH SAS. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above copyright 10 | notice, this list of conditions and the following disclaimer in the 11 | documentation and/or other materials provided with the distribution. 12 | * Neither the name of OVH SAS nor the 13 | names of its contributors may be used to endorse or promote products 14 | derived from this software without specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY OVH SAS AND CONTRIBUTORS ``AS IS'' AND ANY 17 | EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 18 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL OVH SAS AND CONTRIBUTORS BE LIABLE FOR ANY 20 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 21 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 23 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 24 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 25 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Variables 2 | BUILD_DIR := build 3 | GITHASH := $(shell git rev-parse HEAD) 4 | VERSION := $(shell git describe --abbrev=0 --tags --always) 5 | DATE := $(shell TZ=UTC date -u '+%Y-%m-%dT%H:%M:%SZ UTC') 6 | LINT_PATHS := ./src/... 7 | FORMAT_PATHS := ./src 8 | 9 | # Compilation variables 10 | CC := go build 11 | DFLAGS := -race 12 | CFLAGS := -X 'github.com/ovh/metronome/src/aggregator/cmd.githash=$(GITHASH)' \ 13 | -X 'github.com/ovh/metronome/src/aggregator/cmd.date=$(DATE)' \ 14 | -X 'github.com/ovh/metronome/src/aggregator/cmd.version=$(VERSION)' \ 15 | -X 'github.com/ovh/metronome/src/api/cmd.githash=$(GITHASH)' \ 16 | -X 'github.com/ovh/metronome/src/api/cmd.date=$(DATE)' \ 17 | -X 'github.com/ovh/metronome/src/api/cmd.version=$(VERSION)' \ 18 | -X 'github.com/ovh/metronome/src/scheduler/cmd.githash=$(GITHASH)' \ 19 | -X 'github.com/ovh/metronome/src/scheduler/cmd.date=$(DATE)' \ 20 | -X 'github.com/ovh/metronome/src/scheduler/cmd.version=$(VERSION)' \ 21 | -X 'github.com/ovh/metronome/src/worker/cmd.githash=$(GITHASH)' \ 22 | -X 'github.com/ovh/metronome/src/worker/cmd.date=$(DATE)' \ 23 | -X 'github.com/ovh/metronome/src/worker/cmd.version=$(VERSION)' 24 | CROSS := GOOS=linux GOARCH=amd64 25 | 26 | # Makefile variables 27 | VPATH := $(BUILD_DIR) 28 | 29 | # Function definitions 30 | rwildcard := $(foreach d,$(wildcard $1*),$(call rwildcard,$d/,$2) $(filter $(subst *,%,$2),$d)) 31 | 32 | .SECONDEXPANSION: 33 | .PHONY: all 34 | all: init dep format lint release 35 | 36 | .PHONY: init 37 | init: 38 | go get -u github.com/golang/dep/... 39 | go get -u github.com/alecthomas/gometalinter 40 | go get -u github.com/gobuffalo/packr/... 41 | go get -u github.com/onsi/ginkgo/ginkgo 42 | go get -u golang.org/x/tools/cmd/cover 43 | go get -u github.com/modocache/gover 44 | $(GOPATH)/bin/gometalinter --install --no-vendored-linters 45 | 46 | .PHONY: clean 47 | clean: 48 | rm -rf $(BUILD_DIR) 49 | rm -rf dist 50 | $(GOPATH)/bin/packr clean -v 51 | 52 | .PHONY: dep 53 | dep: assets 54 | $(GOPATH)/bin/dep ensure -v 55 | 56 | .PHONY: format 57 | format: 58 | gofmt -w -s $(FORMAT_PATHS) 59 | 60 | .PHONY: lint 61 | lint: 62 | $(GOPATH)/bin/gometalinter --disable-all --config .gometalinter.json $(LINT_PATHS) 63 | 64 | .PHONY: test 65 | test: 66 | $(GOPATH)/bin/ginkgo -r --randomizeAllSpecs --randomizeSuites --failOnPending --cover --trace --race --progress --compilers=2 67 | 68 | .PHONY: testrun 69 | testrun: 70 | $(GOPATH)/bin/ginkgo watch -r ./src 71 | 72 | .PHONY: cover 73 | cover: 74 | $(GOPATH)/bin/gover ./src coverage.txt 75 | 76 | .PHONY: assets 77 | assets: src/api/core/core-packr.go src/metronome/pg/pg-packr.go 78 | 79 | %-packr.go: 80 | $(GOPATH)/bin/packr -v 81 | 82 | .PHONY: dev 83 | dev: format lint build 84 | 85 | .PHONY: build 86 | build: assets $$(call rwildcard, ./src/aggregator, *.go) $$(call rwildcard, ./src/api, *.go) $$(call rwildcard, ./src/worker, *.go) $$(call rwildcard, ./src/scheduler, *.go) $$(call rwildcard, ./src/metronome, *.go) 87 | $(CC) $(DFLAGS) -ldflags "-s -w $(CFLAGS)" -o $(BUILD_DIR)/aggregator src/aggregator/aggregator.go 88 | $(CC) $(DFLAGS) -ldflags "-s -w $(CFLAGS)" -o $(BUILD_DIR)/api src/api/api.go 89 | $(CC) $(DFLAGS) -ldflags "-s -w $(CFLAGS)" -o $(BUILD_DIR)/scheduler src/scheduler/scheduler.go 90 | $(CC) $(DFLAGS) -ldflags "-s -w $(CFLAGS)" -o $(BUILD_DIR)/worker src/worker/worker.go 91 | 92 | .PHONY: release 93 | release: assets $$(call rwildcard, ./src/aggregator, *.go) $$(call rwildcard, ./src/api, *.go) $$(call rwildcard, ./src/worker, *.go) $$(call rwildcard, ./src/scheduler, *.go) $$(call rwildcard, ./src/metronome, *.go) 94 | $(CC) -ldflags "-s -w $(CFLAGS)" -o $(BUILD_DIR)/aggregator src/aggregator/aggregator.go 95 | $(CC) -ldflags "-s -w $(CFLAGS)" -o $(BUILD_DIR)/api src/api/api.go 96 | $(CC) -ldflags "-s -w $(CFLAGS)" -o $(BUILD_DIR)/scheduler src/scheduler/scheduler.go 97 | $(CC) -ldflags "-s -w $(CFLAGS)" -o $(BUILD_DIR)/worker src/worker/worker.go 98 | 99 | .PHONY: dist 100 | dist: assets $$(call rwildcard, ./src/aggregator, *.go) $$(call rwildcard, ./src/api, *.go) $$(call rwildcard, ./src/worker, *.go) $$(call rwildcard, ./src/scheduler, *.go) $$(call rwildcard, ./src/metronome, *.go) 101 | $(CROSS) $(CC) -ldflags "-s -w $(CFLAGS)" -o $(BUILD_DIR)/aggregator src/aggregator/aggregator.go 102 | $(CROSS) $(CC) -ldflags "-s -w $(CFLAGS)" -o $(BUILD_DIR)/api src/api/api.go 103 | $(CROSS) $(CC) -ldflags "-s -w $(CFLAGS)" -o $(BUILD_DIR)/scheduler src/scheduler/scheduler.go 104 | $(CROSS) $(CC) -ldflags "-s -w $(CFLAGS)" -o $(BUILD_DIR)/worker src/worker/worker.go 105 | 106 | .PHONY: install 107 | install: release 108 | cp -v $(BUILD_DIR)/aggregator $(GOPATH)/bin/metronome-aggregator 109 | cp -v $(BUILD_DIR)/api $(GOPATH)/bin/metronome-api 110 | cp -v $(BUILD_DIR)/scheduler $(GOPATH)/bin/metronome-scheduler 111 | cp -v $(BUILD_DIR)/worker $(GOPATH)/bin/metronome-worker 112 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

 Metronome - Distributed, fault tolerant scheduler

3 | 4 | [![version](https://img.shields.io/badge/status-alpha-orange.svg)](https://github.com/ovh/**metronome**) 5 | [![Build Status](https://travis-ci.org/ovh/metronome.svg?branch=ci)](https://travis-ci.org/ovh/metronome) 6 | [![codecov](https://codecov.io/gh/ovh/metronome/branch/master/graph/badge.svg)](https://codecov.io/gh/ovh/metronome) 7 | [![Go Report Card](https://goreportcard.com/badge/github.com/ovh/metronome)](https://goreportcard.com/report/github.com/ovh/metronome) 8 | [![GoDoc](https://godoc.org/github.com/ovh/metronome?status.svg)](https://godoc.org/github.com/ovh/metronome) 9 | [![Join the chat at https://gitter.im/ovh-metronome/Lobby](https://badges.gitter.im/ovh-metronome/Lobby.svg)](https://gitter.im/ovh-metronome/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 10 | 11 | Metronome is a distributed and fault-tolerant event scheduler. It can be used to trigger remote systems throught events (HTTP, AMQP, KAFKA). 12 | 13 | Metronome is written in Go and leverage the power of kafka streams to provide fault tolerance, reliability and scalability. 14 | 15 | Metronome take a new approach to the job scheduling problem, as it only handle job scheduling not effective execution. Effective job execution is perform by triggered external system. 16 | 17 | Metronome has a number of advantages over regular cron: 18 | - Jobs can be written in any language, using any technology as it only trigger event. 19 | - Jobs are schedule using [ISO8601][ISO8601] repeating interval notation, which enables more flexibility. 20 | - It is able to handle high volumes of scheduled jobs in a completely fault way. 21 | - Easy admin, thanks to a great [UI][UI]. 22 | 23 | ## Status 24 | 25 | Currently Metronome is under heavy development. 26 | 27 | ## Quick start 28 | 29 | The best way to test and develop Metronome is using docker, you will need [Docker Toolbox](https://www.docker.com/docker-toolbox) installed before proceding. 30 | 31 | - Clone the repository. 32 | 33 | - Run the included Docker Compose config: 34 | 35 | `docker-compose up -d` 36 | 37 | This will start, PostgreSQL, Redis, Kafka and Metronome instances. 38 | 39 | Open your browser and navigate to `localhost:8081` 40 | 41 | ## Contributing 42 | 43 | Instructions on how to contribute to Metronome are available on the [Contributing][Contributing] page. 44 | 45 | ## Get in touch 46 | 47 | - Twitter: [@notd33d33](https://twitter.com/notd33d33) 48 | 49 | [UI]: https://github.com/ovh/metronome-ui 50 | [Contributing]: CONTRIBUTING.md 51 | [ISO8601]: http://en.wikipedia.org/wiki/ISO_8601 "ISO8601 Standard" 52 | -------------------------------------------------------------------------------- /default.yaml: -------------------------------------------------------------------------------- 1 | pg: 2 | user: metronome 3 | password: metropass 4 | database: metronome 5 | 6 | token: 7 | key: 0323354b2b0fefbda5efbdbce3839f0665777befbda6e4bd8fefbdb328e8b7bc54efbe8928efbda90fe294abefbe92502eefbdbfefbe93e787bee8bebb0647efbfbde6849fefbe8377623d223d2e2172052e4f08efbe80efbe8de5a58e67efbe901aefbda3 8 | ttl: 259200 # 3days 9 | 10 | kafka: 11 | brokers: 12 | - localhost:9092 13 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Metronome is a distributed and fault-tolerant event scheduler 2 | // 3 | // Agents 4 | // 5 | // Metronome as four agents: 6 | // - api: HTTP api to manage the tasks 7 | // - scheduler: plan tasks executions 8 | // - aggregator: maintain the databasse 9 | // - worker: perform HTTP POST to trigger remote systems according to the tasks schedules 10 | package main 11 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | # databases 4 | postgres: 5 | image: postgres 6 | expose: 7 | - "5432" 8 | environment: 9 | POSTGRES_USER: metronome 10 | POSTGRES_PASSWORD: metropass 11 | POSTGRES_DB: metronome 12 | redis: 13 | image: redis 14 | 15 | # kafka 16 | zookeeper: 17 | image: wurstmeister/zookeeper 18 | expose: 19 | - "2181" 20 | kafka: 21 | links: 22 | - zookeeper 23 | image: wurstmeister/kafka 24 | expose: 25 | - "9092" 26 | environment: 27 | KAFKA_ADVERTISED_HOST_NAME: "kafka" 28 | KAFKA_ADVERTISED_PORT: "9092" 29 | KAFKA_CREATE_TOPICS: "tasks:1:1:compact,jobs:1:1,states:1:1" 30 | KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'false' 31 | KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 32 | 33 | ui: 34 | build: git://github.com/ovh/metronome-ui 35 | ports: 36 | - "8080:8080" 37 | 38 | init_db: 39 | links: 40 | - postgres 41 | build: . 42 | command: ./wait-for-it.sh postgres:5432 -- metronome-api init-database --pg.addr=postgres:5432 43 | 44 | api: 45 | links: 46 | - init_db 47 | - kafka 48 | - redis 49 | build: . 50 | ports: 51 | - "8081:8080" 52 | command: ./wait-for-it.sh kafka:9092 -- metronome-api --pg.addr=postgres:5432 --kafka.brokers=kafka:9092 --redis.addr=redis:6379 53 | scheduler: 54 | links: 55 | - kafka 56 | build: . 57 | command: ./wait-for-it.sh kafka:9092 -- metronome-scheduler --kafka.brokers=kafka:9092 --redis.addr=redis:6379 58 | aggregator: 59 | links: 60 | - postgres 61 | - kafka 62 | - redis 63 | build: . 64 | command: ./wait-for-it.sh kafka:9092 -- metronome-aggregator --pg.addr=postgres:5432 --kafka.brokers=kafka:9092 --redis.addr=redis:6379 65 | worker: 66 | links: 67 | - kafka 68 | build: . 69 | command: ./wait-for-it.sh kafka:9092 -- metronome-worker --kafka.brokers=kafka:9092 70 | -------------------------------------------------------------------------------- /icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/aggregator/aggregator.go: -------------------------------------------------------------------------------- 1 | // Aggregator agent consumed tasks definitions from kafka to maintain the tasks database. 2 | // 3 | // You can launch as much Aggregator agent as you want/need as they rely on Kafka partitons to share the workload. 4 | // 5 | // Usage 6 | // 7 | // aggregator [flags] 8 | // Flags: 9 | // --config string config file to use 10 | // --help display help 11 | // -v, --verbose verbose output 12 | package main 13 | 14 | import ( 15 | log "github.com/sirupsen/logrus" 16 | 17 | "github.com/ovh/metronome/src/aggregator/cmd" 18 | ) 19 | 20 | func main() { 21 | if err := cmd.RootCmd.Execute(); err != nil { 22 | log.WithError(err).Error("Could not execute the aggregator") 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/aggregator/cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | "os/signal" 6 | 7 | log "github.com/sirupsen/logrus" 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/viper" 10 | 11 | "github.com/ovh/metronome/src/aggregator/consumers" 12 | "github.com/ovh/metronome/src/metronome/metrics" 13 | ) 14 | 15 | // Aggregator init - define command line arguments. 16 | func init() { 17 | cobra.OnInitialize(initConfig) 18 | 19 | RootCmd.PersistentFlags().StringP("config", "", "", "config file to use") 20 | RootCmd.PersistentFlags().BoolP("verbose", "v", false, "verbose output") 21 | 22 | RootCmd.Flags().String("pg.addr", "127.0.0.1:5432", "postgres address") 23 | RootCmd.Flags().String("pg.user", "metronome", "postgres user") 24 | RootCmd.Flags().String("pg.password", "metropass", "postgres password") 25 | RootCmd.Flags().String("pg.database", "metronome", "postgres database") 26 | RootCmd.Flags().StringSlice("kafka.brokers", []string{"localhost:9092"}, "kafka brokers address") 27 | RootCmd.Flags().String("redis.addr", "127.0.0.1:6379", "redis address") 28 | RootCmd.Flags().String("metrics.addr", "127.0.0.1:9100", "metrics address") 29 | 30 | if err := viper.BindPFlags(RootCmd.PersistentFlags()); err != nil { 31 | log.WithError(err).Error("Could not bind persistent flags") 32 | } 33 | 34 | if err := viper.BindPFlags(RootCmd.Flags()); err != nil { 35 | log.WithError(err).Error("Could not bind flags") 36 | } 37 | } 38 | 39 | // Load config - initialize defaults and read config. 40 | func initConfig() { 41 | if viper.GetBool("verbose") { 42 | log.SetLevel(log.DebugLevel) 43 | } 44 | 45 | // Set defaults 46 | viper.SetDefault("metrics.addr", ":9100") 47 | viper.SetDefault("metrics.path", "/metrics") 48 | viper.SetDefault("redis.pass", "") 49 | viper.SetDefault("kafka.tls", false) 50 | viper.SetDefault("kafka.topics.tasks", "tasks") 51 | viper.SetDefault("kafka.topics.jobs", "jobs") 52 | viper.SetDefault("kafka.topics.states", "states") 53 | viper.SetDefault("kafka.groups.schedulers", "schedulers") 54 | viper.SetDefault("kafka.groups.aggregators", "aggregators") 55 | viper.SetDefault("kafka.groups.workers", "workers") 56 | viper.SetDefault("worker.poolsize", 100) 57 | viper.SetDefault("token.ttl", 3600) 58 | viper.SetDefault("redis.pass", "") 59 | 60 | // Bind environment variables 61 | viper.SetEnvPrefix("mtragg") 62 | viper.AutomaticEnv() 63 | 64 | // Set config search path 65 | viper.AddConfigPath("/etc/metronome/") 66 | viper.AddConfigPath("$HOME/.metronome") 67 | viper.AddConfigPath(".") 68 | 69 | // Load default config 70 | viper.SetConfigName("default") 71 | if err := viper.MergeInConfig(); err != nil { 72 | if _, ok := err.(viper.ConfigFileNotFoundError); ok { 73 | log.Debug("No default config file found") 74 | } else { 75 | log.Panicf("Fatal error in default config file: %v \n", err) 76 | } 77 | } 78 | 79 | // Load aggregator config 80 | viper.SetConfigName("aggregator") 81 | if err := viper.MergeInConfig(); err != nil { 82 | if _, ok := err.(viper.ConfigFileNotFoundError); ok { 83 | log.Debug("No aggregator config file found") 84 | } else { 85 | log.Panicf("Fatal error in aggregator config file: %v \n", err) 86 | } 87 | } 88 | 89 | // Load user defined config 90 | cfgFile := viper.GetString("Config") 91 | if cfgFile != "" { 92 | viper.SetConfigFile(cfgFile) 93 | err := viper.ReadInConfig() 94 | if err != nil { 95 | log.Panicf("Fatal error in config file: %v \n", err) 96 | } 97 | } 98 | } 99 | 100 | // RootCmd launch the aggregator agent. 101 | var RootCmd = &cobra.Command{ 102 | Use: "metronome-aggregator", 103 | Short: "Metronome aggregator update task database", 104 | Long: `Metronome is a distributed and fault-tolerant event scheduler built with love by ovh teams and friends in Go. 105 | Complete documentation is available at http://ovh.github.io/metronome`, 106 | Run: func(cmd *cobra.Command, args []string) { 107 | log.Info("Metronome Aggregator starting") 108 | 109 | metrics.Serve() 110 | 111 | tc, err := consumers.NewTaskConsumer() 112 | if err != nil { 113 | log.WithError(err).Fatal("Could not start the task consumer") 114 | } 115 | 116 | sc, err := consumers.NewStateConsumer() 117 | if err != nil { 118 | log.WithError(err).Fatal("Could not start the state consumer") 119 | } 120 | 121 | log.Info("Started") 122 | 123 | // Trap SIGINT to trigger a shutdown. 124 | sigint := make(chan os.Signal, 1) 125 | signal.Notify(sigint, os.Interrupt) 126 | 127 | <-sigint 128 | 129 | log.Info("Shuting down") 130 | if err := sc.Close(); err != nil { 131 | log.WithError(err).Error("Could not stop gracefully the state consumer") 132 | } 133 | 134 | if err := tc.Close(); err != nil { 135 | log.WithError(err).Error("Could not stop gracefully the task consumer") 136 | } 137 | }, 138 | } 139 | -------------------------------------------------------------------------------- /src/aggregator/cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | var ( 11 | version = "0.0.0" 12 | githash = "HEAD" 13 | date = "1970-01-01T00:00:00Z UTC" 14 | ) 15 | 16 | func init() { 17 | RootCmd.AddCommand(versionCmd) 18 | } 19 | 20 | var versionCmd = &cobra.Command{ 21 | Use: "version", 22 | Short: "Show version", 23 | Run: func(cmd *cobra.Command, arguments []string) { 24 | fmt.Printf("metronome-aggregator version %s %s\n", version, githash) 25 | fmt.Printf("metronome-aggregator build date %s\n", date) 26 | fmt.Printf("go version %s %s/%s\n", runtime.Version(), runtime.GOOS, runtime.GOARCH) 27 | }, 28 | } 29 | -------------------------------------------------------------------------------- /src/aggregator/consumers/state.go: -------------------------------------------------------------------------------- 1 | package consumers 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/Shopify/sarama" 7 | saramaC "github.com/bsm/sarama-cluster" 8 | "github.com/prometheus/client_golang/prometheus" 9 | log "github.com/sirupsen/logrus" 10 | "github.com/spf13/viper" 11 | 12 | "github.com/ovh/metronome/src/metronome/kafka" 13 | "github.com/ovh/metronome/src/metronome/models" 14 | "github.com/ovh/metronome/src/metronome/redis" 15 | ) 16 | 17 | // StateConsumer consumed states messages from Kafka to maintain the state database. 18 | type StateConsumer struct { 19 | consumer *saramaC.Consumer 20 | stateCounter *prometheus.CounterVec 21 | stateUnprocessableCounter *prometheus.CounterVec 22 | stateProcessedCounter *prometheus.CounterVec 23 | statePublishErrorCounter *prometheus.CounterVec 24 | } 25 | 26 | // NewStateConsumer returns a new state consumer. 27 | func NewStateConsumer() (*StateConsumer, error) { 28 | brokers := viper.GetStringSlice("kafka.brokers") 29 | 30 | config := saramaC.NewConfig() 31 | config.Config = *kafka.NewConfig() 32 | config.ClientID = "metronome-aggregator" 33 | config.Consumer.Offsets.Initial = sarama.OffsetOldest 34 | 35 | consumer, err := saramaC.NewConsumer(brokers, kafka.GroupAggregators(), []string{kafka.TopicStates()}, config) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | sc := &StateConsumer{ 41 | consumer: consumer, 42 | } 43 | 44 | // metrics 45 | sc.stateCounter = prometheus.NewCounterVec(prometheus.CounterOpts{ 46 | Namespace: "metronome", 47 | Subsystem: "aggregator", 48 | Name: "states", 49 | Help: "Number of states processed.", 50 | }, 51 | []string{"partition"}) 52 | prometheus.MustRegister(sc.stateCounter) 53 | sc.stateUnprocessableCounter = prometheus.NewCounterVec(prometheus.CounterOpts{ 54 | Namespace: "metronome", 55 | Subsystem: "aggregator", 56 | Name: "states_unprocessable", 57 | Help: "Number of unprocessable states.", 58 | }, 59 | []string{"partition"}) 60 | prometheus.MustRegister(sc.stateUnprocessableCounter) 61 | sc.stateProcessedCounter = prometheus.NewCounterVec(prometheus.CounterOpts{ 62 | Namespace: "metronome", 63 | Subsystem: "aggregator", 64 | Name: "states_processeed", 65 | Help: "Number of processeed states.", 66 | }, 67 | []string{"partition"}) 68 | prometheus.MustRegister(sc.stateProcessedCounter) 69 | sc.statePublishErrorCounter = prometheus.NewCounterVec(prometheus.CounterOpts{ 70 | Namespace: "metronome", 71 | Subsystem: "aggregator", 72 | Name: "states_publish_error", 73 | Help: "Number of states publish error.", 74 | }, 75 | []string{"partition"}) 76 | prometheus.MustRegister(sc.statePublishErrorCounter) 77 | 78 | go func() { 79 | for { 80 | select { 81 | case msg, ok := <-consumer.Messages(): 82 | if !ok { // shuting down 83 | return 84 | } 85 | if err := sc.handleMsg(msg); err != nil { 86 | log.WithError(err).Error("Could not handle the message") 87 | } 88 | } 89 | } 90 | }() 91 | 92 | return sc, nil 93 | } 94 | 95 | // Close the consumer. 96 | func (sc *StateConsumer) Close() error { 97 | return sc.consumer.Close() 98 | } 99 | 100 | // Handle message from Kafka. 101 | // Apply updates to the database. 102 | func (sc *StateConsumer) handleMsg(msg *sarama.ConsumerMessage) error { 103 | sc.stateCounter.WithLabelValues(strconv.Itoa(int(msg.Partition))).Inc() 104 | var s models.State 105 | if err := s.FromKafka(msg); err != nil { 106 | sc.stateUnprocessableCounter.WithLabelValues(strconv.Itoa(int(msg.Partition))).Inc() 107 | return err 108 | } 109 | 110 | log.Infof("UPDATE state: %s", s.TaskGUID) 111 | body, err := s.ToJSON() 112 | if err != nil { 113 | return err 114 | } 115 | 116 | if err := redis.DB().HSet(s.UserID, s.TaskGUID, string(body)).Err(); err != nil { 117 | return err 118 | } 119 | 120 | sc.stateProcessedCounter.WithLabelValues(strconv.Itoa(int(msg.Partition))).Inc() 121 | if err := redis.DB().PublishTopic(s.UserID, "state", string(body)).Err(); err != nil { 122 | sc.statePublishErrorCounter.WithLabelValues(strconv.Itoa(int(msg.Partition))).Inc() 123 | return err 124 | } 125 | 126 | return nil 127 | } 128 | -------------------------------------------------------------------------------- /src/aggregator/consumers/task.go: -------------------------------------------------------------------------------- 1 | package consumers 2 | 3 | import ( 4 | "strconv" 5 | "time" 6 | 7 | "github.com/Shopify/sarama" 8 | saramaC "github.com/bsm/sarama-cluster" 9 | "github.com/prometheus/client_golang/prometheus" 10 | log "github.com/sirupsen/logrus" 11 | "github.com/spf13/viper" 12 | 13 | "github.com/ovh/metronome/src/metronome/kafka" 14 | "github.com/ovh/metronome/src/metronome/models" 15 | "github.com/ovh/metronome/src/metronome/pg" 16 | "github.com/ovh/metronome/src/metronome/redis" 17 | ) 18 | 19 | // TaskConsumer consumed tasks messages from a Kafka topic to maintain the tasks database. 20 | type TaskConsumer struct { 21 | consumer *saramaC.Consumer 22 | doneTasks int 23 | lastCommit time.Time 24 | taskCounter *prometheus.CounterVec 25 | taskUnprocessableCounter *prometheus.CounterVec 26 | taskProcessedCounter *prometheus.CounterVec 27 | taskPublishErrorCounter *prometheus.CounterVec 28 | } 29 | 30 | // NewTaskConsumer returns a new task consumer. 31 | func NewTaskConsumer() (*TaskConsumer, error) { 32 | brokers := viper.GetStringSlice("kafka.brokers") 33 | 34 | config := saramaC.NewConfig() 35 | config.Config = *kafka.NewConfig() 36 | config.ClientID = "metronome-aggregator" 37 | config.Consumer.Offsets.Initial = sarama.OffsetOldest 38 | 39 | consumer, err := saramaC.NewConsumer(brokers, kafka.GroupAggregators(), []string{kafka.TopicTasks()}, config) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | tc := &TaskConsumer{ 45 | consumer: consumer, 46 | doneTasks: 0, 47 | lastCommit: time.Now(), 48 | } 49 | 50 | // metrics 51 | tc.taskCounter = prometheus.NewCounterVec(prometheus.CounterOpts{ 52 | Namespace: "metronome", 53 | Subsystem: "aggregator", 54 | Name: "tasks", 55 | Help: "Number of tasks processed.", 56 | }, 57 | []string{"partition"}) 58 | prometheus.MustRegister(tc.taskCounter) 59 | tc.taskUnprocessableCounter = prometheus.NewCounterVec(prometheus.CounterOpts{ 60 | Namespace: "metronome", 61 | Subsystem: "aggregator", 62 | Name: "tasks_unprocessable", 63 | Help: "Number of unprocessable tasks.", 64 | }, 65 | []string{"partition"}) 66 | prometheus.MustRegister(tc.taskUnprocessableCounter) 67 | tc.taskProcessedCounter = prometheus.NewCounterVec(prometheus.CounterOpts{ 68 | Namespace: "metronome", 69 | Subsystem: "aggregator", 70 | Name: "tasks_processeed", 71 | Help: "Number of processeed tasks.", 72 | }, 73 | []string{"partition"}) 74 | prometheus.MustRegister(tc.taskProcessedCounter) 75 | tc.taskPublishErrorCounter = prometheus.NewCounterVec(prometheus.CounterOpts{ 76 | Namespace: "metronome", 77 | Subsystem: "aggregator", 78 | Name: "tasks_publish_error", 79 | Help: "Number of tasks publish error.", 80 | }, 81 | []string{"partition"}) 82 | prometheus.MustRegister(tc.taskPublishErrorCounter) 83 | 84 | // Consume Kafka Tasks 85 | go func() { 86 | for { 87 | select { 88 | case msg, ok := <-consumer.Messages(): 89 | if !ok { // shuting down 90 | return 91 | } 92 | if err := tc.handleMsg(msg); err != nil { 93 | log.WithError(err).Warn("Could not handle the task") 94 | continue 95 | } 96 | } 97 | } 98 | }() 99 | 100 | return tc, nil 101 | } 102 | 103 | // Close the consumer. 104 | func (tc *TaskConsumer) Close() error { 105 | return tc.consumer.Close() 106 | } 107 | 108 | // Handle message from Kafka. 109 | // Apply updates to the database. 110 | func (tc *TaskConsumer) handleMsg(msg *sarama.ConsumerMessage) error { 111 | tc.taskCounter.WithLabelValues(strconv.Itoa(int(msg.Partition))).Inc() 112 | var t models.Task 113 | if err := t.FromKafka(msg); err != nil { 114 | tc.taskUnprocessableCounter.WithLabelValues(strconv.Itoa(int(msg.Partition))).Inc() 115 | return err 116 | } 117 | 118 | db := pg.DB() 119 | 120 | if t.Schedule == "" { 121 | log.Infof("DELETE task: %s", t.GUID) 122 | 123 | _, err := db.Model(&t).Delete() 124 | if err != nil { 125 | return err 126 | } 127 | } else { 128 | log.Infof("UPSERT task: %s", t.GUID) 129 | 130 | _, err := db.Model(&t).OnConflict("(guid) DO UPDATE"). 131 | Set("name = ?name"). 132 | Set("urn = ?urn"). 133 | Set("schedule = ?schedule"). 134 | Set("payload = ?payload"). 135 | Set("id = ?id"). 136 | Insert() 137 | if err != nil { 138 | return err 139 | } 140 | } 141 | tc.taskProcessedCounter.WithLabelValues(strconv.Itoa(int(msg.Partition))).Inc() 142 | 143 | body, err := t.ToJSON() 144 | if err != nil { 145 | return err 146 | } 147 | 148 | if err = redis.DB().PublishTopic(t.UserID, "task", string(body)).Err(); err != nil { 149 | tc.taskPublishErrorCounter.WithLabelValues(strconv.Itoa(int(msg.Partition))).Inc() 150 | return err 151 | } 152 | 153 | tc.consumer.MarkOffset(msg, "aggregated") 154 | tc.doneTasks++ 155 | if tc.doneTasks >= 100 || time.Now().After(tc.lastCommit.Add(time.Duration(time.Second*10))) { 156 | // If more than 10 seconds since last offset commit ORmore than 100 messages pending 157 | if err = tc.consumer.CommitOffsets(); err != nil { 158 | return err 159 | } 160 | 161 | tc.doneTasks = 0 162 | tc.lastCommit = time.Now() 163 | } 164 | 165 | return nil 166 | } 167 | -------------------------------------------------------------------------------- /src/api/api.go: -------------------------------------------------------------------------------- 1 | // Api agent expose a simple HTTP interface to manage tasks. 2 | // 3 | // The api is stateless making horizontal scaling a breeze. 4 | // 5 | // Usage 6 | // 7 | // api [flags] 8 | // Flags: 9 | // -l, --api.http.listen string api listen addresse 10 | // --config string config file to use 11 | // --help display help 12 | // -v, --verbose verbose output 13 | package main 14 | 15 | import ( 16 | log "github.com/sirupsen/logrus" 17 | 18 | "github.com/ovh/metronome/src/api/cmd" 19 | ) 20 | 21 | func main() { 22 | if err := cmd.RootCmd.Execute(); err != nil { 23 | log.WithError(err).Error("Could not execute the api") 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/api/cmd/initDatabase.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | log "github.com/sirupsen/logrus" 5 | "github.com/spf13/cobra" 6 | 7 | "github.com/ovh/metronome/src/metronome/pg" 8 | ) 9 | 10 | // init - define command line arguments. 11 | func init() { 12 | RootCmd.AddCommand(initDatabaseCmd) 13 | } 14 | 15 | // initDatabaseCmd init the database. 16 | var initDatabaseCmd = &cobra.Command{ 17 | Use: "init-database", 18 | Short: "Init the database schemas", 19 | Run: func(cmd *cobra.Command, args []string) { 20 | log.Info("Initializing database schema") 21 | 22 | database := pg.DB() 23 | assets := []string{ 24 | "extensions.sql", 25 | "users.sql", 26 | "tasks.sql", 27 | "tokens.sql", 28 | } 29 | 30 | for _, asset := range assets { 31 | content, err := pg.Assets().MustString(asset) 32 | if err != nil { 33 | log.WithError(err).WithFields(log.Fields{ 34 | "asset": asset, 35 | }).Error("Cannot found the asset") 36 | continue 37 | } 38 | 39 | if _, err := database.Exec(content); err != nil { 40 | log.WithError(err).WithFields(log.Fields{ 41 | "asset": asset, 42 | }).Error("Failed to setup table") 43 | } 44 | } 45 | 46 | log.Info("Done") 47 | if err := database.Close(); err != nil { 48 | log.WithError(err).Error("Could not gracefully close the connection to the database") 49 | } 50 | }, 51 | } 52 | -------------------------------------------------------------------------------- /src/api/cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | "os/signal" 7 | 8 | "github.com/rs/cors" 9 | log "github.com/sirupsen/logrus" 10 | "github.com/spf13/cobra" 11 | "github.com/spf13/viper" 12 | "github.com/urfave/negroni" 13 | 14 | "github.com/ovh/metronome/src/api/core" 15 | "github.com/ovh/metronome/src/api/routers" 16 | "github.com/ovh/metronome/src/metronome/metrics" 17 | "github.com/ovh/metronome/src/metronome/pg" 18 | ) 19 | 20 | func init() { 21 | cobra.OnInitialize(initConfig) 22 | 23 | RootCmd.PersistentFlags().StringP("config", "", "", "config file to use") 24 | RootCmd.PersistentFlags().BoolP("verbose", "v", false, "verbose output") 25 | RootCmd.PersistentFlags().String("pg.addr", "127.0.0.1:5432", "postgres address") 26 | RootCmd.PersistentFlags().String("pg.user", "metronome", "postgres user") 27 | RootCmd.PersistentFlags().String("pg.password", "metropass", "postgres password") 28 | RootCmd.PersistentFlags().String("pg.database", "metronome", "postgres database") 29 | 30 | RootCmd.Flags().StringP("api.http.listen", "l", "0.0.0.0:8080", "api listen addresse") 31 | RootCmd.Flags().StringSlice("kafka.brokers", []string{"localhost:9092"}, "kafka brokers address") 32 | RootCmd.Flags().String("redis.addr", "127.0.0.1:6379", "redis address") 33 | RootCmd.Flags().String("metrics.addr", "127.0.0.1:9100", "metrics address") 34 | 35 | if err := viper.BindPFlags(RootCmd.PersistentFlags()); err != nil { 36 | log.WithError(err).Error("Could not bind persitent flags") 37 | } 38 | 39 | if err := viper.BindPFlags(RootCmd.Flags()); err != nil { 40 | log.WithError(err).Error("Could not bind flags") 41 | } 42 | } 43 | 44 | func initConfig() { 45 | if viper.GetBool("verbose") { 46 | log.SetLevel(log.DebugLevel) 47 | } 48 | 49 | // Set defaults 50 | viper.SetDefault("metrics.addr", ":9100") 51 | viper.SetDefault("metrics.path", "/metrics") 52 | viper.SetDefault("redis.pass", "") 53 | viper.SetDefault("kafka.tls", false) 54 | viper.SetDefault("kafka.topics.tasks", "tasks") 55 | viper.SetDefault("kafka.topics.jobs", "jobs") 56 | viper.SetDefault("kafka.topics.states", "states") 57 | viper.SetDefault("kafka.groups.schedulers", "schedulers") 58 | viper.SetDefault("kafka.groups.aggregators", "aggregators") 59 | viper.SetDefault("kafka.groups.workers", "workers") 60 | viper.SetDefault("worker.poolsize", 100) 61 | viper.SetDefault("token.ttl", 3600) 62 | viper.SetDefault("redis.pass", "") 63 | 64 | // Bind environment variables 65 | viper.SetEnvPrefix("mtrapi") 66 | viper.AutomaticEnv() 67 | 68 | // Set config search path 69 | viper.AddConfigPath("/etc/metronome/") 70 | viper.AddConfigPath("$HOME/.metronome") 71 | viper.AddConfigPath(".") 72 | 73 | // Load default config 74 | viper.SetConfigName("default") 75 | if err := viper.MergeInConfig(); err != nil { 76 | if _, ok := err.(viper.ConfigFileNotFoundError); ok { 77 | log.Debug("No default config file found") 78 | } else { 79 | log.Panicf("Fatal error in default config file: %v \n", err) 80 | } 81 | } 82 | 83 | // Load api config 84 | viper.SetConfigName("api") 85 | if err := viper.MergeInConfig(); err != nil { 86 | if _, ok := err.(viper.ConfigFileNotFoundError); ok { 87 | log.Debug("No api config file found") 88 | } else { 89 | log.Panicf("Fatal error in api config file: %v \n", err) 90 | } 91 | } 92 | 93 | // Load user defined config 94 | cfgFile := viper.GetString("config") 95 | if cfgFile != "" { 96 | viper.SetConfigFile(cfgFile) 97 | err := viper.ReadInConfig() 98 | if err != nil { 99 | log.Panicf("Fatal error in config file: %v \n", err) 100 | } 101 | } 102 | 103 | // Required 104 | if !viper.IsSet("token.key") { 105 | log.Panic("'token.key' is required") 106 | } 107 | } 108 | 109 | // RootCmd launch the api agent. 110 | var RootCmd = &cobra.Command{ 111 | Use: "metronome-api", 112 | Short: "Metronome api provide a rest api to manage metronome tasks", 113 | Long: `Metronome is a distributed and fault-tolerant event scheduler built with love by ovh teams and friends in Go. 114 | Complete documentation is available at http://ovh.github.io/metronome`, 115 | Run: func(cmd *cobra.Command, args []string) { 116 | log.Info("Metronome API starting") 117 | 118 | n := negroni.New() 119 | 120 | // Log request 121 | logger := &negroni.Logger{ 122 | ALogger: core.RequestLogger{ 123 | LogType: "access", 124 | Level: log.InfoLevel, 125 | }, 126 | } 127 | logger.SetDateFormat(negroni.LoggerDefaultDateFormat) 128 | logger.SetFormat(negroni.LoggerDefaultFormat) 129 | n.Use(logger) 130 | 131 | // Handle handlers panic 132 | recovery := negroni.NewRecovery() 133 | recovery.Logger = core.RequestLogger{ 134 | LogType: "recovery", 135 | Level: log.ErrorLevel, 136 | } 137 | n.Use(recovery) 138 | 139 | // CORS support 140 | n.Use(cors.New(cors.Options{ 141 | AllowedHeaders: []string{"Authorization", "Content-Type"}, 142 | AllowedMethods: []string{"GET", "POST", "DELETE"}, 143 | })) 144 | 145 | // Load routes 146 | router := routers.InitRoutes() 147 | n.UseHandler(router) 148 | 149 | server := &http.Server{ 150 | Addr: viper.GetString("api.http.listen"), 151 | Handler: n, 152 | } 153 | 154 | // Serve metrics 155 | metrics.Serve() 156 | 157 | go func() { 158 | log.Info("Metronome API started") 159 | log.Infof("Listen %s", viper.GetString("api.http.listen")) 160 | if err := server.ListenAndServe(); err != nil { 161 | log.WithError(err).Error("Could not start the server") 162 | } 163 | }() 164 | 165 | sigint := make(chan os.Signal, 1) 166 | signal.Notify(sigint, os.Interrupt) 167 | 168 | <-sigint 169 | 170 | if err := server.Close(); err != nil { 171 | log.WithError(err).Error("Could not stop gracefully the server") 172 | } 173 | 174 | database := pg.DB() 175 | if err := database.Close(); err != nil { 176 | log.WithError(err).Error("Could not stop gracefully close the connection to the database") 177 | } 178 | }, 179 | } 180 | -------------------------------------------------------------------------------- /src/api/cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | var ( 11 | version = "0.0.0" 12 | githash = "HEAD" 13 | date = "1970-01-01T00:00:00Z UTC" 14 | ) 15 | 16 | func init() { 17 | RootCmd.AddCommand(versionCmd) 18 | } 19 | 20 | var versionCmd = &cobra.Command{ 21 | Use: "version", 22 | Short: "Show version", 23 | Run: func(cmd *cobra.Command, arguments []string) { 24 | fmt.Printf("metronome-api version %s %s\n", version, githash) 25 | fmt.Printf("metronome-api build date %s\n", date) 26 | fmt.Printf("go version %s %s/%s\n", runtime.Version(), runtime.GOOS, runtime.GOARCH) 27 | }, 28 | } 29 | -------------------------------------------------------------------------------- /src/api/controllers/auth/auth_controller.go: -------------------------------------------------------------------------------- 1 | package authctrl 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/ovh/metronome/src/api/core" 8 | "github.com/ovh/metronome/src/api/core/io/in" 9 | "github.com/ovh/metronome/src/api/core/io/out" 10 | "github.com/ovh/metronome/src/api/factories" 11 | authSrv "github.com/ovh/metronome/src/api/services/auth" 12 | userSrv "github.com/ovh/metronome/src/api/services/user" 13 | ) 14 | 15 | type tokenQuery struct { 16 | Type string `json:"type"` 17 | Username string `json:"username,omitempty"` 18 | Password string `json:"password,omitempty"` 19 | RefreshToken string `json:"refreshToken,omitempty"` 20 | AccessToken string `json:"accessToken,omitempty"` 21 | } 22 | 23 | // AuthHandler endoint handle token requests. 24 | func AuthHandler(w http.ResponseWriter, r *http.Request) { 25 | var tokenQuery tokenQuery 26 | 27 | body, err := in.JSON(r, &tokenQuery) 28 | if err != nil { 29 | out.JSON(w, http.StatusBadRequest, factories.Error(err)) 30 | return 31 | } 32 | 33 | authQueryResult, err := core.ValidateJSON("auth", "authQuery", string(body)) 34 | if err != nil { 35 | out.JSON(w, http.StatusInternalServerError, factories.Error(err)) 36 | return 37 | } 38 | 39 | if !authQueryResult.Valid { 40 | out.JSON(w, http.StatusUnprocessableEntity, authQueryResult.Errors) 41 | return 42 | } 43 | 44 | switch tokenQuery.Type { 45 | 46 | case "bearer": 47 | loginQueryResult, err := core.ValidateJSON("auth", "loginQuery", string(body)) 48 | if err != nil { 49 | out.JSON(w, http.StatusInternalServerError, factories.Error(err)) 50 | return 51 | } 52 | 53 | if !loginQueryResult.Valid { 54 | out.JSON(w, http.StatusUnprocessableEntity, loginQueryResult.Errors) 55 | return 56 | } 57 | 58 | user, err := userSrv.Login(tokenQuery.Username, tokenQuery.Password) 59 | if err != nil { 60 | out.JSON(w, http.StatusInternalServerError, factories.Error(err)) 61 | return 62 | } 63 | 64 | if user == nil { 65 | out.JSON(w, http.StatusUnauthorized, factories.Error(errors.New("Unknown user"))) 66 | return 67 | } 68 | 69 | token, err := authSrv.BearerTokensFromUser(user) 70 | if err != nil { 71 | out.JSON(w, http.StatusInternalServerError, factories.Error(err)) 72 | return 73 | } 74 | 75 | out.JSON(w, http.StatusOK, token) 76 | 77 | case "access": 78 | accessQueryResult, err := core.ValidateJSON("auth", "accessQuery", string(body)) 79 | if err != nil { 80 | out.JSON(w, http.StatusInternalServerError, factories.Error(err)) 81 | return 82 | } 83 | 84 | if !accessQueryResult.Valid { 85 | out.JSON(w, http.StatusUnprocessableEntity, accessQueryResult.Errors) 86 | return 87 | } 88 | 89 | token, err := authSrv.BearerTokensFromRefresh(tokenQuery.RefreshToken) 90 | if err != nil { 91 | out.JSON(w, http.StatusUnauthorized, factories.Error(err)) 92 | return 93 | } 94 | 95 | out.JSON(w, http.StatusOK, token) 96 | } 97 | } 98 | 99 | // LogoutHandler endoint remove RefreshTokens 100 | func LogoutHandler(w http.ResponseWriter, r *http.Request) { 101 | 102 | token, err := authSrv.GetToken(r.Header.Get("Authorization")) 103 | if err != nil { 104 | out.JSON(w, http.StatusInternalServerError, factories.Error(err)) 105 | return 106 | } 107 | 108 | if token == nil { 109 | out.JSON(w, http.StatusUnauthorized, factories.Error(errors.New("Unauthorized"))) 110 | return 111 | } 112 | 113 | err = authSrv.RevokeRefreshTokenFromAccess(token) 114 | if err != nil { 115 | out.JSON(w, http.StatusInternalServerError, factories.Error(err)) 116 | return 117 | } 118 | 119 | out.JSON(w, http.StatusOK, true) 120 | } 121 | -------------------------------------------------------------------------------- /src/api/controllers/auth/schema/accessQuery.json: -------------------------------------------------------------------------------- 1 | { 2 | "properties": { 3 | "refreshToken": { 4 | "$ref": "#/definitions/refreshToken" 5 | }, 6 | "type": { 7 | "type": "string", 8 | "enum": ["access"] 9 | } 10 | }, 11 | "required": ["refreshToken", "type"], 12 | "type": "object", 13 | "additionalProperties": false 14 | } 15 | -------------------------------------------------------------------------------- /src/api/controllers/auth/schema/authQuery.json: -------------------------------------------------------------------------------- 1 | { 2 | "properties": { 3 | "type": { 4 | "type": "string", 5 | "enum": ["bearer", "access"] 6 | } 7 | }, 8 | "required": ["type"], 9 | "type": "object" 10 | } 11 | -------------------------------------------------------------------------------- /src/api/controllers/auth/schema/definitions.json: -------------------------------------------------------------------------------- 1 | { 2 | "username": { 3 | "type": "string", 4 | "minLength": 1 5 | }, 6 | "password": { 7 | "minLength": 1 8 | }, 9 | "refreshToken": { 10 | "type": "string", 11 | "minLength": 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/api/controllers/auth/schema/loginQuery.json: -------------------------------------------------------------------------------- 1 | { 2 | "properties": { 3 | "username": { 4 | "$ref": "#/definitions/username" 5 | }, 6 | "password": { 7 | "$ref": "#/definitions/password" 8 | }, 9 | "type": { 10 | "type": "string", 11 | "enum": ["bearer"] 12 | } 13 | }, 14 | "required": ["username", "password", "type"], 15 | "type": "object", 16 | "additionalProperties": false 17 | } 18 | -------------------------------------------------------------------------------- /src/api/controllers/task/schema/create.json: -------------------------------------------------------------------------------- 1 | { 2 | "properties": { 3 | "id": { 4 | "$ref": "#/definitions/id" 5 | }, 6 | "name": { 7 | "$ref": "#/definitions/name" 8 | }, 9 | "schedule": { 10 | "$ref": "#/definitions/schedule" 11 | }, 12 | "urn": { 13 | "$ref": "#/definitions/urn" 14 | }, 15 | "payload": { 16 | "$ref": "#/definitions/payload" 17 | } 18 | }, 19 | "required": ["name", "schedule", "urn"], 20 | "type": "object", 21 | "additionalProperties": false 22 | } 23 | -------------------------------------------------------------------------------- /src/api/controllers/task/schema/definitions.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": { 3 | "type": "string", 4 | "minLength": 1, 5 | "maxLength": 256, 6 | "pattern": "^\\S+$" 7 | }, 8 | "name": { 9 | "type": "string", 10 | "minLength": 1, 11 | "maxLength": 256, 12 | "pattern": "^\\S+.*\\S+$" 13 | }, 14 | "schedule": { 15 | "type": "string", 16 | "pattern": "^R(\\d*)\\/(\\d{4})-(0?[1-9]|1[0-2])-(0?[1-9]|[1-2][0-9]|3[0-1])T([0-1]?\\d|2[0-3]):([0-5]?\\d):([0-5]?\\d)Z\\/P(?:(?:(\\d+)Y(\\d+)M|(\\d+)Y|(\\d+)M)|(?:(?:(?:(\\d+)D)T(?:(\\d+)H)(?:(\\d+)M)(?:(\\d+)S))|(?:(?:(\\d+)D)T(?:(\\d+)H)(?:(\\d+)M)|(?:(?:(\\d+)D)T(?:(\\d+)H)(?:(\\d+)S))|(?:(?:(\\d+)D)T(?:(\\d+)M)(?:(\\d+)S))|(?:(\\d+)D)T|(?:T(?:(\\d+)H)(?:(\\d+)M)(?:(\\d+)S))|(?:(?:(\\d+)D)T(?:(\\d+)H))|(?:(?:(\\d+)D)T(?:(\\d+)M))|(?:(?:(\\d+)D)T(?:(\\d+)S))|(?:T(?:(\\d+)H)(?:(\\d+)M))|(?:T(?:(\\d+)H)(?:(\\d+)S))|(?:T(?:(\\d+)M)(?:(\\d+)S))|(?:T(?:(\\d+)H))|(?:T(?:(\\d+)M))|(?:T(?:(\\d+)S)))))\\/ET(?:(\\d+)M(\\d+)S|(\\d+)M|(\\d+)S)$" 17 | }, 18 | "urn": { 19 | "type": "string", 20 | "minLength": 1, 21 | "pattern": "^(\\S+):\/\/(\\S+)$" 22 | }, 23 | "payload": { 24 | "type": "object" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/api/controllers/task/task.go: -------------------------------------------------------------------------------- 1 | package taskctrl 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/gorilla/mux" 8 | 9 | "github.com/ovh/metronome/src/api/core" 10 | "github.com/ovh/metronome/src/api/core/io/in" 11 | "github.com/ovh/metronome/src/api/core/io/out" 12 | "github.com/ovh/metronome/src/api/factories" 13 | authSrv "github.com/ovh/metronome/src/api/services/auth" 14 | taskSrv "github.com/ovh/metronome/src/api/services/task" 15 | "github.com/ovh/metronome/src/metronome/models" 16 | ) 17 | 18 | // Create endoint handle task creation. 19 | func Create(w http.ResponseWriter, r *http.Request) { 20 | token, err := authSrv.GetToken(r.Header.Get("Authorization")) 21 | if err != nil { 22 | out.JSON(w, http.StatusInternalServerError, factories.Error(err)) 23 | return 24 | } 25 | 26 | if token == nil { 27 | out.JSON(w, http.StatusUnauthorized, factories.Error(errors.New("Unauthorized"))) 28 | return 29 | } 30 | 31 | var task models.Task 32 | body, err := in.JSON(r, &task) 33 | if err != nil { 34 | out.JSON(w, http.StatusBadRequest, factories.Error(err)) 35 | return 36 | } 37 | 38 | // schedule regex: https://regex101.com/r/vyBrRd/3 39 | result, err := core.ValidateJSON("task", "create", string(body)) 40 | if err != nil { 41 | out.JSON(w, http.StatusInternalServerError, factories.Error(err)) 42 | return 43 | } 44 | 45 | if !result.Valid { 46 | out.JSON(w, http.StatusUnprocessableEntity, result.Errors) 47 | return 48 | } 49 | 50 | task.UserID = authSrv.UserID(token) 51 | success := taskSrv.Create(&task) 52 | if !success { 53 | out.JSON(w, http.StatusBadGateway, factories.Error(errors.New("Bad gateway"))) 54 | return 55 | } 56 | 57 | out.JSON(w, http.StatusOK, task) 58 | } 59 | 60 | // Delete endoint handle task deletion. 61 | func Delete(w http.ResponseWriter, r *http.Request) { 62 | token, err := authSrv.GetToken(r.Header.Get("Authorization")) 63 | if err != nil { 64 | out.JSON(w, http.StatusInternalServerError, factories.Error(err)) 65 | return 66 | } 67 | 68 | if token == nil { 69 | out.JSON(w, http.StatusUnauthorized, factories.Error(errors.New("Unauthorized"))) 70 | return 71 | } 72 | 73 | success := taskSrv.Delete(mux.Vars(r)["id"], authSrv.UserID(token)) 74 | if !success { 75 | out.JSON(w, http.StatusBadGateway, factories.Error(errors.New("Bad gateway"))) 76 | return 77 | } 78 | 79 | w.WriteHeader(http.StatusOK) 80 | } 81 | -------------------------------------------------------------------------------- /src/api/controllers/tasks/tasks.go: -------------------------------------------------------------------------------- 1 | package tasksctrl 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/ovh/metronome/src/api/core/io/out" 8 | "github.com/ovh/metronome/src/api/factories" 9 | authSrv "github.com/ovh/metronome/src/api/services/auth" 10 | tasksSrv "github.com/ovh/metronome/src/api/services/tasks" 11 | ) 12 | 13 | // All endoint return the user tasks. 14 | func All(w http.ResponseWriter, r *http.Request) { 15 | token, err := authSrv.GetToken(r.Header.Get("Authorization")) 16 | if err != nil { 17 | out.JSON(w, http.StatusInternalServerError, factories.Error(err)) 18 | return 19 | } 20 | 21 | if token == nil { 22 | out.JSON(w, http.StatusUnauthorized, factories.Error(errors.New("Unauthorized"))) 23 | return 24 | } 25 | 26 | tasks, err := tasksSrv.All(authSrv.UserID(token)) 27 | if err != nil { 28 | out.JSON(w, http.StatusInternalServerError, factories.Error(err)) 29 | return 30 | } 31 | 32 | out.JSON(w, http.StatusOK, tasks) 33 | } 34 | -------------------------------------------------------------------------------- /src/api/controllers/user/schema/create.json: -------------------------------------------------------------------------------- 1 | { 2 | "properties": { 3 | "name": { 4 | "$ref": "#/definitions/name" 5 | }, 6 | "password": { 7 | "$ref": "#/definitions/password" 8 | } 9 | }, 10 | "required": ["name", "password"], 11 | "type": "object", 12 | "additionalProperties": false 13 | } 14 | -------------------------------------------------------------------------------- /src/api/controllers/user/schema/definitions.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": { 3 | "type": "string", 4 | "minLength": 1, 5 | "maxLength": 256, 6 | "pattern": "^\\S+.*?\\S+$" 7 | }, 8 | "password": { 9 | "type": "string", 10 | "minLength": 1, 11 | "maxLength": 256 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/api/controllers/user/schema/edit.json: -------------------------------------------------------------------------------- 1 | { 2 | "properties": { 3 | "password": { 4 | "$ref": "#/definitions/password" 5 | } 6 | }, 7 | "type": "object", 8 | "additionalProperties": false 9 | } 10 | -------------------------------------------------------------------------------- /src/api/controllers/user/user_controller.go: -------------------------------------------------------------------------------- 1 | package userctrl 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/ovh/metronome/src/api/core" 8 | "github.com/ovh/metronome/src/api/core/io/in" 9 | "github.com/ovh/metronome/src/api/core/io/out" 10 | "github.com/ovh/metronome/src/api/factories" 11 | "github.com/ovh/metronome/src/api/models" 12 | authSrv "github.com/ovh/metronome/src/api/services/auth" 13 | userSrv "github.com/ovh/metronome/src/api/services/user" 14 | ) 15 | 16 | // Create endpoint handle the user account creation. 17 | func Create(w http.ResponseWriter, r *http.Request) { 18 | var user models.User 19 | 20 | body, err := in.JSON(r, &user) 21 | if err != nil { 22 | out.JSON(w, http.StatusBadRequest, err) 23 | return 24 | } 25 | 26 | result, err := core.ValidateJSON("user", "create", string(body)) 27 | if err != nil { 28 | out.JSON(w, http.StatusInternalServerError, factories.Error(err)) 29 | return 30 | } 31 | 32 | if !result.Valid { 33 | out.JSON(w, http.StatusUnprocessableEntity, result.Errors) 34 | return 35 | } 36 | 37 | duplicated, err := userSrv.Create(&user) 38 | if err != nil { 39 | out.JSON(w, http.StatusInternalServerError, factories.Error(err)) 40 | return 41 | } 42 | 43 | if duplicated { 44 | var errs []core.JSONSchemaErr 45 | errs = append(errs, core.JSONSchemaErr{ 46 | Field: "name", 47 | Type: "duplicated", 48 | Description: "name is duplicated", 49 | }) 50 | 51 | out.JSON(w, http.StatusUnprocessableEntity, errs) 52 | return 53 | } 54 | 55 | out.JSON(w, http.StatusOK, user) 56 | } 57 | 58 | // Edit endoint handle user edit. 59 | func Edit(w http.ResponseWriter, r *http.Request) { 60 | token, err := authSrv.GetToken(r.Header.Get("Authorization")) 61 | if err != nil { 62 | out.JSON(w, http.StatusInternalServerError, factories.Error(err)) 63 | return 64 | } 65 | 66 | if token == nil { 67 | out.JSON(w, http.StatusUnauthorized, factories.Error(errors.New("Unauthorized"))) 68 | return 69 | } 70 | 71 | var user models.User 72 | 73 | body, err := in.JSON(r, &user) 74 | if err != nil { 75 | out.JSON(w, http.StatusBadRequest, factories.Error(err)) 76 | return 77 | } 78 | 79 | result, err := core.ValidateJSON("user", "edit", string(body)) 80 | if err != nil { 81 | out.JSON(w, http.StatusInternalServerError, factories.Error(err)) 82 | return 83 | } 84 | 85 | if !result.Valid { 86 | out.JSON(w, http.StatusUnprocessableEntity, result.Errors) 87 | return 88 | } 89 | 90 | duplicated, err := userSrv.Edit(authSrv.UserID(token), &user) 91 | if err != nil { 92 | out.JSON(w, http.StatusInternalServerError, factories.Error(err)) 93 | return 94 | } 95 | 96 | if duplicated { 97 | var errs []core.JSONSchemaErr 98 | errs = append(errs, core.JSONSchemaErr{ 99 | Field: "name", 100 | Type: "duplicated", 101 | Description: "name is duplicated", 102 | }) 103 | 104 | out.JSON(w, http.StatusUnprocessableEntity, errs) 105 | return 106 | } 107 | 108 | w.WriteHeader(http.StatusOK) 109 | } 110 | 111 | // Current endoint return the user bind to the token. 112 | func Current(w http.ResponseWriter, r *http.Request) { 113 | token, err := authSrv.GetToken(r.Header.Get("Authorization")) 114 | if err != nil { 115 | out.JSON(w, http.StatusInternalServerError, factories.Error(err)) 116 | return 117 | } 118 | 119 | if token == nil { 120 | out.JSON(w, http.StatusUnauthorized, factories.Error(errors.New("Unauthorized"))) 121 | return 122 | } 123 | 124 | user, err := userSrv.Get(authSrv.UserID(token)) 125 | if err != nil { 126 | out.JSON(w, http.StatusInternalServerError, err) 127 | return 128 | } 129 | 130 | if user == nil { 131 | out.JSON(w, http.StatusNotFound, factories.Error(errors.New("Not found"))) 132 | return 133 | } 134 | 135 | out.JSON(w, http.StatusOK, user) 136 | } 137 | -------------------------------------------------------------------------------- /src/api/controllers/ws/ws_controller.go: -------------------------------------------------------------------------------- 1 | package wsctrl 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/gorilla/websocket" 8 | "github.com/ovh/metronome/src/api/core/ws" 9 | "github.com/ovh/metronome/src/api/factories" 10 | log "github.com/sirupsen/logrus" 11 | 12 | "github.com/ovh/metronome/src/api/core/io/out" 13 | authSrv "github.com/ovh/metronome/src/api/services/auth" 14 | "github.com/ovh/metronome/src/metronome/redis" 15 | ) 16 | 17 | var upgrader = websocket.Upgrader{ 18 | ReadBufferSize: 1024, 19 | WriteBufferSize: 1024, 20 | CheckOrigin: func(r *http.Request) bool { return true }, 21 | } 22 | 23 | // Join handle ws connections. 24 | func Join(w http.ResponseWriter, r *http.Request) { 25 | conn, err := upgrader.Upgrade(w, r, nil) 26 | if err != nil { 27 | log.WithError(err).Error("Could not upgrade the http request to websocket") 28 | out.JSON(w, http.StatusInternalServerError, factories.Error(err)) 29 | return 30 | } 31 | client := ws.NewClient(conn) 32 | defer client.Close() 33 | 34 | // wait for auth token 35 | msg, ok := <-client.Messages() 36 | if !ok { 37 | return 38 | } 39 | 40 | token, err := authSrv.GetToken(msg) 41 | if err != nil { 42 | out.JSON(w, http.StatusInternalServerError, factories.Error(err)) 43 | return 44 | } 45 | 46 | if token == nil { 47 | out.JSON(w, http.StatusUnauthorized, factories.Error(errors.New("Unauthorized"))) 48 | return 49 | } 50 | 51 | pubsub, err := redis.DB().Subscribe(authSrv.UserID(token)) 52 | if err != nil { 53 | log.WithError(err).Error("Could not subscribe to redis") 54 | out.JSON(w, http.StatusInternalServerError, factories.Error(err)) 55 | return 56 | } 57 | defer pubsub.Close() 58 | 59 | in := make(chan string) 60 | kill := make(chan struct{}) 61 | 62 | go func() { 63 | for { 64 | msg, err := pubsub.ReceiveMessage() 65 | if err != nil { 66 | kill <- struct{}{} 67 | return 68 | } 69 | 70 | in <- msg.Payload 71 | } 72 | }() 73 | 74 | for { 75 | select { 76 | case _, ok := <-client.Messages(): 77 | if !ok { // shuting down 78 | w.WriteHeader(http.StatusOK) 79 | return 80 | } 81 | 82 | case msg := <-in: 83 | client.Send(msg) 84 | 85 | case <-kill: 86 | out.JSON(w, http.StatusBadGateway, factories.Error(errors.New("Bad gateway"))) 87 | return 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/api/core/JSON.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/xeipuuv/gojsonschema" 7 | ) 8 | 9 | // JSONSchemaErr describe error in schema validation. 10 | type JSONSchemaErr struct { 11 | Field string `json:"field"` 12 | Type string `json:"type"` 13 | Description string `json:"description"` 14 | } 15 | 16 | // JSONValidationResult describe result of the schema validation. 17 | type JSONValidationResult struct { 18 | Valid bool 19 | Errors []JSONSchemaErr 20 | } 21 | 22 | // ValidateJSON check a JSON string against a JSON schema. 23 | // See: http://json-schema.org/ 24 | func ValidateJSON(root, schema, input string) (*JSONValidationResult, error) { 25 | var f interface{} 26 | box, err := Assets(root) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | asset, err := box.MustBytes(schema + ".json") 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | if err = json.Unmarshal(asset, &f); err != nil { 37 | return nil, err 38 | } 39 | 40 | s := f.(map[string]interface{}) 41 | definitions, err := box.MustBytes("definitions.json") 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | if err = json.Unmarshal(definitions, &f); err != nil { 47 | return nil, err 48 | } 49 | 50 | defs := f.(map[string]interface{}) 51 | s["definitions"] = defs 52 | 53 | schemaLoader := gojsonschema.NewGoLoader(s) 54 | objectLoader := gojsonschema.NewStringLoader(input) 55 | result, err := gojsonschema.Validate(schemaLoader, objectLoader) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | r := JSONValidationResult{} 61 | r.Valid = result.Valid() 62 | 63 | var errs []JSONSchemaErr 64 | for _, err := range result.Errors() { 65 | errs = append(errs, JSONSchemaErr{ 66 | err.Field(), 67 | err.Type(), 68 | err.Description(), 69 | }) 70 | } 71 | 72 | r.Errors = errs 73 | return &r, nil 74 | } 75 | -------------------------------------------------------------------------------- /src/api/core/assets.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | 7 | "github.com/gobuffalo/packr" 8 | ) 9 | 10 | var ( 11 | boxOnce sync.Once 12 | boxes map[string]packr.Box 13 | ) 14 | 15 | // Assets return a packr box which contains all assets 16 | func Assets(namespace string) (*packr.Box, error) { 17 | boxOnce.Do(func() { 18 | boxes = map[string]packr.Box{ 19 | "auth": packr.NewBox("../controllers/auth/schema"), 20 | "task": packr.NewBox("../controllers/task/schema"), 21 | "user": packr.NewBox("../controllers/user/schema"), 22 | } 23 | }) 24 | 25 | box, ok := boxes[namespace] 26 | if !ok { 27 | return nil, fmt.Errorf("No box found for namespace '%s'", namespace) 28 | } 29 | 30 | return &box, nil 31 | } 32 | -------------------------------------------------------------------------------- /src/api/core/io/in/JSON.go: -------------------------------------------------------------------------------- 1 | package in 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "io/ioutil" 7 | "net/http" 8 | ) 9 | 10 | // JSON unmarshal a body HTTP request to an interface. 11 | // It also implement body size limit 12 | func JSON(r *http.Request, v interface{}) ([]byte, error) { 13 | body, err := ioutil.ReadAll(io.LimitReader(r.Body, 1048576)) 14 | if err != nil { 15 | return nil, err 16 | } 17 | 18 | if err := r.Body.Close(); err != nil { 19 | return nil, err 20 | } 21 | 22 | if err := json.Unmarshal(body, &v); err != nil { 23 | return nil, err 24 | } 25 | 26 | return body, nil 27 | } 28 | -------------------------------------------------------------------------------- /src/api/core/io/out/JSON.go: -------------------------------------------------------------------------------- 1 | package out 2 | 3 | import ( 4 | "net/http" 5 | 6 | log "github.com/sirupsen/logrus" 7 | "github.com/unrolled/render" 8 | ) 9 | 10 | // JSON perform an http response with a JSON payload. 11 | // status defined the http status code. 12 | func JSON(w http.ResponseWriter, status int, v interface{}) { // nolint: interfacer 13 | if err := render.New().JSON(w, status, v); err != nil { 14 | log.WithError(err).Error("Could not awnser to the request") 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/api/core/kafka.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | 7 | "github.com/Shopify/sarama" 8 | log "github.com/sirupsen/logrus" 9 | "github.com/spf13/viper" 10 | 11 | "github.com/ovh/metronome/src/metronome/kafka" 12 | ) 13 | 14 | // Kafka handle Kafka connection. 15 | // The producer use a WaitForAll strategy to perform message ack. 16 | type Kafka struct { 17 | Producer sarama.SyncProducer 18 | } 19 | 20 | var k *Kafka 21 | var once sync.Once 22 | 23 | // GetKafka return the kafka instance. 24 | func GetKafka() *Kafka { 25 | once.Do(func() { 26 | brokers := viper.GetStringSlice("kafka.brokers") 27 | 28 | config := kafka.NewConfig() 29 | config.ClientID = "metronome-api" 30 | config.Producer.RequiredAcks = sarama.WaitForAll 31 | config.Producer.Timeout = 1 * time.Second 32 | config.Producer.Compression = sarama.CompressionGZIP 33 | config.Producer.Flush.Frequency = 500 * time.Millisecond 34 | config.Producer.Partitioner = sarama.NewHashPartitioner 35 | config.Producer.Return.Successes = true 36 | config.Producer.Retry.Max = 3 37 | 38 | producer, err := sarama.NewSyncProducer(brokers, config) 39 | if err != nil { 40 | log.WithError(err).Fatal("Could not connect to kafka") 41 | } 42 | 43 | k = &Kafka{Producer: producer} 44 | }) 45 | 46 | return k 47 | } 48 | 49 | // Close the producer. 50 | func (k *Kafka) Close() error { 51 | return k.Producer.Close() 52 | } 53 | -------------------------------------------------------------------------------- /src/api/core/oauth/accessToken.go: -------------------------------------------------------------------------------- 1 | package oauth 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/hex" 6 | "errors" 7 | "fmt" 8 | "time" 9 | 10 | jwt "github.com/dgrijalva/jwt-go" 11 | "github.com/spf13/viper" 12 | ) 13 | 14 | // AuthClaims add roles to the jwt claims. 15 | type AuthClaims struct { 16 | Roles []string `json:"roles"` 17 | RefreshToken string `json:"refreshToken"` 18 | jwt.StandardClaims 19 | } 20 | 21 | // GenerateAccessToken return a new token. 22 | func GenerateAccessToken(userID string, roles []string, refreshToken string) (string, error) { 23 | claims := AuthClaims{ 24 | roles, 25 | refreshToken, 26 | jwt.StandardClaims{ 27 | ExpiresAt: time.Now().Add(time.Second * time.Duration(viper.GetInt("token.ttl"))).Unix(), 28 | IssuedAt: time.Now().Unix(), 29 | Subject: userID, 30 | }, 31 | } 32 | 33 | token := jwt.NewWithClaims(jwt.SigningMethodHS512, claims) 34 | key, err := hex.DecodeString(viper.GetString("token.key")) 35 | if err != nil { 36 | return "", err 37 | } 38 | 39 | tokenString, err := token.SignedString(key) 40 | if err != nil { 41 | return "", err 42 | } 43 | 44 | return tokenString, nil 45 | } 46 | 47 | // GetToken return a token from a token string. 48 | // Return nil if the token string is invalid or if the token as expired. 49 | func GetToken(tokenString string) (*jwt.Token, error) { 50 | token, err := jwt.ParseWithClaims(tokenString, &AuthClaims{}, func(token *jwt.Token) (interface{}, error) { 51 | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { 52 | return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) 53 | } 54 | 55 | key, err := hex.DecodeString(viper.GetString("token.key")) 56 | if err != nil { 57 | return nil, err 58 | } 59 | return key, nil 60 | }) 61 | 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | if !token.Valid { 67 | return nil, errors.New("Token is not valid") 68 | } 69 | 70 | return token, nil 71 | } 72 | 73 | // UserID return the user id from a token. 74 | func UserID(token *jwt.Token) string { 75 | claims := token.Claims.(*AuthClaims) 76 | return claims.Subject 77 | } 78 | 79 | // Roles return the roles from a token. 80 | func Roles(token *jwt.Token) []string { 81 | claims := token.Claims.(*AuthClaims) 82 | return claims.Roles 83 | } 84 | 85 | // RefreshToken return the refreshToken 86 | func RefreshToken(token *jwt.Token) ([]byte, error) { 87 | claims := token.Claims.(*AuthClaims) 88 | refreshToken, err := base64.StdEncoding.DecodeString(claims.RefreshToken) 89 | if err != nil { 90 | return nil, err 91 | } 92 | return refreshToken, nil 93 | } 94 | -------------------------------------------------------------------------------- /src/api/core/oauth/refreshToken.go: -------------------------------------------------------------------------------- 1 | package oauth 2 | 3 | import ( 4 | "encoding/base64" 5 | 6 | "github.com/ovh/metronome/src/api/models" 7 | uuid "github.com/satori/go.uuid" 8 | ) 9 | 10 | // GenerateRefreshToken returns an Token 11 | func GenerateRefreshToken(userID string, roles []string) (*models.Token, error) { 12 | return &models.Token{ 13 | Token: base64.StdEncoding.EncodeToString(uuid.NewV4().Bytes()), 14 | UserID: userID, 15 | Roles: roles, 16 | Type: "refresh", 17 | }, nil 18 | } 19 | -------------------------------------------------------------------------------- /src/api/core/requestLogger.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | log "github.com/sirupsen/logrus" 5 | ) 6 | 7 | // RequestLogger wrap logrus to a compliant negroni logger. 8 | // Log level can be defined through Level. 9 | type RequestLogger struct { 10 | LogType string 11 | Level log.Level 12 | } 13 | 14 | // Println to logrus. 15 | func (rl RequestLogger) Println(v ...interface{}) { 16 | lg := log.WithField("type", rl.LogType) 17 | l := lg.Infoln 18 | 19 | switch rl.Level { 20 | case log.PanicLevel: 21 | l = lg.Panicln 22 | case log.FatalLevel: 23 | l = lg.Fatalln 24 | case log.ErrorLevel: 25 | l = lg.Errorln 26 | case log.WarnLevel: 27 | l = lg.Warnln 28 | case log.InfoLevel: 29 | l = lg.Infoln 30 | case log.DebugLevel: 31 | l = lg.Debugln 32 | } 33 | 34 | l(v...) 35 | } 36 | 37 | // Printf to logrus. 38 | func (rl RequestLogger) Printf(format string, v ...interface{}) { 39 | lg := log.WithField("type", rl.LogType) 40 | l := lg.Infof 41 | 42 | switch rl.Level { 43 | case log.PanicLevel: 44 | l = lg.Panicf 45 | case log.FatalLevel: 46 | l = lg.Fatalf 47 | case log.ErrorLevel: 48 | l = lg.Errorf 49 | case log.WarnLevel: 50 | l = lg.Warnf 51 | case log.InfoLevel: 52 | l = lg.Infof 53 | case log.DebugLevel: 54 | l = lg.Debugf 55 | } 56 | 57 | l(format, v...) 58 | } 59 | -------------------------------------------------------------------------------- /src/api/core/ws/client.go: -------------------------------------------------------------------------------- 1 | package ws 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/gorilla/websocket" 7 | log "github.com/sirupsen/logrus" 8 | ) 9 | 10 | const ( 11 | // Time allowed to write a message to the peer. 12 | writeWait = 10 * time.Second 13 | 14 | // Time allowed to read the next pong message from the peer. 15 | pongWait = 60 * time.Second 16 | 17 | // Send pings to peer with this period. Must be less than pongWait. 18 | pingPeriod = (pongWait * 9) / 10 19 | 20 | // Maximum message size allowed from peer. 21 | maxMessageSize = 512 22 | ) 23 | 24 | // Client handle websockets clients 25 | type Client struct { 26 | conn *websocket.Conn 27 | 28 | // Buffered channel of inbound messages. 29 | receive chan string 30 | // Buffered channel of outbound messages. 31 | send chan string 32 | } 33 | 34 | // NewClient return a new client 35 | func NewClient(conn *websocket.Conn) *Client { 36 | c := &Client{ 37 | conn: conn, 38 | receive: make(chan string, 64), 39 | send: make(chan string, 256), 40 | } 41 | 42 | go func() { 43 | if err := c.readPump(); err != nil { 44 | log.WithError(err).Error("Could not read from the pump") 45 | } 46 | }() 47 | 48 | go func() { 49 | if err := c.writePump(); err != nil { 50 | log.WithError(err).Error("Could not write in the pump") 51 | } 52 | }() 53 | 54 | return c 55 | } 56 | 57 | // Close close the client connection 58 | func (c *Client) Close() { 59 | close(c.send) 60 | } 61 | 62 | // Messages return the inbound messages channel 63 | func (c *Client) Messages() <-chan string { 64 | return c.receive 65 | } 66 | 67 | // Send send a message to the outbound channel 68 | func (c *Client) Send(msg string) { 69 | c.send <- msg 70 | } 71 | 72 | // readPump pumps messages from the websocket connection. 73 | func (c *Client) readPump() error { 74 | c.conn.SetReadLimit(maxMessageSize) 75 | if err := c.conn.SetReadDeadline(time.Now().Add(pongWait)); err != nil { 76 | return err 77 | } 78 | 79 | c.conn.SetPongHandler(func(string) error { 80 | return c.conn.SetReadDeadline(time.Now().Add(pongWait)) 81 | }) 82 | 83 | for { 84 | mt, message, err := c.conn.ReadMessage() 85 | if err != nil { 86 | if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway) { 87 | log.Warnf("WS error: %v", err) 88 | } 89 | close(c.receive) 90 | return err 91 | } 92 | 93 | if mt == websocket.TextMessage { 94 | c.receive <- string(message) 95 | } 96 | } 97 | } 98 | 99 | // writePump pumps messages to the websocket connection. 100 | func (c *Client) writePump() error { 101 | ticker := time.NewTicker(pingPeriod) 102 | defer func() { 103 | ticker.Stop() 104 | if err := c.conn.Close(); err != nil { 105 | log.WithError(err).Error("Could not gracefully close the websocket") 106 | } 107 | }() 108 | 109 | for { 110 | select { 111 | case message, ok := <-c.send: 112 | if err := c.conn.SetWriteDeadline(time.Now().Add(writeWait)); err != nil { 113 | return err 114 | } 115 | 116 | if !ok { 117 | return c.conn.WriteMessage(websocket.CloseMessage, []byte{}) 118 | } 119 | 120 | if err := c.conn.WriteMessage(websocket.TextMessage, []byte(message)); err != nil { 121 | return err 122 | } 123 | 124 | case <-ticker.C: 125 | if err := c.conn.SetWriteDeadline(time.Now().Add(writeWait)); err != nil { 126 | return err 127 | } 128 | 129 | if err := c.conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil { 130 | return err 131 | } 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/api/factories/errors.go: -------------------------------------------------------------------------------- 1 | package factories 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/ovh/metronome/src/api/models" 7 | log "github.com/sirupsen/logrus" 8 | ) 9 | 10 | // Error create a models.Error from an error 11 | func Error(err error) models.Error { 12 | if err == nil { 13 | err = errors.New("") 14 | log.Warn("Nil error connot be formatted as models.Error") 15 | } 16 | 17 | return models.Error{ 18 | Err: err.Error(), 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/api/models/bearerToken.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | // BearerToken is the struct which is exposed by the /auth endpoint 4 | type BearerToken struct { 5 | AccessToken string `json:"token,omitempty"` 6 | Type string `json:"tokenType"` 7 | RefreshToken string `json:"refreshToken,omitempty"` 8 | } 9 | -------------------------------------------------------------------------------- /src/api/models/error.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | // Error describe error serialization. 4 | type Error struct { 5 | Err string `json:"err"` 6 | } 7 | -------------------------------------------------------------------------------- /src/api/models/taskAns.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/ovh/metronome/src/metronome/models" 5 | ) 6 | 7 | // TaskAns hold Task attributes and state fields. 8 | type TaskAns struct { 9 | models.Task 10 | RunAt int64 `json:"runAt"` 11 | RunCode int64 `json:"runCode"` 12 | } 13 | 14 | // TasksAns is an array of TaskAns. 15 | type TasksAns []TaskAns 16 | -------------------------------------------------------------------------------- /src/api/models/token.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "time" 4 | 5 | // Token describe token serialization. 6 | type Token struct { 7 | Token string `db:"token"` 8 | UserID string `db:"user_id"` 9 | Roles []string `db:"roles"` 10 | Type string `db:"type"` 11 | CreatedAt time.Time `db:"created_at"` 12 | } 13 | 14 | // Tokens is a slice of Token 15 | type Tokens []Token 16 | -------------------------------------------------------------------------------- /src/api/models/user.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // User holds user attributes. 8 | type User struct { 9 | ID string `json:"id" sql:"user_id,pk"` 10 | Name string `json:"name"` 11 | Password string `json:"password,omitempty"` 12 | Roles []string `json:"roles,omitempty"` 13 | CreatedAt time.Time `json:"created_at"` 14 | } 15 | 16 | // Users defined an array of user. 17 | type Users []User 18 | -------------------------------------------------------------------------------- /src/api/routers/auth.go: -------------------------------------------------------------------------------- 1 | package routers 2 | 3 | import ( 4 | authCtrl "github.com/ovh/metronome/src/api/controllers/auth" 5 | ) 6 | 7 | // AuthRoutes defined auth endpoints 8 | var AuthRoutes = Routes{ 9 | Route{"Get access token", "POST", "/", authCtrl.AuthHandler}, 10 | Route{"Logoff a user", "POST", "/logout", authCtrl.LogoutHandler}, 11 | } 12 | -------------------------------------------------------------------------------- /src/api/routers/router.go: -------------------------------------------------------------------------------- 1 | // Package routers defined the api endpoints 2 | package routers 3 | 4 | import ( 5 | "net/http" 6 | "path" 7 | 8 | "github.com/gorilla/mux" 9 | ) 10 | 11 | // Route defined an http endpoint. 12 | type Route struct { 13 | Name string 14 | Method string 15 | Pattern string 16 | HandlerFunc http.HandlerFunc 17 | } 18 | 19 | // Routes defined multiple http endoints. 20 | type Routes []Route 21 | 22 | // InitRoutes bind the http endpoints. 23 | func InitRoutes() *mux.Router { 24 | router := mux.NewRouter() 25 | bind(router, "/task", TaskRoutes) 26 | bind(router, "/tasks", TasksRoutes) 27 | bind(router, "/auth", AuthRoutes) 28 | bind(router, "/user", UserRoutes) 29 | bind(router, "/ws", WsRoutes) 30 | return router 31 | } 32 | 33 | func bind(router *mux.Router, base string, routes Routes) { 34 | for _, route := range routes { 35 | p := path.Join(base, route.Pattern) 36 | 37 | router. 38 | Methods(route.Method). 39 | Path(p). 40 | Name(route.Name). 41 | HandlerFunc(route.HandlerFunc) 42 | 43 | if p != "/" { 44 | router. 45 | Methods(route.Method). 46 | Path(p + "/"). 47 | Name(route.Name). 48 | HandlerFunc(route.HandlerFunc) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/api/routers/task.go: -------------------------------------------------------------------------------- 1 | package routers 2 | 3 | import ( 4 | taskCtrl "github.com/ovh/metronome/src/api/controllers/task" 5 | ) 6 | 7 | // TaskRoutes defined task endpoints. 8 | var TaskRoutes = Routes{ 9 | Route{"Create task", "POST", "/", taskCtrl.Create}, 10 | Route{"Delete task", "DELETE", "/{id:\\S{1,256}}", taskCtrl.Delete}, 11 | } 12 | -------------------------------------------------------------------------------- /src/api/routers/tasks.go: -------------------------------------------------------------------------------- 1 | package routers 2 | 3 | import ( 4 | tasksCtrl "github.com/ovh/metronome/src/api/controllers/tasks" 5 | ) 6 | 7 | // TasksRoutes defined tasks endpoints. 8 | var TasksRoutes = Routes{ 9 | Route{"Get tasks", "GET", "/", tasksCtrl.All}, 10 | } 11 | -------------------------------------------------------------------------------- /src/api/routers/user.go: -------------------------------------------------------------------------------- 1 | package routers 2 | 3 | import ( 4 | userCtrl "github.com/ovh/metronome/src/api/controllers/user" 5 | ) 6 | 7 | // UserRoutes defined user endpoints. 8 | var UserRoutes = Routes{ 9 | Route{"Create a user", "POST", "/", userCtrl.Create}, 10 | Route{"Edit a user", "PATCH", "/", userCtrl.Edit}, 11 | Route{"Retrieve current user", "GET", "/", userCtrl.Current}, 12 | } 13 | -------------------------------------------------------------------------------- /src/api/routers/ws.go: -------------------------------------------------------------------------------- 1 | package routers 2 | 3 | import ( 4 | wsCtrl "github.com/ovh/metronome/src/api/controllers/ws" 5 | ) 6 | 7 | // WsRoutes defined websockets endpoints. 8 | var WsRoutes = Routes{ 9 | Route{"Websocket", "GET", "/", wsCtrl.Join}, 10 | } 11 | -------------------------------------------------------------------------------- /src/api/services/auth/auth_service.go: -------------------------------------------------------------------------------- 1 | // Package authsrv handle authorization token operations. 2 | package authsrv 3 | 4 | import ( 5 | "errors" 6 | "strings" 7 | 8 | jwt "github.com/dgrijalva/jwt-go" 9 | 10 | "github.com/ovh/metronome/src/api/core/oauth" 11 | "github.com/ovh/metronome/src/api/models" 12 | "github.com/ovh/metronome/src/metronome/core" 13 | "github.com/ovh/metronome/src/metronome/pg" 14 | ) 15 | 16 | // BearerTokensFromUser return both new Access and Refresh tokens. 17 | func BearerTokensFromUser(user *models.User) (*models.BearerToken, error) { 18 | db := pg.DB() 19 | 20 | refreshToken, err := oauth.GenerateRefreshToken(user.ID, user.Roles) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | // We need to forward the refresh token to the client 26 | // and store an encrypted version into our DB 27 | res, err := db.Model(refreshToken).Insert() 28 | if err != nil || res.RowsAffected() == 0 { 29 | return nil, err 30 | } 31 | 32 | accessToken, err := oauth.GenerateAccessToken(user.ID, user.Roles, refreshToken.Token) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | return &models.BearerToken{ 38 | AccessToken: accessToken, 39 | RefreshToken: refreshToken.Token, 40 | Type: "bearer", 41 | }, nil 42 | } 43 | 44 | // GetToken return a token from a accessToken string. 45 | // Return nil if the accessToken string is invalid or if the token as expired. 46 | func GetToken(tokenString string) (*jwt.Token, error) { 47 | if strings.HasPrefix(tokenString, "Bearer ") { 48 | return oauth.GetToken(tokenString[7:]) 49 | } 50 | 51 | return oauth.GetToken(tokenString) 52 | } 53 | 54 | // UserID return the user id from a token. 55 | func UserID(token *jwt.Token) string { 56 | return oauth.UserID(token) 57 | } 58 | 59 | // Roles return the roles from a token. 60 | func Roles(token *jwt.Token) []string { 61 | return oauth.Roles(token) 62 | } 63 | 64 | // HasRole check if the token as a role. 65 | func HasRole(role string, token *jwt.Token) bool { 66 | for _, r := range Roles(token) { 67 | if r == role { 68 | return true 69 | } 70 | } 71 | return false 72 | } 73 | 74 | // getRefreshToken return a RefreshToken from PG 75 | // Returns nil if empty 76 | func getRefreshTokenFromDB(refreshToken string) (*models.Token, error) { 77 | db := pg.DB() 78 | 79 | token := new(models.Token) 80 | err := db.Model(token).Where("token = ? AND type = 'refresh'", refreshToken).Select() 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | return token, nil 86 | } 87 | 88 | // BearerTokensFromRefresh return a new AccessToken 89 | func BearerTokensFromRefresh(refreshToken string) (*models.BearerToken, error) { 90 | token, err := getRefreshTokenFromDB(refreshToken) 91 | if err == pg.ErrNoRows { 92 | return nil, errors.New("No such refresh token") 93 | } 94 | 95 | if err != nil { 96 | return nil, err 97 | } 98 | 99 | accessToken, err := oauth.GenerateAccessToken(token.UserID, token.Roles, refreshToken) 100 | if err != nil { 101 | return nil, err 102 | } 103 | 104 | return &models.BearerToken{ 105 | AccessToken: accessToken, 106 | Type: "bearer", 107 | RefreshToken: refreshToken, 108 | }, nil 109 | } 110 | 111 | // RevokeRefreshTokenFromAccess remove a RefreshToken from DB from an accessToken 112 | func RevokeRefreshTokenFromAccess(token *jwt.Token) error { 113 | db := pg.DB() 114 | 115 | refreshTokenPlain, err := oauth.RefreshToken(token) 116 | if err != nil { 117 | return err 118 | } 119 | 120 | users := models.Users{} 121 | err = db.Model(&users).Where("user_id = ?", oauth.UserID(token)).Select() 122 | if err != nil { 123 | return err 124 | } 125 | 126 | if len(users) == 0 { 127 | return nil 128 | } 129 | user := users[0] 130 | 131 | // We need to regenerate the salt 132 | ciphertext := core.PBKDF2(string(refreshTokenPlain), core.Sha256(user.ID)) 133 | var refreshToken models.Token 134 | _, err = db.Model(&refreshToken).Where("token = ? AND type = 'refresh'", ciphertext).Delete() 135 | return err 136 | } 137 | -------------------------------------------------------------------------------- /src/api/services/task/task_service.go: -------------------------------------------------------------------------------- 1 | // Package tasksrv handle task Kafka messages. 2 | package tasksrv 3 | 4 | import ( 5 | "time" 6 | 7 | log "github.com/sirupsen/logrus" 8 | 9 | acore "github.com/ovh/metronome/src/api/core" 10 | "github.com/ovh/metronome/src/metronome/core" 11 | "github.com/ovh/metronome/src/metronome/models" 12 | ) 13 | 14 | // Create a new task. 15 | // Return true if success. 16 | func Create(task *models.Task) bool { 17 | task.CreatedAt = time.Now() 18 | 19 | if len(task.ID) == 0 { 20 | task.ID = core.Sha256(task.UserID + task.Name + string(task.CreatedAt.Unix())) 21 | } 22 | 23 | k := acore.GetKafka() 24 | 25 | _, _, err := k.Producer.SendMessage(task.ToKafka()) 26 | if err != nil { 27 | log.Errorf("FAILED to send message: %s\n", err) 28 | return false 29 | } 30 | return true 31 | } 32 | 33 | // Delete a task. 34 | // Return true if success. 35 | func Delete(id string, userID string) bool { 36 | k := acore.GetKafka() 37 | 38 | t := &models.Task{ 39 | ID: id, 40 | UserID: userID, 41 | } 42 | 43 | _, _, err := k.Producer.SendMessage(t.ToKafka()) 44 | if err != nil { 45 | log.Errorf("FAILED to send message: %s\n", err) 46 | return false 47 | } 48 | return true 49 | } 50 | -------------------------------------------------------------------------------- /src/api/services/tasks/tasks_service.go: -------------------------------------------------------------------------------- 1 | // Package taskssrv handle tasks database operations. 2 | package taskssrv 3 | 4 | import ( 5 | amodels "github.com/ovh/metronome/src/api/models" 6 | "github.com/ovh/metronome/src/metronome/models" 7 | "github.com/ovh/metronome/src/metronome/pg" 8 | "github.com/ovh/metronome/src/metronome/redis" 9 | log "github.com/sirupsen/logrus" 10 | ) 11 | 12 | // All retrieve all the tasks of a user. 13 | // Return nil if no task. 14 | func All(userID string) (*amodels.TasksAns, error) { 15 | 16 | var tasks models.Tasks 17 | db := pg.DB() 18 | 19 | err := db.Model(&tasks).Where("user_id = ?", userID).Select() 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | if len(tasks) == 0 { 25 | return nil, nil 26 | } 27 | 28 | states := redis.DB().HGetAll(userID) 29 | if states.Err() != nil { 30 | return nil, states.Err() 31 | } 32 | 33 | var ans amodels.TasksAns 34 | for _, t := range tasks { 35 | var s models.State 36 | state, ok := states.Val()[t.GUID] 37 | if !ok { 38 | log.Warnf("No such entry in map states for key '%s'", t.GUID) 39 | ans = append(ans, amodels.TaskAns{ 40 | Task: t, 41 | }) 42 | continue 43 | } 44 | 45 | if err = s.FromJSON([]byte(state)); err != nil { 46 | return nil, err 47 | } 48 | 49 | ans = append(ans, amodels.TaskAns{ 50 | Task: t, 51 | RunAt: s.At, 52 | RunCode: s.State, 53 | }) 54 | } 55 | 56 | return &ans, err 57 | } 58 | -------------------------------------------------------------------------------- /src/api/services/user/user_service.go: -------------------------------------------------------------------------------- 1 | // Package usersrv handle user database operations. 2 | package usersrv 3 | 4 | import ( 5 | "golang.org/x/crypto/bcrypt" 6 | 7 | "github.com/ovh/metronome/src/api/models" 8 | "github.com/ovh/metronome/src/metronome/pg" 9 | ) 10 | 11 | // Login made a lookup on the database base on username and perform password comparaison. 12 | // It return nil if the username is unknown or the password mismatch. 13 | func Login(username, password string) (*models.User, error) { 14 | db := pg.DB() 15 | 16 | users := models.Users{} 17 | err := db.Model(&users).Where("name = ?", username).Select() 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | if len(users) == 0 { 23 | return nil, err 24 | } 25 | 26 | user := users[0] 27 | if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil { 28 | return nil, err 29 | } 30 | 31 | return &user, nil 32 | } 33 | 34 | // Create a new user into the database. 35 | // Return true if the username already exist. 36 | func Create(user *models.User) (bool, error) { 37 | password, err := genPassword([]byte(user.Password)) 38 | if err != nil { 39 | return false, err 40 | } 41 | 42 | user.Password = string(password) 43 | 44 | db := pg.DB() 45 | res, err := db.Model(&user).OnConflict("DO NOTHING").Insert() 46 | if err != nil { 47 | return false, err 48 | } 49 | if res.RowsAffected() == 0 { 50 | return true, nil 51 | } 52 | 53 | user.Password = "" // remove password hash 54 | return false, nil 55 | } 56 | 57 | // Edit a user in the database. 58 | // Return true if the username already exist. 59 | func Edit(userID string, user *models.User) (bool, error) { 60 | db := pg.DB() 61 | 62 | var cols []string 63 | if len(user.Password) > 0 { 64 | password, err := genPassword([]byte(user.Password)) 65 | if err != nil { 66 | return false, err 67 | } 68 | user.Password = string(password) 69 | cols = append(cols, "password") 70 | } 71 | 72 | user.ID = userID 73 | _, err := db.Model(&user).OnConflict("DO NOTHING").Column(cols...).Update() 74 | if err != nil { 75 | return false, err 76 | } 77 | 78 | user.Password = "" // remove password hash 79 | return false, nil 80 | } 81 | 82 | // Get a user from the database. 83 | // Return nil if the user is not found. 84 | func Get(userID string) (*models.User, error) { 85 | db := pg.DB() 86 | 87 | var users models.Users 88 | err := db.Model(&users).Where("user_id = ?", userID).Select() 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | if len(users) == 0 { 94 | return nil, nil 95 | } 96 | 97 | user := users[0] 98 | user.Password = "" // remove password hash 99 | return &user, nil 100 | } 101 | 102 | // genPassword hash password using bcrypt. 103 | func genPassword(password []byte) ([]byte, error) { 104 | hash, err := bcrypt.GenerateFromPassword(password, bcrypt.DefaultCost) 105 | if err != nil { 106 | return nil, err 107 | } 108 | 109 | return hash, nil 110 | } 111 | -------------------------------------------------------------------------------- /src/metronome/core/pbkdf2.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "crypto/sha1" 5 | "encoding/hex" 6 | 7 | "golang.org/x/crypto/pbkdf2" 8 | ) 9 | 10 | var ( 11 | // passwordSecurityIterations is based on New NIST guidelines (Update August 2016) 12 | // https://pages.nist.gov/800-63-3/sp800-63b.html#sec5 13 | passwordSecurityIterations = 10000 14 | passwordSecurityKeylen = 512 15 | ) 16 | 17 | // PBKDF2 is hashing using pbkdf2 method 18 | func PBKDF2(str string, salt string) string { 19 | hashedPassword := pbkdf2.Key([]byte(str), []byte(salt), passwordSecurityIterations, passwordSecurityKeylen, sha1.New) 20 | return hex.EncodeToString(hashedPassword) 21 | } 22 | -------------------------------------------------------------------------------- /src/metronome/core/sha256.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/hex" 6 | ) 7 | 8 | // Sha256 hash a string in a sha256 way. 9 | func Sha256(in string) string { 10 | key := []byte(in) 11 | hash := sha256.Sum256(key) 12 | return hex.EncodeToString(hash[:]) 13 | } 14 | -------------------------------------------------------------------------------- /src/metronome/kafka/kafka.go: -------------------------------------------------------------------------------- 1 | package kafka 2 | 3 | import ( 4 | "github.com/Shopify/sarama" 5 | "github.com/spf13/viper" 6 | ) 7 | 8 | // NewConfig returns a new state sarama config. 9 | // Preset TLS and SASL config 10 | func NewConfig() *sarama.Config { 11 | config := sarama.NewConfig() 12 | if viper.GetBool("kafka.tls") { 13 | config.Net.TLS.Enable = true 14 | } 15 | 16 | if viper.IsSet("kafka.sasl.user") || viper.IsSet("kafka.sasl.password") { 17 | config.Net.SASL.Enable = true 18 | config.Net.SASL.User = viper.GetString("kafka.sasl.user") 19 | config.Net.SASL.Password = viper.GetString("kafka.sasl.password") 20 | } 21 | 22 | config.Version = sarama.V0_10_0_1 23 | 24 | return config 25 | } 26 | 27 | // TopicTasks kafka topic used for tasks 28 | func TopicTasks() string { 29 | return viper.GetString("kafka.topics.tasks") 30 | } 31 | 32 | // TopicJobs kafka topic used for jobs 33 | func TopicJobs() string { 34 | return viper.GetString("kafka.topics.jobs") 35 | } 36 | 37 | // TopicStates kafka topic used for states 38 | func TopicStates() string { 39 | return viper.GetString("kafka.topics.states") 40 | } 41 | 42 | // GroupSchedulers kafka consumer group used for schedulers 43 | func GroupSchedulers() string { 44 | return viper.GetString("kafka.groups.schedulers") 45 | } 46 | 47 | // GroupAggregators kafka consumer group used for aggregators 48 | func GroupAggregators() string { 49 | return viper.GetString("kafka.groups.aggregators") 50 | } 51 | 52 | // GroupWorkers kafka consumer group used for workers 53 | func GroupWorkers() string { 54 | return viper.GetString("kafka.groups.workers") 55 | } 56 | -------------------------------------------------------------------------------- /src/metronome/metrics/metrics.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/prometheus/client_golang/prometheus/promhttp" 7 | log "github.com/sirupsen/logrus" 8 | "github.com/spf13/viper" 9 | ) 10 | 11 | // Serve start the metrics endpoint 12 | func Serve() { 13 | http.Handle(viper.GetString("metrics.path"), promhttp.Handler()) 14 | addr := viper.GetString("metrics.addr") 15 | go func() { 16 | log.Fatal(http.ListenAndServe(addr, nil)) 17 | }() 18 | } 19 | -------------------------------------------------------------------------------- /src/metronome/models/job.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "fmt" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/Shopify/sarama" 11 | log "github.com/sirupsen/logrus" 12 | 13 | "github.com/ovh/metronome/src/metronome/kafka" 14 | ) 15 | 16 | // Job is a task execution. 17 | type Job struct { 18 | GUID string `json:"guid"` 19 | UserID string `json:"user_id"` 20 | At int64 `json:"at"` 21 | Epsilon int64 `json:"epsilon"` 22 | URN string `json:"URN"` 23 | Payload map[string]interface{} `json:"payload"` 24 | } 25 | 26 | // ToKafka serialize a Job to Kafka. 27 | func (j *Job) ToKafka() *sarama.ProducerMessage { 28 | payloadBytes, err := json.Marshal(j.Payload) 29 | if err != nil { 30 | log.WithError(err).Warn("Cannot marshall job payload") 31 | payloadBytes = []byte("{}") 32 | } 33 | p := base64.StdEncoding.EncodeToString(payloadBytes) 34 | 35 | return &sarama.ProducerMessage{ 36 | Topic: kafka.TopicJobs(), 37 | Key: sarama.StringEncoder(j.GUID), 38 | Value: sarama.StringEncoder(fmt.Sprintf("%v %v %v %v %v %v", j.GUID, j.UserID, j.At, j.Epsilon, j.URN, p)), 39 | } 40 | } 41 | 42 | // FromKafka unserialize a Job from Kafka. 43 | func (j *Job) FromKafka(msg *sarama.ConsumerMessage) error { 44 | key := string(msg.Key) 45 | segs := strings.Split(string(msg.Value), " ") 46 | if len(segs) != 6 { 47 | return fmt.Errorf("unprocessable job(%v) - bad segments", key) 48 | } 49 | 50 | timestamp, err := strconv.ParseInt(segs[2], 0, 64) 51 | if err != nil { 52 | return fmt.Errorf("unprocessable job(%v) - bad timestamp", key) 53 | } 54 | 55 | epsilon, err := strconv.ParseInt(segs[3], 0, 64) 56 | if err != nil { 57 | return fmt.Errorf("unprocessable job(%v) - bad epsilon", key) 58 | } 59 | 60 | var payload []byte 61 | payload, err = base64.StdEncoding.DecodeString(segs[5]) 62 | if err != nil { 63 | return fmt.Errorf("unprocessable job(%v) - bad payload (not base64)", key) 64 | } 65 | err = json.Unmarshal(payload, &j.Payload) 66 | if err != nil { 67 | return fmt.Errorf("unprocessable job(%v) - bad payload (not map string-interface)", key) 68 | } 69 | 70 | j.GUID = key 71 | j.UserID = segs[1] 72 | j.At = timestamp 73 | j.Epsilon = epsilon 74 | j.URN = segs[4] 75 | 76 | return nil 77 | } 78 | 79 | // ToJSON serialize a Task as JSON. 80 | func (j *Job) ToJSON() ([]byte, error) { 81 | out, err := json.Marshal(j) 82 | if err != nil { 83 | return nil, err 84 | } 85 | 86 | return out, nil 87 | } 88 | -------------------------------------------------------------------------------- /src/metronome/models/state.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/Shopify/sarama" 10 | 11 | "github.com/ovh/metronome/src/metronome/core" 12 | "github.com/ovh/metronome/src/metronome/kafka" 13 | ) 14 | 15 | const ( 16 | // Success task end success 17 | Success = iota 18 | // Failed task failed 19 | Failed 20 | // Expired task not performed within epsilon time frame 21 | Expired 22 | ) 23 | 24 | // State is a state of a task execution. 25 | type State struct { 26 | ID string `json:"id"` 27 | TaskGUID string `json:"taskGUID"` 28 | UserID string `json:"userID"` 29 | At int64 `json:"at"` 30 | DoneAt int64 `json:"doneAt"` 31 | Duration int64 `json:"duration"` 32 | URN string `json:"URN"` 33 | State int64 `json:"state"` 34 | } 35 | 36 | // States is a State array 37 | type States []State 38 | 39 | // ToKafka serialize a State to Kafka. 40 | func (s *State) ToKafka() *sarama.ProducerMessage { 41 | if len(s.ID) == 0 { 42 | s.ID = core.Sha256(s.TaskGUID + strconv.FormatInt(s.At, 10)) 43 | } 44 | return &sarama.ProducerMessage{ 45 | Topic: kafka.TopicStates(), 46 | Key: sarama.StringEncoder(s.ID), 47 | Value: sarama.StringEncoder(fmt.Sprintf("%v %v %v %v %v %v %v", s.TaskGUID, s.UserID, s.At, s.URN, s.DoneAt, s.Duration, s.State)), 48 | } 49 | } 50 | 51 | // FromKafka unserialize a State from Kafka. 52 | func (s *State) FromKafka(msg *sarama.ConsumerMessage) error { 53 | key := string(msg.Key) 54 | segs := strings.Split(string(msg.Value), " ") 55 | if len(segs) != 7 { 56 | return fmt.Errorf("unprocessable state(%v) - bad segments", key) 57 | } 58 | 59 | at, err := strconv.ParseInt(segs[2], 0, 64) 60 | if err != nil { 61 | return fmt.Errorf("unprocessable state(%v) - bad at", key) 62 | } 63 | 64 | doneAt, err := strconv.ParseInt(segs[4], 0, 64) 65 | if err != nil { 66 | return fmt.Errorf("unprocessable state(%v) - bad done at", key) 67 | } 68 | 69 | duration, err := strconv.ParseInt(segs[5], 0, 64) 70 | if err != nil { 71 | return fmt.Errorf("unprocessable state(%v) - bad duration", key) 72 | } 73 | 74 | state, err := strconv.ParseInt(segs[6], 0, 64) 75 | if err != nil { 76 | return fmt.Errorf("unprocessable state(%v) - bad state", key) 77 | } 78 | 79 | s.ID = key 80 | s.TaskGUID = segs[0] 81 | s.UserID = segs[1] 82 | s.At = at 83 | s.DoneAt = doneAt 84 | s.Duration = duration 85 | s.URN = segs[3] 86 | s.State = state 87 | 88 | return nil 89 | } 90 | 91 | // ToJSON serialize a State as JSON. 92 | func (s *State) ToJSON() ([]byte, error) { 93 | out, err := json.Marshal(s) 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | return out, nil 99 | } 100 | 101 | // FromJSON unserialize a State from JSON. 102 | func (s *State) FromJSON(in []byte) error { 103 | if len(in) == 0 { 104 | s.State = -1 105 | return nil 106 | } 107 | 108 | return json.Unmarshal(in, &s) 109 | } 110 | -------------------------------------------------------------------------------- /src/metronome/models/task.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "fmt" 7 | "net/url" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "github.com/Shopify/sarama" 13 | log "github.com/sirupsen/logrus" 14 | 15 | "github.com/ovh/metronome/src/metronome/core" 16 | "github.com/ovh/metronome/src/metronome/kafka" 17 | ) 18 | 19 | // Task holds task attributes. 20 | type Task struct { 21 | GUID string `json:"guid" sql:"guid,pk"` 22 | ID string `json:"id" sql:"id"` 23 | UserID string `json:"user_id"` 24 | Name string `json:"name"` 25 | Schedule string `json:"schedule"` 26 | URN string `json:"URN"` 27 | Payload map[string]interface{} `json:"payload" sql:",notnull"` 28 | CreatedAt time.Time `json:"created_at"` 29 | } 30 | 31 | // Tasks is a Task list 32 | type Tasks []Task 33 | 34 | // ToKafka serialize a Task to Kafka. 35 | func (t *Task) ToKafka() *sarama.ProducerMessage { 36 | if len(t.GUID) == 0 { 37 | t.GUID = core.Sha256(t.UserID + t.ID) 38 | } 39 | 40 | pBytes, err := json.Marshal(t.Payload) 41 | if err != nil { 42 | log.WithError(err).Warn("Cannot marshall Task payload") 43 | pBytes = []byte("{}") 44 | } 45 | p := base64.StdEncoding.EncodeToString(pBytes) 46 | 47 | return &sarama.ProducerMessage{ 48 | Topic: kafka.TopicTasks(), 49 | Key: sarama.StringEncoder(t.GUID), 50 | Value: sarama.StringEncoder(fmt.Sprintf("%v %v %v %v %v %v %v", t.UserID, t.ID, t.Schedule, t.URN, url.QueryEscape(t.Name), t.CreatedAt.Unix(), p)), 51 | } 52 | } 53 | 54 | // FromKafka unserialize a Task from Kafka. 55 | func (t *Task) FromKafka(msg *sarama.ConsumerMessage) error { 56 | key := string(msg.Key) 57 | segs := strings.Split(string(msg.Value), " ") 58 | if len(segs) != 7 { 59 | log.Infof("segments: %+v %+v", segs, len(segs)) 60 | return fmt.Errorf("unprocessable task(%v) - bad segments", key) 61 | } 62 | 63 | name, err := url.QueryUnescape(segs[4]) 64 | if err != nil { 65 | return fmt.Errorf("unprocessable task(%v) - bad name", key) 66 | } 67 | 68 | timestamp, err := strconv.Atoi(segs[5]) 69 | if err != nil { 70 | return fmt.Errorf("unprocessable task(%v) - bad timestamp", key) 71 | } 72 | 73 | payload, err := base64.StdEncoding.DecodeString(segs[6]) 74 | if err != nil { 75 | return fmt.Errorf("unprocessable task(%v) - Bad payload (not Base64)", key) 76 | } 77 | err = json.Unmarshal(payload, &t.Payload) 78 | if err != nil { 79 | return fmt.Errorf("unprocessable task(%v) - Bad payload (not map string-interface)", key) 80 | } 81 | 82 | t.GUID = key 83 | t.UserID = segs[0] 84 | t.ID = segs[1] 85 | t.Schedule = segs[2] 86 | t.URN = segs[3] 87 | t.Name = name 88 | t.CreatedAt = time.Unix(int64(timestamp), 0) 89 | 90 | return nil 91 | } 92 | 93 | // ToJSON serialize a Task as JSON. 94 | func (t *Task) ToJSON() ([]byte, error) { 95 | out, err := json.Marshal(t) 96 | if err != nil { 97 | return nil, err 98 | } 99 | 100 | return out, nil 101 | } 102 | -------------------------------------------------------------------------------- /src/metronome/pg/assets.go: -------------------------------------------------------------------------------- 1 | package pg 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/gobuffalo/packr" 7 | ) 8 | 9 | var ( 10 | boxOnce sync.Once 11 | box packr.Box 12 | ) 13 | 14 | // Assets return a packr box which contains all assets 15 | func Assets() *packr.Box { 16 | boxOnce.Do(func() { 17 | box = packr.NewBox("./schema") 18 | }) 19 | 20 | return &box 21 | } 22 | -------------------------------------------------------------------------------- /src/metronome/pg/pg.go: -------------------------------------------------------------------------------- 1 | package pg 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/spf13/viper" 7 | "gopkg.in/pg.v5" 8 | ) 9 | 10 | type db struct { 11 | DB *pg.DB 12 | } 13 | 14 | var ( 15 | d *db 16 | once sync.Once 17 | 18 | // ErrNoRows is throwed when SELECT returns nothing 19 | ErrNoRows = pg.ErrNoRows 20 | ) 21 | 22 | // DB get a database instance 23 | func DB() *pg.DB { 24 | once.Do(func() { 25 | database := pg.Connect(&pg.Options{ 26 | Addr: viper.GetString("pg.addr"), 27 | User: viper.GetString("pg.user"), 28 | Password: viper.GetString("pg.password"), 29 | Database: viper.GetString("pg.database"), 30 | }) 31 | 32 | d = &db{ 33 | DB: database, 34 | } 35 | }) 36 | return d.DB 37 | } 38 | -------------------------------------------------------------------------------- /src/metronome/pg/schema/extensions.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; 2 | -------------------------------------------------------------------------------- /src/metronome/pg/schema/tasks.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS tasks 2 | ( 3 | guid text NOT NULL, 4 | user_id uuid NOT NULL, 5 | name text NOT NULL, 6 | urn text NOT NULL, 7 | schedule text NOT NULL, 8 | payload jsonb, 9 | created_at timestamp without time zone NOT NULL, 10 | id text NOT NULL, 11 | CONSTRAINT tasks_pkey PRIMARY KEY (guid), 12 | CONSTRAINT user_id_fk FOREIGN KEY (user_id) 13 | REFERENCES users (user_id) MATCH SIMPLE 14 | ON UPDATE NO ACTION 15 | ON DELETE NO ACTION 16 | ) 17 | -------------------------------------------------------------------------------- /src/metronome/pg/schema/tokens.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS tokens 2 | ( 3 | token text NOT NULL, 4 | user_id uuid NOT NULL, 5 | type character varying(256) NOT NULL, 6 | created_at timestamp without time zone NOT NULL DEFAULT now(), 7 | roles jsonb, 8 | CONSTRAINT tokens_pkey PRIMARY KEY (token), 9 | CONSTRAINT user_id_fk FOREIGN KEY (user_id) 10 | REFERENCES users (user_id) MATCH SIMPLE 11 | ON UPDATE NO ACTION 12 | ON DELETE NO ACTION 13 | ); 14 | CREATE UNIQUE INDEX IF NOT EXISTS tokens_token_idx 15 | ON tokens USING btree 16 | (token) 17 | TABLESPACE pg_default; 18 | -------------------------------------------------------------------------------- /src/metronome/pg/schema/users.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS users 2 | ( 3 | user_id uuid NOT NULL DEFAULT uuid_generate_v4(), 4 | name character varying(256) NOT NULL, 5 | password character varying(256) NOT NULL, 6 | created_at timestamp without time zone NOT NULL DEFAULT now(), 7 | roles jsonb, 8 | CONSTRAINT users_pkey PRIMARY KEY (user_id) 9 | ); 10 | 11 | CREATE UNIQUE INDEX IF NOT EXISTS users_name_idx 12 | ON users USING btree 13 | (name) 14 | TABLESPACE pg_default; 15 | -------------------------------------------------------------------------------- /src/metronome/redis/redis.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/spf13/viper" 7 | "gopkg.in/redis.v5" 8 | ) 9 | 10 | type db struct { 11 | DB *Client 12 | } 13 | 14 | var d *db 15 | var onceDB sync.Once 16 | 17 | // Client is a redis client 18 | type Client struct { 19 | *redis.Client 20 | } 21 | 22 | // DB get a database instance 23 | func DB() *Client { 24 | onceDB.Do(func() { 25 | redis := redis.NewClient(&redis.Options{ 26 | Addr: viper.GetString("redis.addr"), 27 | Password: viper.GetString("redis.pass"), 28 | DB: 0, 29 | }) 30 | 31 | d = &db{ 32 | DB: &Client{redis}, 33 | } 34 | }) 35 | return d.DB 36 | } 37 | 38 | // PublishTopic send a message to a given topic 39 | func (c *Client) PublishTopic(channel, topic, message string) *redis.IntCmd { 40 | return c.Publish(channel, topic+":"+message) 41 | } 42 | -------------------------------------------------------------------------------- /src/scheduler/cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | "os/signal" 6 | "strings" 7 | "sync" 8 | 9 | log "github.com/sirupsen/logrus" 10 | "github.com/spf13/cobra" 11 | "github.com/spf13/viper" 12 | 13 | "github.com/ovh/metronome/src/metronome/metrics" 14 | "github.com/ovh/metronome/src/scheduler/routines" 15 | ) 16 | 17 | // Scheduler init - define command line arguments 18 | func init() { 19 | cobra.OnInitialize(initConfig) 20 | 21 | RootCmd.PersistentFlags().StringP("config", "", "", "config file to use") 22 | RootCmd.PersistentFlags().BoolP("verbose", "v", false, "verbose output") 23 | 24 | RootCmd.Flags().StringSlice("kafka.brokers", []string{"localhost:9092"}, "kafka brokers address") 25 | RootCmd.Flags().String("redis.addr", "127.0.0.1:6379", "redis address") 26 | RootCmd.Flags().String("metrics.addr", "127.0.0.1:9100", "metrics address") 27 | 28 | if err := viper.BindPFlags(RootCmd.Flags()); err != nil { 29 | log.WithError(err).Error("Could not bind the flags") 30 | } 31 | 32 | if err := viper.BindPFlags(RootCmd.PersistentFlags()); err != nil { 33 | log.WithError(err).Error("Could not bind the persistent flags") 34 | } 35 | } 36 | 37 | // Load config - initialize defaults and read config 38 | func initConfig() { 39 | if viper.GetBool("verbose") { 40 | log.SetLevel(log.DebugLevel) 41 | } 42 | 43 | // Set defaults 44 | viper.SetDefault("metrics.addr", ":9100") 45 | viper.SetDefault("metrics.path", "/metrics") 46 | viper.SetDefault("redis.pass", "") 47 | viper.SetDefault("kafka.tls", false) 48 | viper.SetDefault("kafka.topics.tasks", "tasks") 49 | viper.SetDefault("kafka.topics.jobs", "jobs") 50 | viper.SetDefault("kafka.topics.states", "states") 51 | viper.SetDefault("kafka.groups.schedulers", "schedulers") 52 | viper.SetDefault("kafka.groups.aggregators", "aggregators") 53 | viper.SetDefault("kafka.groups.workers", "workers") 54 | viper.SetDefault("worker.poolsize", 100) 55 | viper.SetDefault("token.ttl", 3600) 56 | viper.SetDefault("redis.pass", "") 57 | 58 | // Bind environment variables 59 | viper.SetEnvPrefix("mtrsch") 60 | viper.SetEnvKeyReplacer(strings.NewReplacer("_", ".")) 61 | viper.AutomaticEnv() 62 | 63 | // Set config search path 64 | viper.AddConfigPath("/etc/metronome/") 65 | viper.AddConfigPath("$HOME/.metronome") 66 | viper.AddConfigPath(".") 67 | 68 | // Load default config 69 | viper.SetConfigName("default") 70 | if err := viper.MergeInConfig(); err != nil { 71 | if _, ok := err.(viper.ConfigFileNotFoundError); ok { 72 | log.Debug("No default config file found") 73 | } else { 74 | log.Panicf("Fatal error in default config file: %v \n", err) 75 | } 76 | } 77 | 78 | // Load scheduler config 79 | viper.SetConfigName("scheduler") 80 | if err := viper.MergeInConfig(); err != nil { 81 | if _, ok := err.(viper.ConfigFileNotFoundError); ok { 82 | log.Debug("No scheduler config file found") 83 | } else { 84 | log.Panicf("Fatal error in scheduler config file: %v \n", err) 85 | } 86 | } 87 | 88 | // Load user defined config 89 | cfgFile := viper.GetString("config") 90 | if cfgFile != "" { 91 | viper.SetConfigFile(cfgFile) 92 | err := viper.ReadInConfig() 93 | if err != nil { 94 | log.Panicf("Fatal error in config file: %v \n", err) 95 | } 96 | } 97 | } 98 | 99 | // RootCmd launch the scheduler agent. 100 | var RootCmd = &cobra.Command{ 101 | Use: "metronome-scheduler", 102 | Short: "Metronome scheduler plan tasks executions", 103 | Long: `Metronome is a distributed and fault-tolerant event scheduler built with love by ovh teams and friends in Go. 104 | Complete documentation is available at http://ovh.github.io/metronome`, 105 | Run: func(cmd *cobra.Command, args []string) { 106 | log.Info("Metronome Scheduler starting") 107 | 108 | log.Info("Loading tasks") 109 | tc, err := routines.NewTaskConsumer() 110 | if err != nil { 111 | log.WithError(err).Fatal("Could not start the task consumer") 112 | } 113 | 114 | metrics.Serve() 115 | 116 | // Trap SIGINT to trigger a shutdown. 117 | sigint := make(chan os.Signal, 1) 118 | signal.Notify(sigint, os.Interrupt) 119 | 120 | var schedulers sync.WaitGroup 121 | 122 | running := true 123 | 124 | loop: 125 | for { 126 | select { 127 | case partition := <-tc.Partitons(): 128 | schedulers.Add(1) 129 | go func() { 130 | log.Infof("Scheduler start %v", partition.Partition) 131 | ts, err := routines.NewTaskScheduler(partition.Partition, partition.Tasks) 132 | if err != nil { 133 | log.WithError(err).Error("Could not create a new task scheduler") 134 | return 135 | } 136 | 137 | tc.WaitForDrain() 138 | log.Infof("Scheduler tasks loaded %v", partition.Partition) 139 | if running { 140 | if err = ts.Start(); err != nil { 141 | log.WithError(err).Error("Could not start the task scheduler") 142 | return 143 | } 144 | log.Infof("Scheduler started %v", partition.Partition) 145 | } 146 | 147 | ts.Halted() 148 | log.Infof("Scheduler halted %v", partition.Partition) 149 | schedulers.Done() 150 | }() 151 | case <-sigint: 152 | log.Info("Shuting down") 153 | running = false 154 | break loop 155 | } 156 | } 157 | 158 | if err = tc.Close(); err != nil { 159 | log.WithError(err).Error("Could not stop gracefully the task consumer") 160 | } 161 | 162 | log.Infof("Consumer halted") 163 | schedulers.Wait() 164 | }, 165 | } 166 | -------------------------------------------------------------------------------- /src/scheduler/cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | var ( 11 | version = "0.0.0" 12 | githash = "HEAD" 13 | date = "1970-01-01T00:00:00Z UTC" 14 | ) 15 | 16 | func init() { 17 | RootCmd.AddCommand(versionCmd) 18 | } 19 | 20 | var versionCmd = &cobra.Command{ 21 | Use: "version", 22 | Short: "Show version", 23 | Run: func(cmd *cobra.Command, arguments []string) { 24 | fmt.Printf("metronome-scheduler version %s %s\n", version, githash) 25 | fmt.Printf("metronome-scheduler build date %s\n", date) 26 | fmt.Printf("go version %s %s/%s\n", runtime.Version(), runtime.GOOS, runtime.GOARCH) 27 | }, 28 | } 29 | -------------------------------------------------------------------------------- /src/scheduler/core/core_suite_test.go: -------------------------------------------------------------------------------- 1 | package core_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo" 5 | . "github.com/onsi/gomega" 6 | "testing" 7 | ) 8 | 9 | func TestCore(t *testing.T) { 10 | RegisterFailHandler(Fail) 11 | RunSpecs(t, "Scheduler Core Suite") 12 | } 13 | -------------------------------------------------------------------------------- /src/scheduler/core/duration.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "regexp" 5 | "strconv" 6 | "time" 7 | ) 8 | 9 | var durationRegex = regexp.MustCompile(`P(?P\d+Y)?(?P\d+M)?(?P\d+D)?T?(?P\d+H)?(?P\d+M)?(?P\d+S)?`) 10 | 11 | // ParseDuration return a time.Duration from an iso string. 12 | func ParseDuration(str string) time.Duration { 13 | matches := durationRegex.FindStringSubmatch(str) 14 | 15 | years := ParseInt64(matches[1]) 16 | months := ParseInt64(matches[2]) 17 | days := ParseInt64(matches[3]) 18 | hours := ParseInt64(matches[4]) 19 | minutes := ParseInt64(matches[5]) 20 | seconds := ParseInt64(matches[6]) 21 | 22 | hour := int64(time.Hour) 23 | minute := int64(time.Minute) 24 | second := int64(time.Second) 25 | return time.Duration(years*24*365*hour + months*30*24*hour + days*24*hour + hours*hour + minutes*minute + seconds*second) 26 | } 27 | 28 | // ParseInt64 return an int64 from a string. 29 | // Errors are handle as 0. 30 | func ParseInt64(value string) int64 { 31 | if len(value) == 0 { 32 | return 0 33 | } 34 | parsed, err := strconv.Atoi(value[:len(value)-1]) 35 | if err != nil { 36 | return 0 37 | } 38 | return int64(parsed) 39 | } 40 | -------------------------------------------------------------------------------- /src/scheduler/core/entry.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "math" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | "github.com/ovh/metronome/src/metronome/models" 12 | ) 13 | 14 | // Entry is a task with execution time management. 15 | type Entry struct { 16 | // The task to run 17 | task models.Task 18 | 19 | timeMode bool 20 | start time.Time 21 | period float64 22 | epsilon float64 23 | repeat int64 24 | 25 | months int64 26 | years int64 27 | 28 | next int64 29 | planned int64 30 | 31 | initialized bool 32 | } 33 | 34 | // NewEntry return a new entry. 35 | func NewEntry(task models.Task) (*Entry, error) { 36 | segs := strings.Split(task.Schedule, "/") 37 | if len(segs) != 4 { 38 | return nil, fmt.Errorf("Bad schedule %s", task.Schedule) 39 | } 40 | 41 | start, err := time.Parse(time.RFC3339, segs[1]) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | matches := durationRegex.FindStringSubmatch(segs[2]) 47 | 48 | r, rS := int64(-1), segs[0][1:] 49 | if len(rS) > 0 { 50 | parsed, err := strconv.Atoi(rS) 51 | if err != nil { 52 | return nil, fmt.Errorf("Bad repeat %s", task.Schedule) 53 | } 54 | r = int64(parsed) 55 | } 56 | 57 | e := &Entry{ 58 | task: task, 59 | epsilon: ParseDuration(strings.Replace(segs[3], "E", "P", 1)).Seconds(), 60 | start: start, 61 | repeat: r, 62 | timeMode: strings.Contains(segs[2], "T"), 63 | period: ParseDuration(segs[2]).Seconds(), 64 | next: -1, 65 | years: ParseInt64(matches[1]), 66 | months: ParseInt64(matches[2]), 67 | } 68 | 69 | if e.period == 0 { 70 | return nil, fmt.Errorf("Null period %v", task.Schedule) 71 | } 72 | 73 | return e, nil 74 | } 75 | 76 | // SameAs check if entry is semanticaly the same as a task. 77 | func (e *Entry) SameAs(t models.Task) bool { 78 | return e.task.URN == t.URN && 79 | e.task.Schedule == t.Schedule 80 | } 81 | 82 | // UserID return the task user ID. 83 | func (e *Entry) UserID() string { 84 | return e.task.UserID 85 | } 86 | 87 | // Epsilon return the task epsilon. 88 | func (e *Entry) Epsilon() int64 { 89 | return int64(e.epsilon) 90 | } 91 | 92 | // URN return the task urn. 93 | func (e *Entry) URN() string { 94 | return e.task.URN 95 | } 96 | 97 | // GUID return the task GUID. 98 | func (e *Entry) GUID() string { 99 | return e.task.GUID 100 | } 101 | 102 | // GetPayload return the Task payload 103 | func (e *Entry) GetPayload() map[string]interface{} { 104 | return e.task.Payload 105 | } 106 | 107 | // SetPayload upodate Task payload 108 | func (e *Entry) SetPayload(payload map[string]interface{}) { 109 | e.task.Payload = payload 110 | } 111 | 112 | // Next return the next execution time. 113 | // Return -1 if invalid. 114 | func (e *Entry) Next() int64 { 115 | return e.next 116 | } 117 | 118 | // Init the planning system 119 | // Must be called before Plan 120 | func (e *Entry) Init(now time.Time) { 121 | e.initialized = true 122 | 123 | if e.timeMode { 124 | e.next = e.initTimeMode(now) 125 | return // e.next, true 126 | } 127 | e.next = e.initDateMode(now) 128 | } 129 | 130 | // initTimeMode compute first iteration for time period 131 | func (e *Entry) initTimeMode(now time.Time) int64 { 132 | start := e.start.Unix() 133 | 134 | if start >= now.Unix() { 135 | e.planned = 1 136 | return start 137 | } 138 | 139 | n := int64(math.Ceil(float64(now.Unix()-start) / e.period)) 140 | 141 | if e.repeat >= 0 && n > e.repeat { 142 | return -1 143 | } 144 | 145 | next := start + int64(e.period)*int64(n) 146 | 147 | e.planned = (n + 1) 148 | return next 149 | } 150 | 151 | // initDateMode compute first iteration for date period 152 | func (e *Entry) initDateMode(now time.Time) int64 { 153 | if e.start.Unix() >= now.Unix() { 154 | e.planned = 1 155 | return e.start.Unix() 156 | } 157 | 158 | period := int(e.months + e.years*12) 159 | 160 | dY := now.Year() - e.start.Year() 161 | dM := int(now.Month() - e.start.Month()) 162 | dD := int(now.Day() - e.start.Day()) 163 | dh := int(now.Hour() - e.start.Hour()) 164 | dm := int(now.Minute() - e.start.Minute()) 165 | ds := int(now.Second() - e.start.Second()) 166 | 167 | n := dY*12 + dM/period 168 | dt := (((dD*24+dh)*60)+dm)*60 + ds 169 | 170 | if dt > 0 { 171 | n++ 172 | } 173 | if e.repeat >= 0 && int64(n) > e.repeat { 174 | return -1 175 | } 176 | 177 | next := e.start.AddDate(0, n*period, 0) 178 | 179 | dY = next.Year() - e.start.Year() 180 | dM = int(next.Month() - e.start.Month()) 181 | 182 | // overshoot (due to month rollover) 183 | if dY*12+dM > n*period { 184 | next = next.AddDate(0, 0, -next.Day()) 185 | } 186 | 187 | e.planned = int64(n + 1) 188 | return next.Unix() 189 | } 190 | 191 | // Plan the next execution time. 192 | // Return true if planning as been updated. 193 | func (e *Entry) Plan(now time.Time) (bool, error) { 194 | if !e.initialized { 195 | return false, errors.New("Unitialized entry. Please call init before") 196 | } 197 | 198 | if e.next >= now.Unix() { 199 | return false, nil 200 | } 201 | 202 | if e.next < 0 { 203 | return false, nil 204 | } 205 | 206 | if e.repeat >= 0 && e.planned > e.repeat { 207 | e.next = -1 208 | return false, nil 209 | } 210 | 211 | e.planned++ 212 | if e.timeMode { 213 | e.next += int64(e.period) 214 | } else { 215 | // date mode 216 | period := int(e.months + e.years*12) 217 | next := e.start.AddDate(0, period*int(e.planned-1), 0) 218 | 219 | dY := next.Year() - e.start.Year() 220 | dM := int(next.Month() - e.start.Month()) 221 | 222 | // overshoot (due to month rollover) 223 | if dY*12+dM > int(e.planned-1)*period { 224 | next = next.AddDate(0, 0, -next.Day()) 225 | } 226 | 227 | e.next = next.Unix() 228 | } 229 | 230 | return true, nil 231 | } 232 | -------------------------------------------------------------------------------- /src/scheduler/core/entry_test.go: -------------------------------------------------------------------------------- 1 | package core_test 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | . "github.com/onsi/ginkgo" 8 | . "github.com/onsi/ginkgo/extensions/table" 9 | . "github.com/onsi/gomega" 10 | 11 | "github.com/ovh/metronome/src/metronome/models" 12 | 13 | core "github.com/ovh/metronome/src/scheduler/core" 14 | ) 15 | 16 | func entry(schedule string) (*core.Entry, error) { 17 | return core.NewEntry(models.Task{ 18 | Schedule: schedule, 19 | }) 20 | } 21 | 22 | var _ = Describe("Entry", func() { 23 | Describe("New", func() { 24 | It("Good schedule", func() { 25 | _, err := entry("R/2016-12-15T11:39:00Z/PT1S/ET1S") 26 | Ω(err).ShouldNot(HaveOccurred()) 27 | }) 28 | 29 | It("Same as", func() { 30 | task := models.Task{ 31 | UserID: "UserID", 32 | GUID: "GUID", 33 | URN: "URN", 34 | Schedule: "R/2016-12-15T11:39:00Z/PT1S/ET1S", 35 | } 36 | 37 | entry, err := core.NewEntry(task) 38 | Ω(err).ShouldNot(HaveOccurred()) 39 | Ω(entry.SameAs(task)).Should(BeTrue()) 40 | Ω(entry.UserID() == task.UserID).Should(BeTrue()) 41 | Ω(entry.URN() == task.URN).Should(BeTrue()) 42 | Ω(entry.GUID() == task.GUID).Should(BeTrue()) 43 | }) 44 | 45 | It("Bad schedule", func() { 46 | _, err := entry("BadSchedule") 47 | Ω(err).Should(HaveOccurred()) 48 | }) 49 | 50 | It("Bad schedule date", func() { 51 | _, err := entry("R/notAdateZ/PT1S/ET1S") 52 | Ω(err).Should(HaveOccurred()) 53 | }) 54 | 55 | It("Null time period", func() { 56 | _, err := entry("R/2016-12-15T11:39:00Z/PT0S/ET1S") 57 | Ω(err).Should(HaveOccurred()) 58 | }) 59 | 60 | It("Null date period", func() { 61 | _, err := entry("R/2016-12-15T11:39:00Z/P0M/ET1S") 62 | Ω(err).Should(HaveOccurred()) 63 | }) 64 | 65 | It("With repeat", func() { 66 | _, err := entry("R3/2016-12-15T11:39:00Z/P1M/ET1S") 67 | Ω(err).ShouldNot(HaveOccurred()) 68 | }) 69 | 70 | It("Bad repeat", func() { 71 | _, err := entry("Ra/2016-12-15T11:39:00Z/P1M/ET1S") 72 | Ω(err).Should(HaveOccurred()) 73 | }) 74 | 75 | It("Set payload", func() { 76 | e, _ := entry("R/2016-12-15T11:39:00Z/PT1S/ET1S") 77 | p := map[string]interface{}{"x": "y"} 78 | 79 | e.SetPayload(p) 80 | ePayload := e.GetPayload() 81 | 82 | Ω(ePayload).ShouldNot(BeNil()) 83 | Ω(ePayload["x"]).Should(Equal("y")) 84 | }) 85 | }) 86 | 87 | DescribeTable("Init", 88 | func(schedule, now string, next string) { 89 | entry, err := entry(schedule) 90 | Ω(err).ShouldNot(HaveOccurred()) 91 | 92 | n, err := time.Parse(time.RFC3339, now) 93 | Ω(err).ShouldNot(HaveOccurred()) 94 | 95 | entry.Init(n) 96 | 97 | if len(next) > 0 { 98 | nx, err := time.Parse(time.RFC3339, next) 99 | Ω(err).ShouldNot(HaveOccurred()) 100 | 101 | Ω(time.Unix(entry.Next(), 0).UTC()).Should(BeTemporally("==", nx)) 102 | } else { 103 | Ω(entry.Next()).Should(Equal(int64(-1))) 104 | } 105 | }, 106 | // Time 107 | Entry("in the future", "R/2016-12-15T11:32:00Z/PT1S/ET1S", "2016-01-01T00:00:00Z", "2016-12-15T11:32:00Z"), 108 | Entry("1s", "R/2016-12-01T00:00:00Z/PT1S/ET1S", "2017-01-01T00:00:00Z", "2017-01-01T00:00:00Z"), 109 | Entry("10s start time", "R/2017-01-01T00:00:00Z/PT10S/ET1S", "2017-01-01T00:00:00Z", "2017-01-01T00:00:00Z"), 110 | Entry("10s on time", "R/2016-01-01T00:00:00Z/PT10S/ET1S", "2017-01-01T00:00:00Z", "2017-01-01T00:00:00Z"), 111 | Entry("10s", "R/2017-01-01T00:00:00Z/PT10S/ET1S", "2017-01-01T00:00:03Z", "2017-01-01T00:00:10Z"), 112 | Entry("1m", "R/2017-01-01T00:00:00Z/PT1M/ET1S", "2017-01-01T00:00:03Z", "2017-01-01T00:01:00Z"), 113 | Entry("1m10s", "R/2017-01-01T00:00:00Z/PT1M10S/ET1S", "2017-01-01T00:00:03Z", "2017-01-01T00:01:10Z"), 114 | Entry("1h", "R/2017-01-01T00:00:00Z/PT1H/ET1S", "2017-01-01T00:00:03Z", "2017-01-01T01:00:00Z"), 115 | Entry("1h5m24s", "R/2017-01-01T00:00:00Z/PT1H5M24S/ET1S", "2017-01-01T00:00:03Z", "2017-01-01T01:05:24Z"), 116 | Entry("1D", "R/2017-01-01T00:00:00Z/P1DT/ET1S", "2017-01-01T00:00:03Z", "2017-01-02T00:00:00Z"), 117 | Entry("1D3h27m17s", "R/2017-01-01T00:00:00Z/P1DT3H27M17S/ET1S", "2017-01-01T00:00:03Z", "2017-01-02T03:27:17Z"), 118 | // Date 119 | Entry("in the future", "R/2016-12-15T11:32:00Z/P1M/ET1S", "2016-01-01T00:00:00Z", "2016-12-15T11:32:00Z"), 120 | Entry("1M start time", "R/2017-01-01T00:00:00Z/P1M/ET1S", "2017-01-01T00:00:00Z", "2017-01-01T00:00:00Z"), 121 | Entry("1M on time", "R/2016-01-01T00:00:00Z/P1M/ET1S", "2017-01-01T00:00:00Z", "2017-01-01T00:00:00Z"), 122 | Entry("1M", "R/2017-01-01T00:00:00Z/P1M/ET1S", "2017-01-01T00:00:03Z", "2017-02-01T00:00:00Z"), 123 | Entry("1M end month", "R/2017-01-31T00:00:00Z/P1M/ET1S", "2017-02-01T00:00:03Z", "2017-02-28T00:00:00Z"), 124 | Entry("3M", "R/2017-01-01T00:00:00Z/P3M/ET1S", "2017-01-01T00:00:03Z", "2017-04-01T00:00:00Z"), 125 | Entry("3M end month", "R/2017-01-30T00:00:00Z/P3M/ET1S", "2017-01-31T00:00:03Z", "2017-04-30T00:00:00Z"), 126 | Entry("1Y", "R/2017-01-03T00:00:00Z/P1Y/ET1S", "2017-01-31T00:00:03Z", "2018-01-03T00:00:00Z"), 127 | Entry("1Y5M", "R/2017-01-03T03:00:00Z/P1Y5M/ET1S", "2017-01-31T00:00:03Z", "2018-06-03T03:00:00Z"), 128 | // Repeat 129 | Entry("10s no repeat", "R0/2017-01-01T00:00:00Z/PT10S/ET1S", "2017-01-01T00:00:00Z", "2017-01-01T00:00:00Z"), 130 | Entry("10s no repeat over", "R0/2017-01-01T00:00:00Z/PT10S/ET1S", "2017-01-01T00:00:01Z", ""), 131 | Entry("10s R3", "R3/2017-01-01T00:00:00Z/PT10S/ET1S", "2017-01-01T00:00:30Z", "2017-01-01T00:00:30Z"), 132 | Entry("10s R3 over", "R3/2017-01-01T00:00:00Z/PT10S/ET1S", "2017-01-01T00:00:31Z", ""), 133 | Entry("5M no repeat", "R0/2017-01-01T00:00:00Z/P5M/ET1S", "2017-01-01T00:00:00Z", "2017-01-01T00:00:00Z"), 134 | Entry("5M no repeat over", "R0/2017-01-01T00:00:00Z/P5M/ET1S", "2017-01-01T00:00:01Z", ""), 135 | Entry("5M R1", "R1/2017-01-01T00:00:00Z/P5M/ET1S", "2017-06-01T00:00:00Z", "2017-06-01T00:00:00Z"), 136 | Entry("5M R1 over", "R1/2017-01-01T00:00:00Z/P5M/ET1S", "2017-06-01T00:00:01Z", ""), 137 | ) 138 | 139 | Describe("Plan", func() { 140 | It("Should return an error if not initialized", func() { 141 | entry, err := entry("R/2016-12-15T11:39:00Z/PT1S/ET1S") 142 | Ω(err).ShouldNot(HaveOccurred()) 143 | 144 | _, err = entry.Plan(time.Now()) 145 | Ω(err).Should(HaveOccurred()) 146 | }) 147 | 148 | DescribeTable("Next", 149 | func(schedule string, plans []struct { 150 | now string 151 | next string 152 | }) { 153 | entry, err := entry(schedule) 154 | Ω(err).ShouldNot(HaveOccurred()) 155 | 156 | for i, plan := range plans { 157 | By(fmt.Sprintf("n%v - %v", i, plan.now)) 158 | 159 | n, err := time.Parse(time.RFC3339, plans[i].now) 160 | Ω(err).ShouldNot(HaveOccurred()) 161 | if i == 0 { 162 | entry.Init(n) 163 | } else { 164 | entry.Plan(n) 165 | } 166 | 167 | if len(plan.next) > 0 { 168 | nx, err := time.Parse(time.RFC3339, plan.next) 169 | Ω(err).ShouldNot(HaveOccurred()) 170 | 171 | Ω(time.Unix(entry.Next(), 0).UTC()).Should(BeTemporally("==", nx)) 172 | } else { 173 | Ω(entry.Next()).Should(Equal(int64(-1))) 174 | } 175 | 176 | } 177 | }, 178 | // Time 179 | Entry("in the future", "R/2016-12-15T11:32:00Z/PT1S/ET1S", []struct { 180 | now string 181 | next string 182 | }{{"2016-01-01T00:00:00Z", "2016-12-15T11:32:00Z"}, 183 | {"2016-01-01T00:00:01Z", "2016-12-15T11:32:00Z"}}), 184 | Entry("10s", "R/2017-01-01T00:00:00Z/PT10S/ET1S", []struct { 185 | now string 186 | next string 187 | }{{"2017-01-01T00:00:03Z", "2017-01-01T00:00:10Z"}, 188 | {"2017-01-01T00:00:11Z", "2017-01-01T00:00:20Z"}, 189 | {"2017-01-01T00:00:12Z", "2017-01-01T00:00:20Z"}, 190 | {"2017-01-01T00:00:20Z", "2017-01-01T00:00:20Z"}, 191 | {"2017-01-01T00:00:21Z", "2017-01-01T00:00:30Z"}}), 192 | // date 193 | Entry("3M", "R/2017-01-01T00:00:00Z/P3M/ET1S", []struct { 194 | now string 195 | next string 196 | }{{"2017-01-01T00:00:00Z", "2017-01-01T00:00:00Z"}, 197 | {"2017-01-01T00:00:11Z", "2017-04-01T00:00:00Z"}, 198 | {"2017-01-02T00:00:12Z", "2017-04-01T00:00:00Z"}, 199 | {"2017-02-01T00:00:00Z", "2017-04-01T00:00:00Z"}, 200 | {"2017-04-01T00:00:21Z", "2017-07-01T00:00:00Z"}}), 201 | Entry("1M end month", "R/2017-01-31T00:00:00Z/P1M/ET1S", []struct { 202 | now string 203 | next string 204 | }{{"2017-01-01T00:00:00Z", "2017-01-31T00:00:00Z"}, 205 | {"2017-01-31T00:00:00Z", "2017-01-31T00:00:00Z"}, 206 | {"2017-01-31T01:00:00Z", "2017-02-28T00:00:00Z"}, 207 | {"2017-02-15T00:00:00Z", "2017-02-28T00:00:00Z"}, 208 | {"2017-02-28T01:00:00Z", "2017-03-31T00:00:00Z"}}), 209 | // repeat 210 | Entry("7m repeat start time", "R2/2017-01-01T00:00:00Z/PT7M/ET1S", []struct { 211 | now string 212 | next string 213 | }{{"2017-01-01T00:00:00Z", "2017-01-01T00:00:00Z"}, 214 | {"2017-01-01T00:05:00Z", "2017-01-01T00:07:00Z"}, 215 | {"2017-01-01T00:08:00Z", "2017-01-01T00:14:00Z"}, 216 | {"2017-01-01T00:14:01Z", ""}}), 217 | Entry("2h repeat", "R1/2017-01-01T00:00:00Z/PT2H/ET1S", []struct { 218 | now string 219 | next string 220 | }{{"2017-01-01T00:00:03Z", "2017-01-01T02:00:00Z"}, 221 | {"2017-01-01T01:30:00Z", "2017-01-01T02:00:00Z"}, 222 | {"2017-01-01T02:00:00Z", "2017-01-01T02:00:00Z"}, 223 | {"2017-01-01T02:14:01Z", ""}}), 224 | ) 225 | }) 226 | }) 227 | -------------------------------------------------------------------------------- /src/scheduler/routines/jobConsumer.go: -------------------------------------------------------------------------------- 1 | package routines 2 | 3 | import ( 4 | "github.com/Shopify/sarama" 5 | log "github.com/sirupsen/logrus" 6 | "github.com/spf13/viper" 7 | 8 | "github.com/ovh/metronome/src/metronome/kafka" 9 | "github.com/ovh/metronome/src/metronome/models" 10 | ) 11 | 12 | // NewJobComsumer return a new job consumer 13 | func NewJobComsumer(offsets map[int32]int64, jobs chan models.Job) error { 14 | brokers := viper.GetStringSlice("kafka.brokers") 15 | 16 | config := kafka.NewConfig() 17 | config.ClientID = "metronome-scheduler" 18 | 19 | client, err := sarama.NewClient(brokers, config) 20 | if err != nil { 21 | return err 22 | } 23 | 24 | consumer, err := sarama.NewConsumerFromClient(client) 25 | if err != nil { 26 | return err 27 | } 28 | 29 | parts, err := client.Partitions(kafka.TopicJobs()) 30 | if err != nil { 31 | return err 32 | } 33 | 34 | hwm := make(map[int32]int64) 35 | for p := range parts { 36 | i, err := client.GetOffset(kafka.TopicJobs(), int32(p), sarama.OffsetNewest) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | hwm[int32(p)] = i 42 | } 43 | 44 | go func() { 45 | for part, offset := range offsets { 46 | if (offset + 1) >= hwm[part] { 47 | // already to the end 48 | continue 49 | } 50 | 51 | pc, err := consumer.ConsumePartition(kafka.TopicJobs(), part, offset) 52 | if err != nil { 53 | log.Error(err) 54 | continue 55 | } 56 | 57 | consume: 58 | for { 59 | select { 60 | case msg := <-pc.Messages(): 61 | var j models.Job 62 | if err := j.FromKafka(msg); err != nil { 63 | log.WithError(err).Warn("Could not retrieve the job from kafka message") 64 | continue 65 | } 66 | body, err := j.ToJSON() 67 | if err != nil { 68 | log.WithError(err).Warn("Could not deserialize the job") 69 | continue 70 | } 71 | log.Debugf("Job received: %s", string(body)) 72 | jobs <- j 73 | 74 | if (msg.Offset + 1) >= hwm[part] { 75 | break consume 76 | } 77 | } 78 | } 79 | 80 | if err := pc.Close(); err != nil { 81 | log.Error(err) 82 | } 83 | } 84 | 85 | close(jobs) 86 | }() 87 | 88 | return nil 89 | } 90 | -------------------------------------------------------------------------------- /src/scheduler/routines/jobProducer.go: -------------------------------------------------------------------------------- 1 | package routines 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | 7 | "github.com/Shopify/sarama" 8 | log "github.com/sirupsen/logrus" 9 | "github.com/spf13/viper" 10 | 11 | "github.com/ovh/metronome/src/metronome/kafka" 12 | "github.com/ovh/metronome/src/metronome/models" 13 | ) 14 | 15 | // JobProducer handle the internal states of the producer. 16 | type JobProducer struct { 17 | producer sarama.AsyncProducer 18 | wg sync.WaitGroup 19 | stopSig chan struct{} 20 | offsets map[int32]int64 21 | offsetsMutex sync.RWMutex 22 | } 23 | 24 | // NewJobProducer return a new job producer. 25 | // Read jobs to send from jobs channel. 26 | func NewJobProducer(jobs <-chan []models.Job) (*JobProducer, error) { 27 | config := kafka.NewConfig() 28 | config.ClientID = "metronome-scheduler" 29 | config.Producer.RequiredAcks = sarama.WaitForAll 30 | config.Producer.Timeout = 1 * time.Second 31 | config.Producer.Compression = sarama.CompressionSnappy 32 | config.Producer.Flush.Frequency = 300 * time.Millisecond 33 | config.Producer.Return.Successes = true 34 | config.Producer.Retry.Max = 3 35 | 36 | brokers := viper.GetStringSlice("kafka.brokers") 37 | 38 | producer, err := sarama.NewAsyncProducer(brokers, config) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | jp := &JobProducer{ 44 | producer: producer, 45 | stopSig: make(chan struct{}), 46 | offsets: make(map[int32]int64), 47 | } 48 | 49 | go func() { 50 | for { 51 | select { 52 | case js, ok := <-jobs: 53 | if !ok { 54 | return 55 | } 56 | for _, j := range js { 57 | producer.Input() <- j.ToKafka() 58 | } 59 | 60 | case <-jp.stopSig: 61 | return 62 | } 63 | } 64 | }() 65 | 66 | // Success handling 67 | jp.wg.Add(1) 68 | go func() { 69 | for { 70 | select { 71 | case msg, ok := <-producer.Successes(): 72 | if !ok { 73 | jp.wg.Done() 74 | return 75 | } 76 | jp.offsetsMutex.Lock() 77 | jp.offsets[msg.Partition] = msg.Offset 78 | jp.offsetsMutex.Unlock() 79 | log.Debugf("Msg send: %v", msg) 80 | } 81 | } 82 | }() 83 | 84 | // Failure handling 85 | jp.wg.Add(1) 86 | go func() { 87 | for { 88 | select { 89 | case err, ok := <-producer.Errors(): 90 | if !ok { 91 | jp.wg.Done() 92 | return 93 | } 94 | log.Errorf("Failed to send message: %v", err) 95 | } 96 | } 97 | }() 98 | 99 | return jp, nil 100 | } 101 | 102 | // Close the job producer 103 | func (jp *JobProducer) Close() { 104 | jp.stopSig <- struct{}{} 105 | jp.producer.AsyncClose() 106 | jp.wg.Wait() 107 | } 108 | 109 | // Indexes return the current write indexes by partition 110 | func (jp *JobProducer) Indexes() map[int32]int64 { 111 | res := make(map[int32]int64) 112 | 113 | jp.offsetsMutex.RLock() 114 | defer jp.offsetsMutex.RUnlock() 115 | 116 | for k, v := range jp.offsets { 117 | res[k] = v 118 | } 119 | 120 | return res 121 | } 122 | -------------------------------------------------------------------------------- /src/scheduler/routines/taskConsumer.go: -------------------------------------------------------------------------------- 1 | package routines 2 | 3 | import ( 4 | "strconv" 5 | "sync" 6 | "time" 7 | 8 | "github.com/Shopify/sarama" 9 | saramaC "github.com/bsm/sarama-cluster" 10 | "github.com/prometheus/client_golang/prometheus" 11 | log "github.com/sirupsen/logrus" 12 | "github.com/spf13/viper" 13 | 14 | "github.com/ovh/metronome/src/metronome/kafka" 15 | "github.com/ovh/metronome/src/metronome/models" 16 | ) 17 | 18 | // Partition handle a topic partition 19 | type Partition struct { 20 | Partition int32 21 | Tasks chan models.Task 22 | } 23 | 24 | // TaskConsumer handle the internal states of the consumer 25 | type TaskConsumer struct { 26 | client *saramaC.Client 27 | consumer *saramaC.Consumer 28 | drained bool 29 | drainWg sync.WaitGroup 30 | // group tasks by partition 31 | partitions map[int32]chan models.Task 32 | partitionsChan chan Partition 33 | hwm map[int32]int64 34 | // metrics 35 | taskCounter *prometheus.CounterVec 36 | taskUnprocessableCounter *prometheus.CounterVec 37 | } 38 | 39 | // NewTaskConsumer return a new task consumer 40 | func NewTaskConsumer() (*TaskConsumer, error) { 41 | brokers := viper.GetStringSlice("kafka.brokers") 42 | 43 | config := saramaC.NewConfig() 44 | config.Config = *kafka.NewConfig() 45 | config.ClientID = "metronome-scheduler" 46 | config.Consumer.Offsets.Initial = sarama.OffsetOldest 47 | config.Group.Return.Notifications = true 48 | 49 | client, err := saramaC.NewClient(brokers, config) 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | consumer, err := saramaC.NewConsumerFromClient(client, kafka.GroupSchedulers(), []string{kafka.TopicTasks()}) 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | tc := &TaskConsumer{ 60 | client: client, 61 | consumer: consumer, 62 | partitions: make(map[int32]chan models.Task), 63 | partitionsChan: make(chan Partition), 64 | } 65 | tc.taskCounter = prometheus.NewCounterVec(prometheus.CounterOpts{ 66 | Namespace: "metronome", 67 | Subsystem: "scheduler", 68 | Name: "tasks", 69 | Help: "Number of tasks processed.", 70 | }, 71 | []string{"partition"}) 72 | prometheus.MustRegister(tc.taskCounter) 73 | tc.taskUnprocessableCounter = prometheus.NewCounterVec(prometheus.CounterOpts{ 74 | Namespace: "metronome", 75 | Subsystem: "scheduler", 76 | Name: "tasks_unprocessable", 77 | Help: "Number of unprocessable tasks.", 78 | }, 79 | []string{"partition"}) 80 | 81 | tc.hwm = <-tc.highWaterMarks() 82 | offsets := make(map[int32]int64) 83 | messages := 0 84 | 85 | // init offsets 86 | for p := range tc.hwm { 87 | offsets[p] = -1 88 | } 89 | 90 | tc.drainWg.Add(1) 91 | 92 | // Progress display 93 | ticker := time.NewTicker(500 * time.Millisecond) 94 | 95 | go func() { 96 | for { 97 | select { 98 | case msg, ok := <-consumer.Messages(): 99 | if !ok { // shuting down 100 | return 101 | } 102 | 103 | // Fix a buggy behaviour of sarama. Sarama could send Messages before Notifications 104 | // aka: received message before knowing our assigned partitions 105 | if tc.partitions[msg.Partition] == nil { 106 | tc.partitions[msg.Partition] = make(chan models.Task) 107 | tc.partitionsChan <- Partition{msg.Partition, tc.partitions[msg.Partition]} 108 | } 109 | 110 | // skip if we have already processed this message 111 | // hapenned at rebalance 112 | if offsets[msg.Partition] < msg.Offset { 113 | messages++ 114 | if err := tc.handleMsg(msg); err != nil { 115 | log.WithError(err).Error("Could not handle the message") 116 | } 117 | offsets[msg.Partition] = msg.Offset 118 | } 119 | 120 | if !tc.drained && tc.isDrained(tc.hwm, offsets) { 121 | ticker.Stop() 122 | tc.drained = true 123 | tc.drainWg.Done() 124 | } 125 | 126 | case notif := <-consumer.Notifications(): 127 | log.Infof("Rebalance - claim %v, release %v", notif.Claimed[kafka.TopicTasks()], notif.Released[kafka.TopicTasks()]) 128 | for _, p := range notif.Released[kafka.TopicTasks()] { 129 | if tc.partitions[p] != nil { 130 | close(tc.partitions[p]) 131 | delete(tc.partitions, p) 132 | offsets[p] = 0 133 | } 134 | } 135 | tc.hwm = <-tc.highWaterMarks() 136 | for _, p := range notif.Claimed[kafka.TopicTasks()] { 137 | if tc.drained { 138 | tc.drained = false 139 | tc.drainWg.Add(1) 140 | } 141 | 142 | if tc.partitions[p] == nil { 143 | tc.partitions[p] = make(chan models.Task) 144 | tc.partitionsChan <- Partition{p, tc.partitions[p]} 145 | } 146 | } 147 | case <-ticker.C: 148 | log.WithField("count", messages).Debug("Loading tasks") 149 | } 150 | } 151 | }() 152 | 153 | return tc, nil 154 | } 155 | 156 | // Partitons return the incomming partition channel 157 | func (tc *TaskConsumer) Partitons() <-chan Partition { 158 | return tc.partitionsChan 159 | } 160 | 161 | // WaitForDrain wait for consumer to EOF partitions 162 | func (tc *TaskConsumer) WaitForDrain() { 163 | tc.drainWg.Wait() 164 | } 165 | 166 | // Close the task consumer 167 | func (tc *TaskConsumer) Close() (err error) { 168 | if e := tc.consumer.Close(); e != nil { 169 | err = e 170 | } 171 | if e := tc.client.Close(); e != nil { 172 | err = e 173 | } 174 | for _, p := range tc.partitions { 175 | close(p) 176 | } 177 | if !tc.drained { 178 | tc.drainWg.Done() 179 | } 180 | return 181 | } 182 | 183 | // Handle incomming messages 184 | func (tc *TaskConsumer) handleMsg(msg *sarama.ConsumerMessage) error { 185 | tc.taskCounter.WithLabelValues(strconv.Itoa(int(msg.Partition))).Inc() 186 | var t models.Task 187 | if err := t.FromKafka(msg); err != nil { 188 | tc.taskUnprocessableCounter.WithLabelValues(strconv.Itoa(int(msg.Partition))).Inc() 189 | return err 190 | } 191 | 192 | body, err := t.ToJSON() 193 | if err != nil { 194 | return err 195 | } 196 | 197 | log.Debugf("Task received: %v partition %v", string(body), msg.Partition) 198 | tc.partitions[msg.Partition] <- t 199 | return nil 200 | } 201 | 202 | // Retrieve highWaterMarks for each partition 203 | func (tc *TaskConsumer) highWaterMarks() chan map[int32]int64 { 204 | resChan := make(chan map[int32]int64) 205 | 206 | go func() { 207 | for { 208 | parts, err := tc.client.Partitions(kafka.TopicTasks()) 209 | if err != nil { 210 | log.Warn("Can't get topic. Retry") 211 | continue 212 | } 213 | 214 | res := make(map[int32]int64) 215 | for p := range parts { 216 | i, err := tc.client.GetOffset(kafka.TopicTasks(), int32(p), sarama.OffsetNewest) 217 | if err != nil { 218 | log.Panic(err) 219 | } 220 | 221 | res[int32(p)] = i 222 | } 223 | 224 | resChan <- res 225 | close(resChan) 226 | break 227 | } 228 | }() 229 | 230 | return resChan 231 | } 232 | 233 | // Check if consumer reach EOF on all the partitions 234 | func (tc *TaskConsumer) isDrained(hwm, offsets map[int32]int64) bool { 235 | subs := tc.consumer.Subscriptions()[kafka.TopicTasks()] 236 | 237 | for partition := range subs { 238 | part := int32(partition) 239 | if _, ok := hwm[part]; !ok { 240 | log.Panicf("Missing HighWaterMarks for partition %v", part) 241 | } 242 | if hwm[part] == 0 { 243 | continue 244 | } 245 | // No message received for partiton 246 | if _, ok := offsets[part]; !ok { 247 | return false 248 | } 249 | // Check offset 250 | if (offsets[part] + 1) < hwm[part] { 251 | return false 252 | } 253 | } 254 | 255 | return true 256 | } 257 | -------------------------------------------------------------------------------- /src/scheduler/routines/taskScheduler.go: -------------------------------------------------------------------------------- 1 | package routines 2 | 3 | import ( 4 | "container/ring" 5 | "encoding/json" 6 | "strconv" 7 | "sync" 8 | "time" 9 | 10 | "github.com/prometheus/client_golang/prometheus" 11 | log "github.com/sirupsen/logrus" 12 | redisV5 "gopkg.in/redis.v5" 13 | 14 | "github.com/ovh/metronome/src/metronome/models" 15 | "github.com/ovh/metronome/src/metronome/redis" 16 | "github.com/ovh/metronome/src/scheduler/core" 17 | ) 18 | 19 | // batch represent a batch of job to send 20 | type batch struct { 21 | at time.Time 22 | jobs map[string][]models.Job 23 | } 24 | 25 | type state struct { 26 | At int64 `json:"at"` 27 | Indexes map[int32]int64 `json:"indexes"` 28 | } 29 | 30 | // TaskScheduler handle the internal states of the scheduler 31 | type TaskScheduler struct { 32 | entries map[string]*core.Entry 33 | nextExec *ring.Ring 34 | plan *ring.Ring 35 | now time.Time 36 | jobs chan []models.Job 37 | halt chan struct{} 38 | planning chan struct{} 39 | dispatch chan struct{} 40 | nextTimer *time.Timer 41 | jobProducer *JobProducer 42 | partition int32 43 | alive sync.WaitGroup 44 | entriesMutex sync.Mutex 45 | // metrics 46 | taskGauge prometheus.Gauge 47 | planCounter prometheus.Counter 48 | } 49 | 50 | // NewTaskScheduler return a new task scheduler 51 | func NewTaskScheduler(partition int32, tasks <-chan models.Task) (*TaskScheduler, error) { 52 | const buffSize = 20 53 | 54 | ts := &TaskScheduler{ 55 | plan: ring.New(buffSize), 56 | entries: make(map[string]*core.Entry), 57 | now: time.Now().UTC(), 58 | jobs: make(chan []models.Job, buffSize), 59 | halt: make(chan struct{}), 60 | planning: make(chan struct{}, 1), 61 | dispatch: make(chan struct{}, 1), 62 | partition: partition, 63 | } 64 | ts.plan.Value = batch{ 65 | ts.now, 66 | make(map[string][]models.Job), 67 | } 68 | ts.nextExec = ts.plan 69 | ts.alive.Add(1) 70 | 71 | // metrics 72 | ts.taskGauge = prometheus.NewGauge(prometheus.GaugeOpts{ 73 | Namespace: "metronome", 74 | Subsystem: "scheduler", 75 | Name: "managed", 76 | Help: "Number of tasks managed.", 77 | ConstLabels: prometheus.Labels{"partition": strconv.Itoa(int(ts.partition))}, 78 | }) 79 | prometheus.MustRegister(ts.taskGauge) 80 | ts.planCounter = prometheus.NewCounter(prometheus.CounterOpts{ 81 | Namespace: "metronome", 82 | Subsystem: "scheduler", 83 | Name: "plan", 84 | Help: "Number of tasks plan.", 85 | ConstLabels: prometheus.Labels{"partition": strconv.Itoa(int(ts.partition))}, 86 | }) 87 | prometheus.MustRegister(ts.planCounter) 88 | 89 | // jobs producer 90 | jobProducer, err := NewJobProducer(ts.jobs) 91 | if err != nil { 92 | return nil, err 93 | } 94 | 95 | ts.jobProducer = jobProducer 96 | go func() { 97 | defer ts.alive.Done() 98 | for { 99 | // dispatch first 100 | select { 101 | case _, ok := <-ts.dispatch: 102 | if ok { 103 | ts.handleDispatch() 104 | continue 105 | } 106 | default: 107 | } 108 | 109 | // planning then 110 | select { 111 | case _, ok := <-ts.planning: 112 | if ok { 113 | ts.handlePlanning() 114 | continue 115 | } 116 | default: 117 | } 118 | 119 | select { 120 | case _, ok := <-ts.dispatch: 121 | if ok { 122 | ts.handleDispatch() 123 | } 124 | case _, ok := <-ts.planning: 125 | if ok { 126 | ts.handlePlanning() 127 | } 128 | case t, ok := <-tasks: 129 | if !ok { 130 | // shutdown 131 | go ts.stop() 132 | return 133 | } 134 | ts.entriesMutex.Lock() 135 | ts.handleTask(t) 136 | ts.entriesMutex.Unlock() 137 | case <-ts.halt: 138 | return 139 | } 140 | } 141 | }() 142 | 143 | return ts, nil 144 | } 145 | 146 | // Start task scheduling 147 | func (ts *TaskScheduler) Start() error { 148 | ts.entriesMutex.Lock() 149 | 150 | defer func() { 151 | ts.entriesMutex.Unlock() 152 | 153 | ts.planning <- struct{}{} 154 | ts.dispatch <- struct{}{} 155 | }() 156 | 157 | val, err := redis.DB().Get(strconv.Itoa(int(ts.partition))).Result() 158 | if err == redisV5.Nil { 159 | // no state save 160 | return nil 161 | } else if err != nil { 162 | return err 163 | } 164 | 165 | var state state 166 | err = json.Unmarshal([]byte(val), &state) 167 | if err != nil { 168 | return err 169 | } 170 | 171 | log.Infof("Scheduler %v restored state %v", ts.partition, state) 172 | 173 | // Re-init from last know scheduler 174 | for _, e := range ts.entries { 175 | e.Init(time.Unix(state.At+1, 0)) 176 | } 177 | 178 | // Look if we have already schedule some jobs 179 | if len(state.Indexes) > 0 { 180 | 181 | jobs := make(chan models.Job) 182 | if err := NewJobComsumer(state.Indexes, jobs); err != nil { 183 | return err 184 | } 185 | 186 | jobsLoader: 187 | for { 188 | select { 189 | case j, ok := <-jobs: 190 | if !ok { 191 | break jobsLoader 192 | } 193 | if ts.entries[j.GUID] == nil { 194 | break 195 | } 196 | ts.entries[j.GUID].Init(time.Unix(j.At+1, 0)) 197 | } 198 | } 199 | } 200 | 201 | // Plan 202 | for guid, e := range ts.entries { 203 | jobs, err := planEntryInBatch(e, ts.nextExec.Value.(batch).at) 204 | if err != nil { 205 | return err 206 | } 207 | ts.nextExec.Value.(batch).jobs[guid] = jobs 208 | } 209 | 210 | return nil 211 | } 212 | 213 | // stop the scheduler 214 | func (ts *TaskScheduler) stop() { 215 | close(ts.dispatch) 216 | close(ts.planning) 217 | if ts.nextTimer != nil { 218 | ts.nextTimer.Stop() 219 | } 220 | select { 221 | case ts.halt <- struct{}{}: 222 | default: 223 | } 224 | 225 | ts.jobProducer.Close() 226 | } 227 | 228 | // Halted wait for scheduler to be halt 229 | func (ts *TaskScheduler) Halted() { 230 | ts.alive.Wait() 231 | } 232 | 233 | // Jobs return the out jobs channel 234 | func (ts *TaskScheduler) Jobs() <-chan []models.Job { 235 | return ts.jobs 236 | } 237 | 238 | // Handle incomming task 239 | func (ts *TaskScheduler) handleTask(t models.Task) error { 240 | if t.Schedule == "" { 241 | if ts.entries[t.GUID] == nil { 242 | return nil 243 | } 244 | 245 | log.Infof("DELETE task: %s", t.GUID) 246 | ts.taskGauge.Dec() 247 | delete(ts.entries, t.GUID) 248 | c := ts.nextExec 249 | for i := 0; i < c.Len(); i++ { 250 | if c.Value != nil { 251 | // Clear schedule execution 252 | c.Value.(batch).jobs[t.GUID] = make([]models.Job, 0) 253 | } 254 | 255 | c = c.Next() 256 | } 257 | return nil 258 | } 259 | 260 | taskUpdate := false 261 | if ts.entries[t.GUID] != nil { 262 | taskUpdate = true 263 | 264 | // Update Task payload 265 | ts.entries[t.GUID].SetPayload(t.Payload) 266 | 267 | if ts.entries[t.GUID].SameAs(t) { 268 | log.Infof("NOP task: %s", t.GUID) 269 | return nil 270 | } 271 | 272 | // Clear schedule execution on task update 273 | c := ts.nextExec 274 | for i := 0; i < c.Len(); i++ { 275 | if c.Value != nil { 276 | c.Value.(batch).jobs[t.GUID] = make([]models.Job, 0) 277 | } 278 | c = c.Next() 279 | } 280 | } 281 | 282 | if !taskUpdate { 283 | log.Infof("NEW task: %s", t.GUID) 284 | ts.taskGauge.Inc() 285 | } else { 286 | log.Infof("UPDATE task: %s", t.GUID) 287 | } 288 | 289 | // Update entries 290 | e, err := core.NewEntry(t) 291 | if err != nil { 292 | log.WithError(err).Errorf("unprocessable task(%+v)", t) 293 | return err 294 | } 295 | ts.entries[t.GUID] = e 296 | 297 | // Plan executions 298 | c := ts.nextExec 299 | e.Init(c.Value.(batch).at) 300 | for i := 0; i < c.Len(); i++ { 301 | if c.Value != nil { 302 | jobs, err := planEntryInBatch(e, c.Value.(batch).at) 303 | if err != nil { 304 | return err 305 | } 306 | 307 | if len(jobs) > 0 { 308 | c.Value.(batch).jobs[t.GUID] = jobs 309 | } 310 | } 311 | 312 | c = c.Next() 313 | } 314 | 315 | return nil 316 | } 317 | 318 | // Dispatch jobs executions 319 | func (ts *TaskScheduler) handleDispatch() { 320 | now := time.Now().UTC().Unix() 321 | at := int64(0) 322 | send := 0 323 | for ts.nextExec.Value != nil && ts.nextExec.Value.(batch).at.Unix() <= now { 324 | at = ts.nextExec.Value.(batch).at.Unix() 325 | var jobs []models.Job 326 | for _, js := range ts.nextExec.Value.(batch).jobs { 327 | send += len(js) 328 | jobs = append(jobs, js...) 329 | } 330 | ts.jobs <- jobs 331 | ts.nextExec.Value = nil 332 | ts.nextExec = ts.nextExec.Next() 333 | } 334 | 335 | if at > 0 { 336 | ts.planCounter.Add(float64(send)) 337 | log.WithFields(log.Fields{ 338 | "at": at, 339 | "partiton": ts.partition, 340 | "indexes": ts.jobProducer.Indexes(), 341 | "do": send, 342 | }).Info("Dispatch") 343 | out, err := json.Marshal(state{at, ts.jobProducer.Indexes()}) 344 | if err != nil { 345 | log.Error(err) 346 | } else { 347 | if err := redis.DB().Set(strconv.Itoa(int(ts.partition)), string(out), 0).Err(); err != nil { 348 | log.Error(err) 349 | } 350 | } 351 | } 352 | 353 | if ts.nextExec.Value == nil { 354 | // NOP wait to be trig 355 | time.AfterFunc(300*time.Millisecond, func() { 356 | ts.dispatch <- struct{}{} 357 | }) 358 | return 359 | } 360 | 361 | nextRun := ts.nextExec.Value.(batch).at.Unix() 362 | ts.nextTimer = time.AfterFunc(time.Duration(nextRun-now)*time.Second, func() { 363 | ts.dispatch <- struct{}{} 364 | }) 365 | 366 | // Trigger planning 367 | select { 368 | case ts.planning <- struct{}{}: 369 | default: 370 | } 371 | } 372 | 373 | // Plan next executions 374 | func (ts *TaskScheduler) handlePlanning() error { 375 | if ts.plan.Next().Value != nil { 376 | return nil 377 | } 378 | 379 | ts.plan = ts.plan.Next() 380 | ts.now = ts.now.Add(1 * time.Second) 381 | if ts.now.Before(time.Now()) { 382 | ts.now = time.Now().UTC() 383 | } 384 | 385 | ts.plan.Value = batch{ 386 | ts.now, 387 | make(map[string][]models.Job), 388 | } 389 | 390 | for k := range ts.entries { 391 | jobs, err := planEntryInBatch(ts.entries[k], ts.now) 392 | if err != nil { 393 | return err 394 | } 395 | 396 | if len(jobs) > 0 { 397 | ts.plan.Value.(batch).jobs[k] = jobs 398 | } 399 | } 400 | 401 | next := ts.plan.Next() 402 | // Plan next batch if available 403 | if next.Value == nil { 404 | select { 405 | case ts.planning <- struct{}{}: 406 | default: 407 | } 408 | } 409 | 410 | return nil 411 | } 412 | 413 | func planEntryInBatch(entry *core.Entry, at time.Time) ([]models.Job, error) { 414 | jobs := make([]models.Job, 0) 415 | _, err := entry.Plan(at) 416 | if err != nil { 417 | return nil, err 418 | } 419 | 420 | for entry.Next() > 0 && entry.Next() <= at.Unix() { 421 | jobs = append(jobs, models.Job{GUID: entry.GUID(), UserID: entry.UserID(), At: entry.Next(), Epsilon: entry.Epsilon(), URN: entry.URN(), Payload: entry.GetPayload()}) 422 | plan, err := entry.Plan(at) 423 | if err != nil { 424 | return nil, err 425 | } 426 | 427 | if !plan { 428 | break 429 | } 430 | } 431 | return jobs, nil 432 | } 433 | -------------------------------------------------------------------------------- /src/scheduler/scheduler.go: -------------------------------------------------------------------------------- 1 | // Scheduler plan tasks executions. 2 | // 3 | // Usage 4 | // 5 | // scheduler [flags] 6 | // Flags: 7 | // --config string config file to use 8 | // --help display help 9 | // -v, --verbose verbose output 10 | package main 11 | 12 | import ( 13 | log "github.com/sirupsen/logrus" 14 | 15 | "github.com/ovh/metronome/src/scheduler/cmd" 16 | ) 17 | 18 | func main() { 19 | if err := cmd.RootCmd.Execute(); err != nil { 20 | log.Panicf("%v", err) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/worker/cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | "os/signal" 6 | 7 | log "github.com/sirupsen/logrus" 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/viper" 10 | 11 | "github.com/ovh/metronome/src/metronome/metrics" 12 | "github.com/ovh/metronome/src/worker/consumers" 13 | ) 14 | 15 | // Scheduler init - define command line arguments 16 | func init() { 17 | cobra.OnInitialize(initConfig) 18 | 19 | RootCmd.PersistentFlags().StringP("config", "", "", "config file to use") 20 | RootCmd.PersistentFlags().BoolP("verbose", "v", false, "verbose output") 21 | 22 | RootCmd.Flags().StringSlice("kafka.brokers", []string{"localhost:9092"}, "kafka brokers address") 23 | RootCmd.Flags().String("metrics.addr", "127.0.0.1:9100", "metrics address") 24 | 25 | if err := viper.BindPFlags(RootCmd.PersistentFlags()); err != nil { 26 | log.WithError(err).Error("Could not bind persistent flags") 27 | } 28 | 29 | if err := viper.BindPFlags(RootCmd.Flags()); err != nil { 30 | log.WithError(err).Error("Could not bind flags") 31 | } 32 | } 33 | 34 | // Load config - initialize defaults and read config 35 | func initConfig() { 36 | if viper.GetBool("verbose") { 37 | log.SetLevel(log.DebugLevel) 38 | } 39 | 40 | // Set defaults 41 | viper.SetDefault("metrics.addr", ":9100") 42 | viper.SetDefault("metrics.path", "/metrics") 43 | viper.SetDefault("redis.pass", "") 44 | viper.SetDefault("kafka.tls", false) 45 | viper.SetDefault("kafka.topics.tasks", "tasks") 46 | viper.SetDefault("kafka.topics.jobs", "jobs") 47 | viper.SetDefault("kafka.topics.states", "states") 48 | viper.SetDefault("kafka.groups.schedulers", "schedulers") 49 | viper.SetDefault("kafka.groups.aggregators", "aggregators") 50 | viper.SetDefault("kafka.groups.workers", "workers") 51 | viper.SetDefault("worker.poolsize", 100) 52 | viper.SetDefault("token.ttl", 3600) 53 | viper.SetDefault("redis.pass", "") 54 | 55 | // Bind environment variables 56 | viper.SetEnvPrefix("mtrwrk") 57 | viper.AutomaticEnv() 58 | 59 | // Set config search path 60 | viper.AddConfigPath("/etc/metronome/") 61 | viper.AddConfigPath("$HOME/.metronome") 62 | viper.AddConfigPath(".") 63 | 64 | // Load default config 65 | viper.SetConfigName("default") 66 | if err := viper.MergeInConfig(); err != nil { 67 | if _, ok := err.(viper.ConfigFileNotFoundError); ok { 68 | log.Debug("No default config file found") 69 | } else { 70 | log.Panicf("Fatal error in default config file: %v \n", err) 71 | } 72 | } 73 | 74 | // Load worker config 75 | viper.SetConfigName("worker") 76 | if err := viper.MergeInConfig(); err != nil { 77 | if _, ok := err.(viper.ConfigFileNotFoundError); ok { 78 | log.Debug("No worker config file found") 79 | } else { 80 | log.Panicf("Fatal error in worker config file: %v \n", err) 81 | } 82 | } 83 | 84 | // Load user defined config 85 | cfgFile := viper.GetString("config") 86 | if cfgFile != "" { 87 | viper.SetConfigFile(cfgFile) 88 | err := viper.ReadInConfig() 89 | if err != nil { 90 | log.Panicf("Fatal error in config file: %v \n", err) 91 | } 92 | } 93 | } 94 | 95 | // RootCmd launch the worker agent. 96 | var RootCmd = &cobra.Command{ 97 | Use: "metronome-worker", 98 | Short: "Metronome worker execute jobs", 99 | Long: `Metronome is a distributed and fault-tolerant event scheduler built with love by ovh teams and friends in Go. 100 | Complete documentation is available at http://ovh.github.io/metronome`, 101 | Run: func(cmd *cobra.Command, args []string) { 102 | log.Info("Metronome Worker starting") 103 | 104 | metrics.Serve() 105 | 106 | jc, err := consumers.NewJobConsumer() 107 | if err != nil { 108 | log.WithError(err).Fatal("Could not start the job consumer") 109 | } 110 | 111 | log.Info("Started") 112 | 113 | // Trap SIGINT to trigger a shutdown. 114 | sigint := make(chan os.Signal, 1) 115 | signal.Notify(sigint, os.Interrupt) 116 | 117 | <-sigint 118 | 119 | log.Info("Shuting down") 120 | if err = jc.Close(); err != nil { 121 | log.WithError(err).Error("Could not close the job consumer") 122 | } 123 | }, 124 | } 125 | -------------------------------------------------------------------------------- /src/worker/cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | var ( 11 | version = "0.0.0" 12 | githash = "HEAD" 13 | date = "1970-01-01T00:00:00Z UTC" 14 | ) 15 | 16 | func init() { 17 | RootCmd.AddCommand(versionCmd) 18 | } 19 | 20 | var versionCmd = &cobra.Command{ 21 | Use: "version", 22 | Short: "Show version", 23 | Run: func(cmd *cobra.Command, arguments []string) { 24 | fmt.Printf("metronome-worker version %s %s\n", version, githash) 25 | fmt.Printf("metronome-worker build date %s\n", date) 26 | fmt.Printf("go version %s %s/%s\n", runtime.Version(), runtime.GOOS, runtime.GOARCH) 27 | }, 28 | } 29 | -------------------------------------------------------------------------------- /src/worker/consumers/jobs.go: -------------------------------------------------------------------------------- 1 | package consumers 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "io/ioutil" 8 | "net" 9 | "net/http" 10 | "net/url" 11 | "strconv" 12 | "sync" 13 | "time" 14 | 15 | "github.com/Shopify/sarama" 16 | saramaC "github.com/bsm/sarama-cluster" 17 | "github.com/prometheus/client_golang/prometheus" 18 | log "github.com/sirupsen/logrus" 19 | "github.com/spf13/viper" 20 | 21 | "github.com/ovh/metronome/src/metronome/kafka" 22 | "github.com/ovh/metronome/src/metronome/models" 23 | ) 24 | 25 | // JobConsumer consumed jobs messages from a Kafka topic and send them as HTTP POST request. 26 | type JobConsumer struct { 27 | consumer *saramaC.Consumer 28 | producer sarama.SyncProducer 29 | wg *sync.WaitGroup // Used to sync shut down 30 | // metrics 31 | jobCounter *prometheus.CounterVec 32 | jobTime *prometheus.HistogramVec 33 | jobSuccessCounter *prometheus.CounterVec 34 | jobFailureCounter *prometheus.CounterVec 35 | jobExpireCounter *prometheus.CounterVec 36 | httpClient *http.Client 37 | } 38 | 39 | // NewJobConsumer returns a new job consumer. 40 | func NewJobConsumer() (*JobConsumer, error) { 41 | brokers := viper.GetStringSlice("kafka.brokers") 42 | 43 | config := saramaC.NewConfig() 44 | config.Config = *kafka.NewConfig() 45 | config.ClientID = "metronome-worker" 46 | config.Producer.RequiredAcks = sarama.WaitForAll 47 | config.Producer.Timeout = 1 * time.Second 48 | config.Producer.Compression = sarama.CompressionSnappy 49 | config.Producer.Flush.Frequency = 500 * time.Millisecond 50 | config.Producer.Partitioner = sarama.NewHashPartitioner 51 | config.Producer.Return.Successes = true 52 | config.Producer.Retry.Max = 3 53 | 54 | consumer, err := saramaC.NewConsumer(brokers, kafka.GroupWorkers(), []string{kafka.TopicJobs()}, config) 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | producer, err := sarama.NewSyncProducer(brokers, &config.Config) 60 | if err != nil { 61 | return nil, err 62 | } 63 | 64 | jc := &JobConsumer{ 65 | consumer: consumer, 66 | producer: producer, 67 | } 68 | 69 | jc.httpClient = &http.Client{ 70 | Transport: &http.Transport{ 71 | Proxy: http.ProxyFromEnvironment, 72 | Dial: (&net.Dialer{ 73 | Timeout: 300 * time.Millisecond, 74 | KeepAlive: 1 * time.Minute, 75 | }).Dial, 76 | TLSHandshakeTimeout: 10 * time.Second, 77 | DisableKeepAlives: false, 78 | MaxIdleConnsPerHost: 1024, 79 | }, 80 | } 81 | 82 | // worker 83 | jc.wg = new(sync.WaitGroup) 84 | 85 | // metrics 86 | jc.jobCounter = prometheus.NewCounterVec(prometheus.CounterOpts{ 87 | Namespace: "metronome", 88 | Subsystem: "worker", 89 | Name: "jobs", 90 | Help: "Number of jobs processed.", 91 | }, 92 | []string{"partition"}) 93 | prometheus.MustRegister(jc.jobCounter) 94 | jc.jobTime = prometheus.NewHistogramVec(prometheus.HistogramOpts{ 95 | Namespace: "metronome", 96 | Subsystem: "worker", 97 | Name: "job_time", 98 | Help: "Job processing time.", 99 | }, 100 | []string{"partition"}) 101 | prometheus.MustRegister(jc.jobTime) 102 | jc.jobSuccessCounter = prometheus.NewCounterVec(prometheus.CounterOpts{ 103 | Namespace: "metronome", 104 | Subsystem: "worker", 105 | Name: "jobs_success", 106 | Help: "Number of jobs success.", 107 | }, 108 | []string{"partition"}) 109 | prometheus.MustRegister(jc.jobSuccessCounter) 110 | jc.jobFailureCounter = prometheus.NewCounterVec(prometheus.CounterOpts{ 111 | Namespace: "metronome", 112 | Subsystem: "worker", 113 | Name: "jobs_failure", 114 | Help: "Number of jobs failure.", 115 | }, 116 | []string{"partition"}) 117 | prometheus.MustRegister(jc.jobFailureCounter) 118 | jc.jobExpireCounter = prometheus.NewCounterVec(prometheus.CounterOpts{ 119 | Namespace: "metronome", 120 | Subsystem: "worker", 121 | Name: "jobs_expire", 122 | Help: "Number of expired jobs.", 123 | }, 124 | []string{"partition"}) 125 | prometheus.MustRegister(jc.jobExpireCounter) 126 | 127 | // Spawning workers 128 | poolSize := viper.GetInt("worker.poolsize") 129 | 130 | log.Printf("Spawning %d goroutines...", poolSize) 131 | for i := 0; i < poolSize; i++ { 132 | jc.wg.Add(1) 133 | go jc.Worker(i + 1) 134 | } 135 | return jc, nil 136 | } 137 | 138 | // Close the consumer. 139 | func (jc *JobConsumer) Close() error { 140 | err := jc.consumer.Close() 141 | jc.wg.Wait() // wait for all workers to shut down properly 142 | return err 143 | } 144 | 145 | // Worker is the main goroutine that is calling handleMsg 146 | func (jc *JobConsumer) Worker(id int) { 147 | defer jc.wg.Done() 148 | for { 149 | select { 150 | case msg, ok := <-jc.consumer.Messages(): 151 | if !ok { // shutting down 152 | log.Printf("Closing worker %d", id) 153 | return 154 | } 155 | if err := jc.handleMsg(msg); err != nil { 156 | log. 157 | WithError(err). 158 | WithFields(log.Fields{"id": id}). 159 | Error("Could not handle the message") 160 | } 161 | } 162 | } 163 | } 164 | 165 | // Handle message from Kafka. 166 | // Forward them as http POST. 167 | func (jc *JobConsumer) handleMsg(msg *sarama.ConsumerMessage) error { 168 | jc.jobCounter.WithLabelValues(strconv.Itoa(int(msg.Partition))).Inc() 169 | var j models.Job 170 | if err := j.FromKafka(msg); err != nil { 171 | return err 172 | } 173 | 174 | start := time.Now() 175 | 176 | log.WithFields(log.Fields{ 177 | "time": j.At, 178 | "epsilon": j.Epsilon, 179 | "urn": j.URN, 180 | "at": start, 181 | }).Debug("POST") 182 | 183 | s := models.State{ 184 | ID: "", 185 | TaskGUID: j.GUID, 186 | UserID: j.UserID, 187 | At: j.At, 188 | DoneAt: start.Unix(), 189 | Duration: time.Since(start).Nanoseconds() / 1000, 190 | URN: j.URN, 191 | State: models.Success, 192 | } 193 | 194 | if j.At < start.Unix()-j.Epsilon { 195 | s.State = models.Expired 196 | } else { 197 | url, err := url.Parse(s.URN) 198 | if err != nil { 199 | s.State = models.Failed 200 | } else { 201 | q := url.Query() 202 | q.Set("time", strconv.FormatInt(j.At, 10)) 203 | q.Set("epsilon", strconv.FormatInt(j.Epsilon, 10)) 204 | q.Set("at", strconv.FormatInt(time.Now().Unix(), 10)) 205 | url.RawQuery = q.Encode() 206 | 207 | body, err := json.Marshal(j.Payload) 208 | if err != nil { 209 | log.WithError(err).Warn("Cannot marshall payload") 210 | s.State = models.Failed 211 | } else { 212 | res, err := jc.httpClient.Post(url.String(), "application/json", bytes.NewReader(body)) 213 | if err != nil { 214 | log.WithError(err).Warn("Could not post form") 215 | } else { 216 | if _, err = io.Copy(ioutil.Discard, res.Body); err != nil { 217 | log.WithError(err).Warn("Failed to discard response body") 218 | } 219 | if err = res.Body.Close(); err != nil { 220 | log.WithError(err).Warn("Could not close the response body") 221 | } 222 | } 223 | 224 | if err != nil || res.StatusCode < 200 || res.StatusCode >= 300 { 225 | s.State = models.Failed 226 | } 227 | } 228 | } 229 | } 230 | 231 | jc.jobTime.WithLabelValues(strconv.Itoa(int(msg.Partition))).Observe(time.Since(start).Seconds()) // to seconds 232 | 233 | switch s.State { 234 | case models.Success: 235 | jc.jobSuccessCounter.WithLabelValues(strconv.Itoa(int(msg.Partition))).Inc() 236 | case models.Failed: 237 | jc.jobFailureCounter.WithLabelValues(strconv.Itoa(int(msg.Partition))).Inc() 238 | case models.Expired: 239 | jc.jobExpireCounter.WithLabelValues(strconv.Itoa(int(msg.Partition))).Inc() 240 | } 241 | 242 | if _, _, err := jc.producer.SendMessage(s.ToKafka()); err != nil { 243 | log.Errorf("FAILED to send message: %s\n", err) 244 | return err 245 | } 246 | return nil 247 | } 248 | -------------------------------------------------------------------------------- /src/worker/worker.go: -------------------------------------------------------------------------------- 1 | // Worker perform jobs. 2 | // 3 | // You can launch as much Worker agent as you want/need as they rely on Kafka partitons to share the workload. 4 | // 5 | // Usage 6 | // 7 | // worker [flags] 8 | // Flags: 9 | // --config string config file to use 10 | // --help display help 11 | // -v, --verbose verbose output 12 | package main 13 | 14 | import ( 15 | log "github.com/sirupsen/logrus" 16 | 17 | "github.com/ovh/metronome/src/worker/cmd" 18 | ) 19 | 20 | func main() { 21 | if err := cmd.RootCmd.Execute(); err != nil { 22 | log.WithError(err).Error("Could not execute the worker") 23 | } 24 | } 25 | --------------------------------------------------------------------------------