From be6ff2e93306a912a7d79c42b8f18cba280a1c72 Mon Sep 17 00:00:00 2001 From: Aleksandr Baryshnikov Date: Mon, 23 Jan 2023 18:43:29 +0800 Subject: [PATCH] initial commit --- .dockerignore | 3 + .github/workflows/release.yaml | 46 ++++++ .gitignore | 3 + .goreleaser.yaml | 41 +++++ Dockerfile | 7 + Dockerfile.build | 15 ++ LICENSE | 21 +++ README.md | 92 +++++++++++ cmd/scheduler/main.go | 59 +++++++ docker-compose.yaml | 18 +++ go.mod | 23 +++ go.sum | 72 +++++++++ notification.go | 87 +++++++++++ options.go | 24 +++ scheduler.go | 270 +++++++++++++++++++++++++++++++++ 15 files changed, 781 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/release.yaml create mode 100644 .gitignore create mode 100644 .goreleaser.yaml create mode 100644 Dockerfile create mode 100644 Dockerfile.build create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cmd/scheduler/main.go create mode 100644 docker-compose.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 notification.go create mode 100644 options.go create mode 100644 scheduler.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..de46d25 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.idea +.DS_Store +/dist diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..85775a5 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,46 @@ +name: Build and release +on: + push: + tags: + - 'v*' + +env: + REGISTRY: ghcr.io + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Log in to the Container registry + uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up Go + uses: actions/setup-go@v2 + with: + go-version: '~1.19' + id: go + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v1 + - name: Check out code into the Go module directory + uses: actions/checkout@v2 + with: + lfs: true + fetch-depth: 0 + - name: Checkout LFS objects + run: git lfs checkout + - name: Pull tag + run: git fetch --tags + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v2 + with: + version: latest + args: release --rm-dist + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc527a8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.idea +.DS_Store +/dist \ No newline at end of file diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..f9fbb2b --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,41 @@ +builds: + - env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + goarch: + - arm64 + - amd64 + main: ./cmd/scheduler/main.go + binary: scheduler +checksum: + name_template: 'checksums.txt' +snapshot: + name_template: "{{ incpatch .Version }}-next" +dockers: + - image_templates: + - "ghcr.io/reddec/{{ .ProjectName }}:{{ .Version }}-amd64" + use: buildx + dockerfile: Dockerfile + build_flag_templates: + - "--platform=linux/amd64" + - image_templates: + - "ghcr.io/reddec/{{ .ProjectName }}:{{ .Version }}-arm64v8" + use: buildx + goarch: arm64 + dockerfile: Dockerfile + build_flag_templates: + - "--platform=linux/arm64/v8" +docker_manifests: + - name_template: "ghcr.io/reddec/{{ .ProjectName }}:{{ .Version }}" + image_templates: + - "ghcr.io/reddec/{{ .ProjectName }}:{{ .Version }}-amd64" + - "ghcr.io/reddec/{{ .ProjectName }}:{{ .Version }}-arm64v8" + +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2fbad07 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM alpine:3.17 AS certs +RUN apk add --no-cache ca-certificates && update-ca-certificates + +FROM scratch +ENTRYPOINT ["/scheduler"] +COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +ADD scheduler / \ No newline at end of file diff --git a/Dockerfile.build b/Dockerfile.build new file mode 100644 index 0000000..e01be1b --- /dev/null +++ b/Dockerfile.build @@ -0,0 +1,15 @@ +FROM golang:1.19 AS build +WORKDIR /usr/src/app +COPY go.mod go.sum ./ +RUN go mod download && go mod verify + +COPY . . +RUN CGO_ENABLED=0 go build -v -o /usr/local/bin/app ./cmd/... + +FROM alpine:3.17 AS certs +RUN apk add --no-cache ca-certificates && update-ca-certificates + +FROM scratch +ENTRYPOINT ["/app"] +COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +COPY --from=build /usr/local/bin/app /app diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e0232ca --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 reddec + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..fb31500 --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +# Docker-Compose scheduler + +Simple and lightweight service which can execute `docker compose run ...` services from the same file based on cron +expression. + +Features: + +- Zero-configuration by-default +- Designed for docker compose (auto-detect, respects namespace) +- HTTP notifications with retries + +Inspired by [ofelia](https://github.com/mcuadros/ofelia). + +```yaml +services: + web: + image: "nginx" + labels: + - "net.reddec.scheduler.cron=@daily" + - "net.reddec.scheduler.exec=nginx -s reload" + + date: + image: busybox + restart: "no" + labels: + - "net.reddec.scheduler.cron=* * * * *" + + scheduler: + image: ghcr.io/reddec/compose-scheduler:1 + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro +``` + +Supports two modes: + +- plain `docker compose run` +- exec command inside service (extra label `net.reddec.scheduler.exec`) + +## Usage + +``` +Application Options: + --project= Docker compose project, will be automatically detected if not set [$PROJECT] + +HTTP notification: + --notify.url= URL to invoke [$NOTIFY_URL] + --notify.retries= Number of additional retries (default: 5) [$NOTIFY_RETRIES] + --notify.interval= Interval between attempts (default: 12s) [$NOTIFY_INTERVAL] + --notify.method= HTTP method (default: POST) [$NOTIFY_METHOD] + --notify.timeout= Request timeout (default: 30s) [$NOTIFY_TIMEOUT] + --notify.authorization= Authorization header value [$NOTIFY_AUTHORIZATION] + +Help Options: + -h, --help Show this help message +``` + +## Notifications + +Scheduler will send notifications after each job if `NOTIFY_URL` env variable or `--notify.url` flag set. Each +notification is a simple HTTP request. +HTTP method, attempts number, and interval between attempts can be configured. +Authorization via `Authorization` header also supported. + +Scheduler will stop retries if at least one of the following criteria met: + +- reached maximum number of attempts +- server returned any `2xx` code (ex: `200`, `201`, ...) + +Outgoing custom headers: + +- `Content-Type: application/json` +- `User-Agent: scheduler/`, where `` is build version +- `Authorization: ` (if set) + +Payload: + +```json +{ + "project": "compose-project", + "service": "web", + "container": "deadbeaf1234", + "schedule": "@daily", + "started": "2023-01-20T11:10:39.44006+08:00", + "finished": "2023-01-20T11:10:39.751879+08:00", + "failed": true, + "error": "exit code 1" +} +``` + +> field `error` exists only if `failed == true` + diff --git a/cmd/scheduler/main.go b/cmd/scheduler/main.go new file mode 100644 index 0000000..1dabeca --- /dev/null +++ b/cmd/scheduler/main.go @@ -0,0 +1,59 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "os/signal" + + "github.com/jessevdk/go-flags" + scheduler "github.com/reddec/compose-scheduler" +) + +//nolint:gochecknoglobals +var ( + version = "dev" + commit = "none" + date = "unknown" + builtBy = "unknown" +) + +type Config struct { + Project string `long:"project" env:"PROJECT" description:"Docker compose project, will be automatically detected if not set"` + Notify scheduler.HTTPNotification `group:"HTTP notification" namespace:"notify" env-namespace:"NOTIFY"` +} + +func main() { + var config Config + config.Notify.UserAgent = "scheduler/" + version + parser := flags.NewParser(&config, flags.Default) + parser.ShortDescription = "Compose scheduler" + parser.LongDescription = fmt.Sprintf("Docker compose scheduler\nscheduler %s, commit %s, built at %s by %s\nAuthor: Aleksandr Baryshnikov ", version, commit, date, builtBy) + + if _, err := parser.Parse(); err != nil { + os.Exit(1) + } + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill) + defer cancel() + + var opts []scheduler.Option + if config.Project != "" { + opts = append(opts, scheduler.WithProject(config.Project)) + } + if config.Notify.URL != "" { + opts = append(opts, scheduler.WithNotification(&config.Notify)) + } + sc, err := scheduler.Create(ctx, opts...) + if err != nil { + log.Panic(err) + } + defer sc.Close() + log.Println("started") + err = sc.Run(ctx) + if err != nil { + log.Panic(err) + } + log.Println("finished") +} diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..924063f --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,18 @@ +# example docker compose +services: + app: # any stateful application + image: "nginx" + labels: + - "net.reddec.scheduler.cron=* * * * *" + - "net.reddec.scheduler.exec=nginx -s reload" + date: + image: busybox + restart: "no" + labels: + - "net.reddec.scheduler.cron=* * * * *" + scheduler: + build: + dockerfile: Dockerfile.build + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..257763d --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module github.com/reddec/compose-scheduler + +go 1.19 + +require ( + github.com/Microsoft/go-winio v0.6.0 // indirect + github.com/docker/distribution v2.8.1+incompatible // indirect + github.com/docker/docker v20.10.23+incompatible // indirect + github.com/docker/go-connections v0.4.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/jessevdk/go-flags v1.5.0 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.0.2 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/sirupsen/logrus v1.9.0 // indirect + golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect + golang.org/x/net v0.0.0-20220722155237-a158d28d115b // indirect + golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect + golang.org/x/tools v0.1.12 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..486f4b8 --- /dev/null +++ b/go.sum @@ -0,0 +1,72 @@ +github.com/Microsoft/go-winio v0.6.0 h1:slsWYD/zyx7lCXoZVlvQrj0hPTM1HI4+v1sIda2yDvg= +github.com/Microsoft/go-winio v0.6.0/go.mod h1:cTAf44im0RAYeL23bpB+fzCyDH2MJiz2BO69KH/soAE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/docker/distribution v2.8.1+incompatible h1:Q50tZOPR6T/hjNsyc9g8/syEs6bk8XXApsHjKukMl68= +github.com/docker/distribution v2.8.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker v20.10.23+incompatible h1:1ZQUUYAdh+oylOT85aA2ZcfRp22jmLhoaEcVEfK8dyA= +github.com/docker/docker v20.10.23+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= +github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc= +github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM= +github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= +github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b h1:PxfKdU9lEEDYjdIzOtC4qFWgkU2rGHdKlKowJSMN9h0= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/notification.go b/notification.go new file mode 100644 index 0000000..873fb15 --- /dev/null +++ b/notification.go @@ -0,0 +1,87 @@ +package scheduler + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "time" +) + +type Payload struct { + Project string `json:"project"` + Service string `json:"service"` + Container string `json:"container"` + Schedule string `json:"schedule"` + Started time.Time `json:"started"` + Finished time.Time `json:"finished"` + Failed bool `json:"failed"` + Error string `json:"error,omitempty"` +} + +type HTTPNotification struct { + URL string `long:"url" env:"URL" description:"URL to invoke"` + Retries int `long:"retries" env:"RETRIES" description:"Number of additional retries" default:"5"` + Interval time.Duration `long:"interval" env:"INTERVAL" description:"Interval between attempts" default:"12s"` + Method string `long:"method" env:"METHOD" description:"HTTP method" default:"POST"` + Timeout time.Duration `long:"timeout" env:"TIMEOUT" description:"Request timeout" default:"30s"` + Authorization string `long:"authorization" env:"AUTHORIZATION" description:"Authorization header value"` + UserAgent string +} + +func (ht *HTTPNotification) Notify(ctx context.Context, record *Payload) error { + left := ht.Retries + for { + err := ht.notify(record) + if err == nil { + log.Println("HTTP notification delivered") + return nil + } + + if left <= 0 { + break + } + log.Println(left, "attempts left;", "notification failed:", err) + + left-- + select { + case <-time.After(ht.Interval): + case <-ctx.Done(): + return ctx.Err() + } + } + return fmt.Errorf("all attempts failed") +} + +func (ht *HTTPNotification) notify(message *Payload) error { + ctx, cancel := context.WithTimeout(context.Background(), ht.Timeout) + defer cancel() + + data, err := json.Marshal(message) + if err != nil { + return fmt.Errorf("marshal: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, ht.Method, ht.URL, bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + if ht.Authorization != "" { + req.Header.Set("Authorization", ht.Authorization) + } + if ht.UserAgent != "" { + req.Header.Set("User-Agent", ht.UserAgent) + } + res, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("execute request: %w", err) + } + defer res.Body.Close() + if res.StatusCode/100 != 2 { + return fmt.Errorf("status: %d", res.StatusCode) + } + return nil +} diff --git a/options.go b/options.go new file mode 100644 index 0000000..253adf3 --- /dev/null +++ b/options.go @@ -0,0 +1,24 @@ +package scheduler + +import "github.com/docker/docker/client" + +type Option func(scheduler *Scheduler) + +func WithDocker(dockerClient *client.Client) Option { + return func(scheduler *Scheduler) { + scheduler.client = dockerClient + scheduler.borrowed = true + } +} + +func WithProject(composeProject string) Option { + return func(scheduler *Scheduler) { + scheduler.project = composeProject + } +} + +func WithNotification(notification *HTTPNotification) Option { + return func(scheduler *Scheduler) { + scheduler.notification = notification + } +} diff --git a/scheduler.go b/scheduler.go new file mode 100644 index 0000000..80edb3e --- /dev/null +++ b/scheduler.go @@ -0,0 +1,270 @@ +package scheduler + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "regexp" + "strings" + "sync/atomic" + "time" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/client" + "github.com/kballard/go-shellquote" + "github.com/robfig/cron/v3" +) + +const ( + composeProjectLabel = "com.docker.compose.project" + composeServiceLabel = "com.docker.compose.service" + schedulerLabel = "net.reddec.scheduler.cron" + commandLabel = "net.reddec.scheduler.exec" +) + +func Create(ctx context.Context, options ...Option) (*Scheduler, error) { + sc := &Scheduler{} + for _, opt := range options { + opt(sc) + } + + if sc.client == nil { + dockerClient, err := client.NewClientWithOpts(client.FromEnv) + if err != nil { + return nil, fmt.Errorf("create docker client: %w", err) + } + sc.client = dockerClient + sc.borrowed = false + } + + if sc.project == "" { + project, err := getComposeProject(ctx, sc.client) + if err != nil { + _ = sc.Close() + return nil, fmt.Errorf("get compose project: %w", err) + } + sc.project = project + } + return sc, nil +} + +type Task struct { + Service string + Container string + Schedule string + Command []string +} + +type Scheduler struct { + project string + client *client.Client + borrowed bool + notification *HTTPNotification +} + +func (sc *Scheduler) Close() error { + if sc.borrowed { + return nil + } + return sc.client.Close() +} +func (sc *Scheduler) Run(ctx context.Context) error { + tasks, err := sc.listTasks(ctx) + if err != nil { + return fmt.Errorf("list tasks: %w", err) + } + + engine := cron.New() + + for _, t := range tasks { + log.Println("task for service", t.Service, "at", t.Schedule) + running := new(int32) + t := t + _, err = engine.AddFunc(t.Schedule, func() { + sc.runJob(ctx, running, t) + }) + if err != nil { + return fmt.Errorf("add service %s: %w", t.Service, err) + } + } + + engine.Start() + <-ctx.Done() + <-engine.Stop().Done() + + return nil +} + +func (sc *Scheduler) runJob(ctx context.Context, running *int32, t Task) { + started := time.Now() + err := sc.runTask(ctx, running, t) + end := time.Now() + var errMessage string + if err != nil { + errMessage = err.Error() + log.Println("service", t.Service, "failed after", end.Sub(started), "with error:", err) + } else { + log.Println("service", t.Service, "finished after", end.Sub(started), "without error") + } + if sc.notification == nil { + return + } + err = sc.notification.Notify(ctx, &Payload{ + Project: sc.project, + Service: t.Service, + Container: t.Container, + Schedule: t.Schedule, + Started: started, + Finished: end, + Failed: err != nil, + Error: errMessage, + }) + if err != nil { + log.Println("notification for service", t.Service, "failed:", err) + } else { + log.Println("notification for service", t.Service, "succeeded") + } +} + +func (sc *Scheduler) runTask(ctx context.Context, running *int32, task Task) error { + if !atomic.CompareAndSwapInt32(running, 0, 1) { + return fmt.Errorf("task is running") + } + defer atomic.StoreInt32(running, 0) + + if len(task.Command) == 0 { + log.Println("running service", task.Service) + return sc.runService(ctx, task) + } + log.Println("executing service", task.Service, "with command", task.Command) + return sc.execService(ctx, task) +} + +func (sc *Scheduler) execService(ctx context.Context, task Task) error { + execID, err := sc.client.ContainerExecCreate(ctx, task.Container, types.ExecConfig{ + Cmd: task.Command, + }) + if err != nil { + return fmt.Errorf("create exec for %s: %w", task.Service, err) + } + + err = sc.client.ContainerExecStart(ctx, execID.ID, types.ExecStartCheck{}) + if err != nil { + return fmt.Errorf("exec for %s: %w", task.Service, err) + } + return nil +} + +func (sc *Scheduler) runService(ctx context.Context, task Task) error { + err := sc.client.ContainerStart(ctx, task.Container, types.ContainerStartOptions{}) + if err != nil { + return fmt.Errorf("start service %s: %w", task.Service, err) + } + ok, failed := sc.client.ContainerWait(ctx, task.Container, container.WaitConditionNotRunning) + select { + case res := <-ok: + if res.Error != nil { + return fmt.Errorf("service %s: %s", task.Service, res.Error.Message) + } + if res.StatusCode != 0 { + return fmt.Errorf("sevice %s: status code %d", task.Service, res.StatusCode) + } + case err = <-failed: + return fmt.Errorf("wait for service %s: %w", task.Service, err) + } + return nil +} + +func (sc *Scheduler) listTasks(ctx context.Context) ([]Task, error) { + list, err := sc.client.ContainerList(ctx, types.ContainerListOptions{ + Filters: filters.NewArgs( + filters.Arg("label", composeProjectLabel+"="+sc.project), + filters.Arg("label", composeServiceLabel), + filters.Arg("label", schedulerLabel), + ), + All: true, + }) + if err != nil { + return nil, fmt.Errorf("list container: %w", err) + } + var ans = make([]Task, 0, len(list)) + for _, c := range list { + service := c.Labels[composeServiceLabel] + var args []string + if v := c.Labels[commandLabel]; v != "" { + cmd, err := shellquote.Split(v) + if err != nil { + return nil, fmt.Errorf("parse command in service %s: %w", service, err) + } + args = cmd + } + ans = append(ans, Task{ + Container: c.ID, + Schedule: c.Labels[schedulerLabel], + Service: service, + Command: args, + }) + } + + return ans, nil +} + +func containerID() (string, error) { + const path = `/proc/1/cpuset` + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("detect container ID: %w", err) + } + id := filepath.Base(strings.TrimSpace(string(data))) + if id == "/" { + return "", fmt.Errorf("calculate container ID from %s: %w", string(data), err) + } + + return id, nil +} + +func getComposeProject(ctx context.Context, dockerClient *client.Client) (string, error) { + var cID string + for _, lookup := range containerIDLookup { + v, err := lookup() + if err == nil { + cID = v + break + } + } + + if cID == "" { + return "", fmt.Errorf("failed detect self container ID") + } + + info, err := dockerClient.ContainerInspect(ctx, cID) + if err != nil { + return "", fmt.Errorf("inspect self container: %w", err) + } + project, ok := info.Config.Labels[composeProjectLabel] + if !ok { + return "", fmt.Errorf("compose label not found - probably container is not part of compose") + } + return project, nil +} + +func containerIDv2() (string, error) { + content, err := os.ReadFile("/proc/self/mountinfo") + if err != nil { + return "", err + } + idRegex := regexp.MustCompile(`containers/([[:alnum:]]{64})/`) + matches := idRegex.FindStringSubmatch(string(content)) + if len(matches) == 0 { + return "", fmt.Errorf("no container id") + } + return matches[len(matches)-1], nil +} + +var containerIDLookup = []func() (string, error){ + containerID, containerIDv2, +}