Initial commit
This commit is contained in:
commit
4d7c983600
13 changed files with 723 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
bin
|
21
LICENSE
Normal file
21
LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2022 Alexey Vanin
|
||||
|
||||
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.
|
7
Makefile
Normal file
7
Makefile
Normal file
|
@ -0,0 +1,7 @@
|
|||
VERSION ?= $(shell git describe --tags --always 2>/dev/null)
|
||||
|
||||
build:
|
||||
go build -ldflags "-X main.Version=$(VERSION)" -o ./bin/nezabx
|
||||
|
||||
clean:
|
||||
rm -rf ./bin
|
35
README.md
Normal file
35
README.md
Normal file
|
@ -0,0 +1,35 @@
|
|||
# Nezabx
|
||||
|
||||
## Overview
|
||||
|
||||
Nezabx (njɛ-za-bɪks) - simple periodic job scheduler with notifications.
|
||||
|
||||
Supported notification channels:
|
||||
- email
|
||||
|
||||
Supported jobs:
|
||||
- arbitrary shell commands
|
||||
|
||||
## Build
|
||||
|
||||
```
|
||||
$ make
|
||||
go build -ldflags "-X main.Version=v0.1.0" -o ./bin/nezabx
|
||||
```
|
||||
|
||||
## Config
|
||||
|
||||
See configuration example with comments in [example.yaml](/example.yaml)
|
||||
|
||||
## Run
|
||||
|
||||
```
|
||||
$ ./bin/nezabx -c config.yaml
|
||||
2022-05-28T22:47:10.979+0300 info application started
|
||||
2022-05-28T22:48:00.049+0300 info script run ok {"cmd": "./healthcheck.sh arg", "next iteration at": "2022-05-28T22:49:00.000+0300"}
|
||||
^C2022-05-28T22:48:05.354+0300 info application received termination signal
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Source code is available under the [MIT License](/LICENSE).
|
5
TODO.md
Normal file
5
TODO.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
- [ ] Check config relations between runner and notification groups on start
|
||||
- [ ] Add option to send "incident resolved" notifications
|
||||
- [ ] Add Matrix notificator
|
||||
- [ ] Add Telegram notificator
|
||||
- [ ] Unit tests for runners, mock interfaces
|
68
config.go
Normal file
68
config.go
Normal file
|
@ -0,0 +1,68 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type (
|
||||
Config struct {
|
||||
State StateConfig `yaml:"state"`
|
||||
Notifications NotificationsConfig `yaml:"notifications"`
|
||||
Commands []CommandConfig `yaml:"commands"`
|
||||
}
|
||||
|
||||
StateConfig struct {
|
||||
Bolt string `yaml:"bolt"`
|
||||
}
|
||||
|
||||
NotificationsConfig struct {
|
||||
Email *EmailConfig `yaml:"email"`
|
||||
}
|
||||
|
||||
EmailConfig struct {
|
||||
SMTP string `yaml:"smtp"`
|
||||
Login string `yaml:"login"`
|
||||
Password string `yaml:"password"`
|
||||
Groups []EmailGroupConfig `yaml:"groups"`
|
||||
}
|
||||
|
||||
EmailGroupConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
Addresses []string `yaml:"addresses"`
|
||||
}
|
||||
|
||||
CommandConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
Exec string `yaml:"exec"`
|
||||
Threshold uint `yaml:"threshold"`
|
||||
ThresholdSleep time.Duration `yaml:"threshold_sleep"`
|
||||
Cron string `yaml:"cron"`
|
||||
Interval time.Duration `yaml:"interval"`
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
Notifications []string `yaml:"notifications"`
|
||||
}
|
||||
)
|
||||
|
||||
var Version = "dev"
|
||||
|
||||
func ReadConfig(file string) (*Config, error) {
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("config: %w", err)
|
||||
}
|
||||
c := new(Config)
|
||||
err = yaml.Unmarshal(data, c)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("config: %w", err)
|
||||
}
|
||||
return c, validateConfig(c)
|
||||
}
|
||||
|
||||
func validateConfig(_ *Config) error {
|
||||
// todo(alexvanin): set defaults and validate values such as bad email addresses
|
||||
return nil
|
||||
}
|
64
db/bolt.go
Normal file
64
db/bolt.go
Normal file
|
@ -0,0 +1,64 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
|
||||
"go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
type (
|
||||
Bolt struct {
|
||||
db *bbolt.DB
|
||||
}
|
||||
|
||||
Status struct {
|
||||
ID []byte
|
||||
Failed bool
|
||||
Notified bool
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
statusBucket = []byte("status")
|
||||
)
|
||||
|
||||
func NewBolt(filename string) (*Bolt, error) {
|
||||
dbPath := path.Join(filename)
|
||||
|
||||
db, err := bbolt.Open(dbPath, 0600, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bolot init: %w", err)
|
||||
}
|
||||
|
||||
return &Bolt{db}, nil
|
||||
}
|
||||
|
||||
func (b *Bolt) Status(id []byte) (st Status, err error) {
|
||||
return st, b.db.Update(func(tx *bbolt.Tx) error {
|
||||
bkt, err := tx.CreateBucketIfNotExists(statusBucket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v := bkt.Get(id)
|
||||
if len(v) == 0 {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(v, &st)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *Bolt) SetStatus(id []byte, st Status) error {
|
||||
return b.db.Update(func(tx *bbolt.Tx) error {
|
||||
bkt, err := tx.CreateBucketIfNotExists(statusBucket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v, err := json.Marshal(st)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return bkt.Put(id, v)
|
||||
})
|
||||
}
|
25
example.yaml
Normal file
25
example.yaml
Normal file
|
@ -0,0 +1,25 @@
|
|||
---
|
||||
|
||||
state:
|
||||
bolt: ./nz.state # path to local state file
|
||||
|
||||
notifications:
|
||||
email:
|
||||
smtp: smtp.gmail.com:587 # SMTP server address
|
||||
login: nzbx@corp.com # email sender login
|
||||
password: secret # email sender password
|
||||
groups: # specify groups of alert receivers
|
||||
- name: developers # group name
|
||||
addresses: # list of alert receiver addresses in this group
|
||||
- alex@corp.com
|
||||
|
||||
commands:
|
||||
- name: Test command # short job description; all jobs MUST have unique description
|
||||
exec: ./script.sh arg1 # command, application or script to execute
|
||||
cron: "*/5 * * * *" # schedule execution as cron table record
|
||||
interval: 10s # overrides cron; schedule execution by interval between consecutive executions
|
||||
timeout: 2s # time limit for successful execution
|
||||
threshold: 3 # amount of consecutive failures before sending notifications
|
||||
threshold_sleep: 5s # interval between two failed executions before reaching threshold
|
||||
notifications: # specify 'type:group' tuple to send notifications
|
||||
- email:developers
|
17
go.mod
Normal file
17
go.mod
Normal file
|
@ -0,0 +1,17 @@
|
|||
module github.com/alexvanin/nezabx
|
||||
|
||||
go 1.18
|
||||
|
||||
require (
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
|
||||
github.com/robfig/cron/v3 v3.0.0
|
||||
go.etcd.io/bbolt v1.3.6
|
||||
go.uber.org/zap v1.21.0
|
||||
gopkg.in/yaml.v3 v3.0.0-20220512140231-539c8e751b99
|
||||
)
|
||||
|
||||
require (
|
||||
go.uber.org/atomic v1.9.0 // indirect
|
||||
go.uber.org/multierr v1.8.0 // indirect
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007 // indirect
|
||||
)
|
71
go.sum
Normal file
71
go.sum
Normal file
|
@ -0,0 +1,71 @@
|
|||
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/robfig/cron/v3 v3.0.0 h1:kQ6Cb7aHOHTSzNVNEhmp8EcWKLb4CbiMW9h9VyIhO4E=
|
||||
github.com/robfig/cron/v3 v3.0.0/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU=
|
||||
go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
|
||||
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||
go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8=
|
||||
go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
|
||||
go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8=
|
||||
go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
|
||||
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/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
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-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/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-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007 h1:gG67DSER+11cZvqIMb8S8bt0vZtiN6xWYARwirrOSfE=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
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-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
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-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20220512140231-539c8e751b99 h1:dbuHpmKjkDzSOMKAWl10QNlgaZUd3V1q99xc81tt2Kc=
|
||||
gopkg.in/yaml.v3 v3.0.0-20220512140231-539c8e751b99/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
107
main.go
Normal file
107
main.go
Normal file
|
@ -0,0 +1,107 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
||||
"github.com/alexvanin/nezabx/db"
|
||||
"github.com/alexvanin/nezabx/notifications/email"
|
||||
"github.com/alexvanin/nezabx/runners"
|
||||
"github.com/robfig/cron/v3"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
func main() {
|
||||
configFile := flag.String("c", "", "config file")
|
||||
debugFlag := flag.Bool("debug", false, "debug mode")
|
||||
versionFlag := flag.Bool("version", false, "show version")
|
||||
flag.Parse()
|
||||
|
||||
if *versionFlag {
|
||||
fmt.Printf("Nezabx %s\n", Version)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
log := Logger(*debugFlag)
|
||||
|
||||
cfg, err := ReadConfig(*configFile)
|
||||
if err != nil {
|
||||
log.Error("invalid configuration", zap.Error(err))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
state, err := db.NewBolt(cfg.State.Bolt)
|
||||
if err != nil {
|
||||
log.Error("invalid configuration", zap.Error(err))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var mailNotificator *email.Notificator
|
||||
|
||||
if cfg.Notifications.Email != nil {
|
||||
mailNotificator := email.NewNotificator(
|
||||
cfg.Notifications.Email.SMTP,
|
||||
cfg.Notifications.Email.Login,
|
||||
cfg.Notifications.Email.Password,
|
||||
)
|
||||
for _, group := range cfg.Notifications.Email.Groups {
|
||||
mailNotificator.AddGroup(group.Name, group.Addresses)
|
||||
}
|
||||
}
|
||||
|
||||
for _, command := range cfg.Commands {
|
||||
cmd, err := runners.NewCommand(command.Exec)
|
||||
if err != nil {
|
||||
log.Warn("invalid command configuration", zap.String("command", command.Name), zap.Error(err))
|
||||
os.Exit(1)
|
||||
}
|
||||
var cronSchedule cron.Schedule
|
||||
if command.Interval == 0 {
|
||||
cronSchedule, err = cron.ParseStandard(command.Cron)
|
||||
if err != nil {
|
||||
log.Warn("invalid cron configuration", zap.String("command", command.Name), zap.Error(err))
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
commandRunner := runners.CommandRunner{
|
||||
Log: log,
|
||||
DB: state,
|
||||
MailNotificator: mailNotificator,
|
||||
Command: cmd,
|
||||
Name: command.Name,
|
||||
Threshold: command.Threshold,
|
||||
ThresholdSleep: command.ThresholdSleep,
|
||||
Timeout: command.Timeout,
|
||||
Interval: command.Interval,
|
||||
CronSchedule: cronSchedule,
|
||||
Notifications: command.Notifications,
|
||||
}
|
||||
commandRunner.Run(ctx)
|
||||
}
|
||||
|
||||
log.Info("application started")
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
log.Info("application received termination signal")
|
||||
}
|
||||
|
||||
func Logger(debug bool) *zap.Logger {
|
||||
logCfg := zap.NewProductionConfig()
|
||||
logCfg.Level.SetLevel(zap.InfoLevel)
|
||||
logCfg.DisableCaller = true
|
||||
logCfg.Encoding = "console"
|
||||
logCfg.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
|
||||
logCfg.DisableStacktrace = true
|
||||
if debug {
|
||||
logCfg.Level.SetLevel(zap.DebugLevel)
|
||||
logCfg.DisableCaller = false
|
||||
}
|
||||
logger, _ := logCfg.Build()
|
||||
return logger
|
||||
}
|
71
notifications/email/email.go
Normal file
71
notifications/email/email.go
Normal file
|
@ -0,0 +1,71 @@
|
|||
package email
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type (
|
||||
Sender struct {
|
||||
SMTPServer string
|
||||
Login string
|
||||
Password string
|
||||
}
|
||||
|
||||
Notificator struct {
|
||||
Sender Sender
|
||||
Groups map[string][]string
|
||||
}
|
||||
)
|
||||
|
||||
func NewNotificator(smtp, login, password string) *Notificator {
|
||||
return &Notificator{
|
||||
Sender: Sender{
|
||||
SMTPServer: smtp,
|
||||
Login: login,
|
||||
Password: password,
|
||||
},
|
||||
Groups: make(map[string][]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Notificator) AddGroup(name string, addresses []string) {
|
||||
n.Groups[name] = addresses
|
||||
}
|
||||
|
||||
func (n Notificator) Send(group, sub, body string) error {
|
||||
receivers, ok := n.Groups[group]
|
||||
if !ok {
|
||||
return errors.New("unknown group")
|
||||
}
|
||||
return n.Sender.SendMail(receivers, sub, body)
|
||||
}
|
||||
|
||||
func (s Sender) SendMail(to []string, subj, body string) error {
|
||||
toTxt := fmt.Sprintf("To: %s\r\n", strings.Join(to, ";"))
|
||||
subjTxt := fmt.Sprintf("Subject: %s\r\n", subj)
|
||||
mimeTxt := "MIME-version: 1.0;\r\nContent-Type: text/html; charset=\"UTF-8\";\r\n\r\n"
|
||||
bodyTxt := fmt.Sprintf("%s\r\n", body)
|
||||
mailTxt := toTxt + subjTxt + mimeTxt + bodyTxt
|
||||
return smtp.SendMail(s.SMTPServer, s, s.Login, to, []byte(mailTxt))
|
||||
}
|
||||
|
||||
func (s Sender) Start(_ *smtp.ServerInfo) (string, []byte, error) {
|
||||
return "LOGIN", []byte{}, nil
|
||||
}
|
||||
|
||||
func (s Sender) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
if more {
|
||||
switch str := string(fromServer); str {
|
||||
case "Username:":
|
||||
return []byte(s.Login), nil
|
||||
case "Password:":
|
||||
return []byte(s.Password), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown fromServer %s", str)
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
231
runners/command.go
Normal file
231
runners/command.go
Normal file
|
@ -0,0 +1,231 @@
|
|||
package runners
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/alexvanin/nezabx/db"
|
||||
"github.com/alexvanin/nezabx/notifications/email"
|
||||
"github.com/google/shlex"
|
||||
"github.com/robfig/cron/v3"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type (
|
||||
Command struct {
|
||||
orig string
|
||||
cmd string
|
||||
args []string
|
||||
}
|
||||
|
||||
CommandRunner struct {
|
||||
Log *zap.Logger
|
||||
DB *db.Bolt
|
||||
MailNotificator *email.Notificator
|
||||
Command *Command
|
||||
Name string
|
||||
Threshold uint
|
||||
ThresholdSleep time.Duration
|
||||
Timeout time.Duration
|
||||
Interval time.Duration
|
||||
CronSchedule cron.Schedule
|
||||
Notifications []string
|
||||
}
|
||||
)
|
||||
|
||||
func NewCommand(path string) (*Command, error) {
|
||||
command, err := shlex.Split(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(command) < 1 {
|
||||
return nil, errors.New("empty command")
|
||||
}
|
||||
return &Command{
|
||||
orig: path,
|
||||
cmd: command[0],
|
||||
args: command[1:],
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h Command) Exec() ([]byte, error) {
|
||||
cmd := exec.Command(h.cmd, h.args...)
|
||||
return cmd.CombinedOutput()
|
||||
}
|
||||
|
||||
func (h Command) String() string {
|
||||
return h.orig
|
||||
}
|
||||
|
||||
func (c CommandRunner) Run(ctx context.Context) {
|
||||
h := sha256.Sum256([]byte(c.Name))
|
||||
id := h[:]
|
||||
|
||||
go func() {
|
||||
firstIter := true
|
||||
for {
|
||||
if !firstIter || c.CronSchedule == nil {
|
||||
c.run(ctx, id)
|
||||
}
|
||||
firstIter = false
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(c.untilNextIteration()):
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (c CommandRunner) run(ctx context.Context, id []byte) {
|
||||
st, err := c.DB.Status(id)
|
||||
if err != nil {
|
||||
c.Log.Warn("database is broken", zap.String("id", hex.EncodeToString(id)), zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.Log.Debug("starting script", zap.Stringer("cmd", c.Command))
|
||||
output, err := execScript(ctx, c.Command, c.Timeout)
|
||||
if err == nil {
|
||||
c.processSuccessfulExecution(id, st)
|
||||
return
|
||||
}
|
||||
|
||||
if !st.Notified {
|
||||
for i := 1; i < int(c.Threshold); i++ {
|
||||
c.Log.Info("script run failed", zap.Stringer("cmd", c.Command), zap.Int("iteration", i))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(c.ThresholdSleep):
|
||||
}
|
||||
|
||||
output, err = execScript(ctx, c.Command, c.Timeout)
|
||||
if err == nil {
|
||||
c.processSuccessfulExecution(id, st)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.processFailedExecution(id, st, output, err)
|
||||
}
|
||||
|
||||
func (c CommandRunner) processSuccessfulExecution(id []byte, st db.Status) {
|
||||
if !st.Failed {
|
||||
c.Log.Info("script run ok", zap.Stringer("cmd", c.Command),
|
||||
zap.Time("next iteration at", c.nextIteration()))
|
||||
return
|
||||
}
|
||||
|
||||
c.Log.Info("script run ok, recovered after failure", zap.Stringer("cmd", c.Command),
|
||||
zap.Time("next iteration at", c.nextIteration()))
|
||||
|
||||
st.Failed = false
|
||||
st.Notified = false
|
||||
err := c.DB.SetStatus(id, st)
|
||||
if err != nil {
|
||||
c.Log.Warn("database is broken", zap.String("id", hex.EncodeToString(id)), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (c CommandRunner) processFailedExecution(id []byte, st db.Status, output []byte, err error) {
|
||||
if st.Failed {
|
||||
c.Log.Info("script run failed, notification has already been sent",
|
||||
zap.Stringer("cmd", c.Command),
|
||||
zap.Time("next iteration at", c.nextIteration()))
|
||||
return
|
||||
}
|
||||
|
||||
c.Log.Info("script run failed, sending notification",
|
||||
zap.Stringer("cmd", c.Command),
|
||||
zap.Time("next iteration at", c.nextIteration()))
|
||||
st.Failed = true
|
||||
st.Notified = true
|
||||
err = c.notify(output, err)
|
||||
if err != nil {
|
||||
c.Log.Warn("notification was not sent", zap.Stringer("cmd", c.Command), zap.Error(err))
|
||||
st.Notified = false
|
||||
}
|
||||
|
||||
err = c.DB.SetStatus(id, st)
|
||||
if err != nil {
|
||||
c.Log.Warn("database is broken", zap.String("id", hex.EncodeToString(id)), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (c CommandRunner) notify(out []byte, err error) error {
|
||||
msg := fmt.Sprintf("Script runner <b>\"%s\"</b> has failed.<br>"+
|
||||
"Executed command: <b>%s</b><br>"+
|
||||
"Exit error: <b>%s</b><br><br>"+
|
||||
"Terminal output:<pre>%s</pre>", c.Name, c.Command, err.Error(), out)
|
||||
|
||||
for _, target := range c.Notifications {
|
||||
kv := strings.Split(target, ":")
|
||||
if len(kv) != 2 {
|
||||
c.Log.Warn("invalid notification target", zap.String("value", target))
|
||||
continue
|
||||
}
|
||||
switch kv[0] {
|
||||
case "email":
|
||||
if c.MailNotificator == nil {
|
||||
c.Log.Warn("email notifications were not configured")
|
||||
continue
|
||||
}
|
||||
err = c.MailNotificator.Send(kv[1], "Nezabx alert message", msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
c.Log.Warn("invalid notification type", zap.String("value", target))
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c CommandRunner) untilNextIteration() time.Duration {
|
||||
if c.CronSchedule != nil {
|
||||
return time.Until(c.CronSchedule.Next(time.Now()))
|
||||
}
|
||||
return c.Interval
|
||||
}
|
||||
|
||||
func (c CommandRunner) nextIteration() time.Time {
|
||||
if c.CronSchedule != nil {
|
||||
return c.CronSchedule.Next(time.Now()) // no truncate because cron operates at most with minute truncated values
|
||||
}
|
||||
return time.Now().Add(c.Interval).Truncate(time.Second)
|
||||
}
|
||||
|
||||
func execScript(ctx context.Context, cmd *Command, timeout time.Duration) (output []byte, err error) {
|
||||
type cmdOutput struct {
|
||||
out []byte
|
||||
err error
|
||||
}
|
||||
|
||||
res := make(chan cmdOutput)
|
||||
go func(ch chan<- cmdOutput) {
|
||||
v, err := cmd.Exec()
|
||||
res <- cmdOutput{
|
||||
out: v,
|
||||
err: err,
|
||||
}
|
||||
close(res)
|
||||
}(res)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(timeout):
|
||||
return nil, errors.New("script execution timeout")
|
||||
case v := <-res:
|
||||
return v.out, v.err
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue