Browse Source

initial import of rotochute.

rotochute is mainly [icinga2rt](https://github.com/bytemine/icinga2rt)
with some small additions and further tweaks.
master
commit
c814ee11d2
  1. 4
      .gitignore
  2. 6
      AUTHORS
  3. 26
      LICENSE
  4. 26
      Makefile
  5. 157
      README.md
  6. 217
      cache.go
  7. 174
      cache_test.go
  8. 261
      config.go
  9. 40
      config_test.go
  10. 8
      go.mod
  11. 8
      go.sum
  12. 186
      main.go
  13. 34
      rotochute.csv
  14. 376
      rt/rt.go
  15. 287
      rt2/rt2.go
  16. 203
      ticket.go
  17. 228
      ticket_test.go

4
.gitignore vendored

@ -0,0 +1,4 @@
.idea
rotochute.bolt
rotochute.json
rotochute

6
AUTHORS

@ -0,0 +1,6 @@
# Authors
icinga2rt was written by Ruben Schuller <schuller@bytemine.net>
Additions found in rotochute are from Felix Kronlage <fkr@hazardous.org>

26
LICENSE

@ -0,0 +1,26 @@
BSD 2-Clause License
Copyright (c) 2016, bytemine GmbH
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

26
Makefile

@ -0,0 +1,26 @@
GO111MODULE := on
tar: dist bin/icinga2rt dist/icinga2rt.json.example
mkdir dist/bytemine-icinga2rt
cp bin/icinga2rt dist/bytemine-icinga2rt/bytemine-icinga2rt
cp README.md dist/bytemine-icinga2rt
mv dist/icinga2rt.json.example dist/bytemine-icinga2rt
mv dist/bytemine-icinga2rt "dist/bytemine-icinga2rt-`bin/icinga2rt -version`"
cd dist && tar cvzf bytemine-icinga2rt-`../bin/icinga2rt -version`.tar.gz bytemine-icinga2rt-`../bin/icinga2rt -version`
cd dist && rm -r bytemine-icinga2rt-`../bin/icinga2rt -version`
sha256sum dist/bytemine-icinga2rt-`bin/icinga2rt -version`.tar.gz
bin:
mkdir -p bin
bin/icinga2rt: bin go.mod main.go cache.go ticket.go config.go rt/rt.go
go build -o bin/icinga2rt
test:
go test -v
dist:
mkdir -p dist
dist/icinga2rt.json.example: dist bin/icinga2rt
cd dist && ../bin/icinga2rt -example

157
README.md

@ -0,0 +1,157 @@
# rotochute
rotochute is based on
[icinga2rt](https://github.com/bytemine/icinga2rt) and much of the code
originates from there. rotochute adds the ability to work with the
[RT-REST2](https://github.com/bestpractical/rt-extension-rest2) api.
rotochute is a tool which automatically creates, updates and closes request tracker tickets on status changes of
hosts or services monitored by icinga2.
## Commandline Arguments
-config string
configuration file (default "rotochute.json")
-debug
debug mode, print log messages
-debugevents
print received events
-example
write example configuration file as rotochute.json.example to current directory
-exportCache string
export contents of cache to this file, and quit
-importCache string
import contents of cache from this file, and quit
-version
display version and exit
## Configuration
A configuration is expected to be in `rotochute.json`, other paths can be used with the `-config` switch.
The `rotochute.json.example` file is a good starting point for a config.
### Explained Example Configuration
If parts of this are used, comments (//...) must be removed. Using the `-example` switch is recommended.
{
"Icinga": {
"URL": "https://monitoring.example.com:5665", // URL to Icinga2 API
"User": "root", // Icinga2 API user
"Password": "secret", // Icinga2 API password
"Insecure": true, // Ignore SSL certificate errors
"Retries": 5 // Maximum tries for connecting to Icinga2 API
},
"RT": {
"URL": "https://support.example.com", // Request Tracker base URL
"User": "apiuser", // Request Tracker API user
"Password": "secret", // Request Tracker password
"Insecure": true // Ignore SSL certificate errors
},
"RT2": {
"URL": "https://support.example.com", // Request Tracker base URL
"Token": "secret", // Request Tracker password
"Insecure": true // Ignore SSL certificate errors
},
"Cache": {
"File": "rotochute.bolt" // Path to cache file storing event-ticket associations
},
"Ticket": {
"Mappings": "rotochute.csv", // File with mappings
"Nobody": "Nobody", // A Request Tracker ticket is unowned if owned by this user.
"Queue": "general", // Request Tracker queue where tickets are created
"ClosedStatus": [ // List of Request Tracker stati for which tickets are considered to be closed.
"done",
"resolved",
"deleted"
]
}
}
### Mappings
A mapping is the tuple of an events state, the old state (if any), if the ticket is owned, and an action to
perform for this event. These mappings are stored in a CSV file with the columns
- state: one of `UNKNOWN`, `WARNING`, `CRITICAL`, `OK`
- old state: one of `UNKNOWN`, `WARNING`, `CRITICAL`, `OK` or an empty string for non existing tickets.
- owned: one of `true` or `false`. should be `false` if old state is the empty string.
- action: one of `create`, `comment`, `delete` or `ignore`
The values supplied are read case-insensitive, but the values provided above are preferred.
Lines can be commented if their first character is `#`.
#### Example
# state, old state, owned, action
# ignore OK events if no old state is known
OK,,false,ignore
# delete ticket if unowned and was WARNING, CRITICAL or UNKNOWN
OK,WARNING,false,delete
OK,CRITICAL,false,delete
OK,UNKNOWN,false,delete
# comment ticket if unowned and was WARNING, CRITICAL or UNKNOWN
OK,WARNING,true,comment
OK,CRITICAL,true,comment
OK,UNKNOWN,true,comment
# create tickets for WARNING, CRITICAL or UNKNOWN if not exisiting
WARNING,,false,create
CRITICAL,,false,create
UNKNOWN,,false,create
# ignore if state hasn't changed
WARNING,WARNING,false,ignore
WARNING,WARNING,true,ignore
CRITICAL,CRITICAL,false,ignore
CRITICAL,CRITICAL,true,ignore
UNKNOWN,UNKNOWN,false,ignore
UNKNOWN,UNKNOWN,true,ignore
# comment tickets on state changes
WARNING,CRITICAL,false,comment
WARNING,CRITICAL,true,comment
WARNING,UNKNOWN,false,comment
WARNING,UNKNOWN,true,comment
CRITICAL,WARNING,false,comment
CRITICAL,WARNING,true,comment
CRITICAL,UNKNOWN,false,comment
CRITICAL,UNKNOWN,true,comment
UNKNOWN,WARNING,false,comment
UNKNOWN,WARNING,true,comment
UNKNOWN,CRITICAL,false,comment
UNKNOWN,CRITICAL,true,comment
## Running
### Upstart
description "rotochute ticket creator"
start on (net-device-up and local-filesystems and runlevel [2345])
stop on runlevel [016]
respawn
respawn limit 10 5
console log
setuid icingatort
setgid icingatort
exec /usr/sbin/rotochute
### systemd
I'm not really used to systemd, but this should work as a unit file:
[Unit]
Description=rotochute ticket creator
After=network-online.target
[Service]
Restart=on-failure
User=icingatort
Group=icingatort
ExecStart=/usr/sbin/rotochute
[Install]
WantedBy=multi-user.target

217
cache.go

@ -0,0 +1,217 @@
package main
import (
"bytes"
"encoding/gob"
"encoding/json"
"hash/fnv"
"io"
"log"
"github.com/bytemine/go-icinga2/event"
bolt "github.com/etcd-io/bbolt" // bbolt is the continuation of bolt and for now is usable as drop in replacement
)
const eventBucketName = "events"
const pendingBucketName = "pendingEvents"
// eventID generates an internal id to prevent using nested maps
func eventID(e *event.Notification) []byte {
h := fnv.New64a()
// the fvn hash always returns nil error, so we can ignore it here
h.Write([]byte(e.Host))
h.Write([]byte(e.Service))
return h.Sum(nil)
}
type cache struct {
*bolt.DB
debug bool
}
func openCache(path string) (*cache, error) {
db, err := bolt.Open(path, 0600, nil)
if err != nil {
return nil, err
}
return &cache{DB: db}, nil
}
// eventTicket is a helper struct for saving to bolt
type eventTicket struct {
Event *event.Notification
TicketID int
}
func decodeEventTicket(x []byte) (*eventTicket, error) {
var et eventTicket
buf := bytes.NewBuffer(x)
d := gob.NewDecoder(buf)
if err := d.Decode(&et); err != nil {
return nil, err
}
return &et, nil
}
func encodeEventTicket(et *eventTicket) ([]byte, error) {
var x bytes.Buffer
e := gob.NewEncoder(&x)
err := e.Encode(et)
if err != nil {
return nil, err
}
return x.Bytes(), nil
}
func (c *cache) getEventTicket(e *event.Notification) (*event.Notification, int, error) {
if *debug {
log.Printf("%x cache: get event", eventID(e))
}
eID := eventID(e)
var et *eventTicket
err := c.DB.View(func(tx *bolt.Tx) error {
var err error // declare it here so we can use = instead of := to prevent shadowing
eventBucket := tx.Bucket([]byte(eventBucketName))
if eventBucket == nil {
return nil
}
x := eventBucket.Get(eID)
// if we don't have a saved event just return nil
if x == nil {
return nil
}
et, err = decodeEventTicket(x)
if err != nil {
return err
}
return nil
})
if err != nil {
return nil, -1, err
}
if et == nil {
return nil, -1, nil
}
return et.Event, et.TicketID, nil
}
func (c *cache) updateEventTicket(e *event.Notification, ticketID int) error {
if *debug {
log.Printf("%x cache: update event", eventID(e))
}
eID := eventID(e)
err := c.DB.Update(func(tx *bolt.Tx) error {
hostBucket, err := tx.CreateBucketIfNotExists([]byte(eventBucketName))
if err != nil {
return err
}
x, err := encodeEventTicket(&eventTicket{Event: e, TicketID: ticketID})
if err != nil {
return err
}
return hostBucket.Put(eID, x)
})
return err
}
func (c *cache) deleteEventTicket(e *event.Notification) error {
if *debug {
log.Printf("%x cache: delete event", eventID(e))
}
eID := eventID(e)
err := c.DB.Update(func(tx *bolt.Tx) error {
hostBucket, err := tx.CreateBucketIfNotExists([]byte(eventBucketName))
if err != nil {
return err
}
return hostBucket.Delete(eID)
})
return err
}
func (c *cache) WriteTo(w io.Writer) (int64, error) {
err := c.DB.View(func(tx *bolt.Tx) error {
eventBucket := tx.Bucket([]byte(eventBucketName))
if eventBucket == nil {
return nil
}
enc := json.NewEncoder(w)
c := eventBucket.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
et, err := decodeEventTicket(v)
if err != nil {
return err
}
err = enc.Encode(et)
if err != nil {
return err
}
}
return nil
})
return 0, err
}
func (c *cache) ReadFrom(r io.Reader) (int64, error) {
err := c.DB.Update(func(tx *bolt.Tx) error {
eventBucket, err := tx.CreateBucketIfNotExists([]byte(eventBucketName))
if err != nil {
return err
}
et := &eventTicket{}
dec := json.NewDecoder(r)
for {
err := dec.Decode(et)
if err != nil {
if err == io.EOF {
return nil
}
return err
}
log.Printf("%#v", et)
x, err := encodeEventTicket(et)
if err != nil {
return err
}
eID := eventID(et.Event)
err = eventBucket.Put(eID, x)
if err != nil {
return err
}
}
})
return 0, err
}

174
cache_test.go

@ -0,0 +1,174 @@
package main
import (
"bytes"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"testing"
"github.com/bytemine/go-icinga2/event"
)
var testEvent = &event.Notification{Host: "example.com", Service: "example"}
func TestEventID(t *testing.T) {
if !bytes.Equal([]byte{0x6b, 0x19, 0x7e, 0xf, 0xbc, 0x99, 0x88, 0xa8}, eventID(testEvent)) {
t.Fail()
}
}
func TestEncodeDecode(t *testing.T) {
et := eventTicket{Event: testEvent, TicketID: 1234}
buf, err := encodeEventTicket(&et)
if err != nil {
t.Error(err)
}
xEt, err := decodeEventTicket(buf)
if err != nil {
t.Error(err)
}
if et.Event.Host != xEt.Event.Host || et.Event.Service != xEt.Event.Service {
t.Fail()
}
if et.TicketID != xEt.TicketID {
t.Fail()
}
}
func tempCache() (*cache, string, error) {
path, err := ioutil.TempDir("", "icinga2rt")
if err != nil {
return nil, "", err
}
path = filepath.Join(path, "icinga2rt.bolt")
c, err := openCache(path)
return c, path, err
}
func removeCache(cache *cache, path string) error {
err := cache.Close()
if err != nil {
return err
}
if path == "" {
return fmt.Errorf("path is empty")
}
err = os.Remove(path)
if err != nil {
return err
}
err = os.Remove(filepath.Dir(path))
return err
}
func TestGetEventTicket(t *testing.T) {
cache, path, err := tempCache()
if err != nil {
t.Error(err)
}
defer removeCache(cache, path)
err = cache.updateEventTicket(testEvent, 1234)
if err != nil {
t.Error()
}
e, ticketID, err := cache.getEventTicket(testEvent)
if err != nil {
t.Error(err)
}
if e.Host != testEvent.Host || e.Service != testEvent.Service || ticketID != 1234 {
t.Fail()
}
}
func TestGetNotExistingEventTicket(t *testing.T) {
cache, path, err := tempCache()
if err != nil {
t.Error(err)
}
defer removeCache(cache, path)
e, ticketID, err := cache.getEventTicket(testEvent)
if err != nil {
t.Error(err)
}
if e != nil || ticketID != -1 {
t.Fail()
}
}
func TestUpdateEventTicket(t *testing.T) {
cache, path, err := tempCache()
if err != nil {
t.Error(err)
}
defer removeCache(cache, path)
err = cache.updateEventTicket(testEvent, 1234)
if err != nil {
t.Error()
}
e, ticketID, err := cache.getEventTicket(testEvent)
if err != nil {
t.Error(err)
}
if e.Host != testEvent.Host || e.Service != testEvent.Service || ticketID != 1234 {
t.Fail()
}
err = cache.updateEventTicket(testEvent, 4321)
if err != nil {
t.Error()
}
e, ticketID, err = cache.getEventTicket(testEvent)
if err != nil {
t.Error(err)
}
if e.Host != testEvent.Host || e.Service != testEvent.Service || ticketID != 4321 {
t.Fail()
}
}
func TestDeleteEventTicket(t *testing.T) {
cache, path, err := tempCache()
if err != nil {
t.Error(err)
}
defer removeCache(cache, path)
err = cache.updateEventTicket(testEvent, 1234)
if err != nil {
t.Error()
}
err = cache.deleteEventTicket(testEvent)
if err != nil {
t.Error()
}
e, ticketID, err := cache.getEventTicket(testEvent)
if err != nil {
t.Error(err)
}
if e != nil || ticketID != -1 {
t.Fail()
}
}

261
config.go

@ -0,0 +1,261 @@
package main
import (
"encoding/csv"
"encoding/json"
"fmt"
"io"
"log"
"os"
"strings"
"github.com/bytemine/go-icinga2/event"
)
type icingaConfig struct {
URL string
User string
Password string
Insecure bool
Retries int
}
type rtConfig struct {
URL string
User string
Password string
Insecure bool
}
type rt2Config struct {
URL string
Token string
Insecure bool
}
type cacheConfig struct {
File string
}
type ticketConfig struct {
Mappings string
mappings []mapping
Nobody string
Queue string
ClosedStatus []string
}
type config struct {
Icinga icingaConfig
RT rtConfig
RT2 rt2Config
Cache cacheConfig
Ticket ticketConfig
}
var defaultConfig = config{
Icinga: icingaConfig{
URL: "https://monitoring.example.com:5665",
User: "root",
Password: "secret",
Insecure: true,
Retries: 5,
},
RT: rtConfig{
URL: "https://support.example.com",
User: "apiuser",
Password: "secret",
Insecure: true,
},
RT2: rt2Config{
URL: "https://support.example.com",
Token: "secret",
Insecure: true,
},
Cache: cacheConfig{
File: "rotochute..bolt",
},
Ticket: ticketConfig{
Mappings: "rotochute.csv",
mappings: []mapping{},
Nobody: "Nobody",
Queue: "general",
ClosedStatus: []string{
"done",
"resolved",
"deleted",
},
},
}
func checkConfig(conf *config) error {
if conf.Icinga.URL == "" {
return fmt.Errorf("Icinga.URL must be set.")
}
if conf.Icinga.User == "" {
return fmt.Errorf("Icinga.User must be set.")
}
if conf.Icinga.Retries == 0 {
return fmt.Errorf("Icinga.Retries must be > 0.")
}
if conf.Ticket.Queue == "" {
return fmt.Errorf("Ticket.Queue must be set.")
}
if conf.Ticket.Nobody == "" {
return fmt.Errorf("Ticket.Nobody must be set.")
}
if conf.Ticket.Mappings == "" || len(conf.Ticket.Mappings) == 0 {
return fmt.Errorf("Ticket.Mappings must be set.")
}
if conf.Ticket.ClosedStatus == nil || len(conf.Ticket.ClosedStatus) == 0 {
return fmt.Errorf("Ticket.ClosedStatus must be set.")
}
if conf.Cache.File == "" {
return fmt.Errorf("Cache.File must be set.")
}
return nil
}
func loadConfig(filename string) (*config, error) {
f, err := os.Open(filename)
if err != nil {
return nil, err
}
return readConfig(f)
}
func readConfig(r io.Reader) (*config, error) {
var c config
dec := json.NewDecoder(r)
err := dec.Decode(&c)
if err != nil {
return nil, err
}
f, err := os.Open(c.Ticket.Mappings)
if err != nil {
log.Fatalf("FATAL: opening mappings file: %s", err)
}
mappings, err := readMappings(f)
if err != nil {
return nil, err
}
c.Ticket.mappings = mappings
return &c, nil
}
func saveConfig(filename string, c *config) error {
f, err := os.Create(filename)
if err != nil {
return err
}
return writeConfig(f, c)
}
func writeConfig(w io.Writer, c *config) error {
x, err := json.MarshalIndent(c, "", "\t")
if err != nil {
return err
}
_, err = w.Write(x)
if err != nil {
return err
}
return nil
}
func parseCSVBool(value string) (bool, error) {
switch strings.ToLower(value) {
case "true":
return true, nil
case "false":
return false, nil
default:
return false, fmt.Errorf("invalid boolean value: %v", value)
}
}
const (
actionStringDelete = "delete"
actionStringComment = "comment"
actionStringCreate = "create"
actionStringIgnore = "ignore"
)
func parseCSVAction(value string) (actionFunc, error) {
switch strings.ToLower(value) {
case actionStringDelete:
return (*ticketUpdater).delete, nil
case actionStringComment:
return (*ticketUpdater).comment, nil
case actionStringCreate:
return (*ticketUpdater).create, nil
case actionStringIgnore:
return (*ticketUpdater).ignore, nil
default:
return nil, fmt.Errorf("invalid action value: %v", value)
}
}
func readMappings(r io.Reader) ([]mapping, error) {
ms := []mapping{}
x := csv.NewReader(r)
x.Comment = '#'
// state, old state, existing, owned, action
x.FieldsPerRecord = 4
line := 0
for {
line++
record, err := x.Read()
if err != nil {
if err != io.EOF {
return nil, err
}
break
}
// uppercase the value as icingas strings are uppercase
state := event.NewState(strings.ToUpper(record[0]))
if state == event.StateNil {
return nil, fmt.Errorf("error in line %v: invalid state value %v", line, record[0])
}
oldState := event.NewState(record[1])
owned, err := parseCSVBool(record[2])
if err != nil {
return nil, fmt.Errorf("error in line %v: %v", line, err)
}
action, err := parseCSVAction(record[3])
if err != nil {
return nil, fmt.Errorf("error in line %v: %v", line, err)
}
m := mapping{condition: condition{state: state, oldState: oldState, owned: owned}, action: action}
ms = append(ms, m)
}
return ms, nil
}

40
config_test.go

@ -0,0 +1,40 @@
package main
import (
"strings"
"testing"
)
const validCSV = `# state, old state, existing, owned, action
OK,WARNING,true,comment
CRITICAL,UNKNOWN,false,ignore
OK,WARNING,true,create
CRITICAL,UNKNOWN,false,delete`
const invalidCSVState = `,WARNING,true,comment`
const invalidCSVBool0 = `OK,WARNING,ŧ®üé,comment`
const invalidCSVBool1 = `OK,WARNING,fæðlſ€,comment`
const invalidCSVAction = `OK,WARNING,true,¢ömm€nŧ`
func TestReadMappings(t *testing.T) {
r := strings.NewReader(validCSV)
ms, err := readMappings(r)
if err != nil {
t.Error(err)
}
if len(ms) != 4 {
t.Fail()
}
t.Log(ms)
for _, v := range []string{invalidCSVState, invalidCSVBool0, invalidCSVBool1, invalidCSVAction} {
r := strings.NewReader(v)
_, err := readMappings(r)
if err == nil {
t.Fail()
t.Logf("expected error while parsing invalid CSV: %v", v)
}
}
}

8
go.mod

@ -0,0 +1,8 @@
module g.hazardous.org/fkr/rotochute
require (
github.com/boltdb/bolt v1.3.1 // indirect
github.com/bytemine/go-icinga2 v0.0.4
github.com/etcd-io/bbolt v1.3.0
golang.org/x/sys v0.0.0-20181005133103-4497e2df6f9e // indirect
)

8
go.sum

@ -0,0 +1,8 @@
github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4=
github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps=
github.com/bytemine/go-icinga2 v0.0.4 h1:XwbJTWP95jCQazA7+0Zd9bc6ejnTuuLHu6GB/vMxspg=
github.com/bytemine/go-icinga2 v0.0.4/go.mod h1:EcdGvd8AQNKmTsgE5oqmWk0COr6UU9CS4QQBb3HUqs8=
github.com/etcd-io/bbolt v1.3.0 h1:ec0U3x11Mk69A8YwQyZEhNaUqHkQSv2gDR3Bioz5DfU=
github.com/etcd-io/bbolt v1.3.0/go.mod h1:ZF2nL25h33cCyBtcyWeZ2/I3HQOfTP+0PIEvHjkjCrw=
golang.org/x/sys v0.0.0-20181005133103-4497e2df6f9e h1:EfdBzeKbFSvOjoIqSZcfS8wp0FBLokGBEs9lz1OtSg0=
golang.org/x/sys v0.0.0-20181005133103-4497e2df6f9e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=

186
main.go

@ -0,0 +1,186 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"io"
"log"
"os"
"time"
"github.com/bytemine/go-icinga2"
"github.com/bytemine/go-icinga2/event"
. "g.hazardous.org/fkr/rotochute/rt2"
)
const version = "0.2.1"
const icingaQueueName = "rotochute"
var writeExample = flag.Bool("example", false, "write example configuration file as rotochute.json.example to current directory")
var configFile = flag.String("config", "rotochute.json", "configuration file")
var debug = flag.Bool("debug", false, "debug mode, print log messages")
var debugEvents = flag.Bool("debugevents", false, "print received events")
var showVersion = flag.Bool("version", false, "display version and exit")
var exportCache = flag.String("exportCache", "", "export contents of cache to this file, and quit")
var importCache = flag.String("importCache", "", "import contents of cache from this file, and quit")
// openEventStreamer connects to the icinga2 API, exponentially backing off when the connection fails
func openEventStreamer(retries int, icingaClient *icinga2.Client, queue string, filter string, streamtype ...event.StreamType) (io.Reader, error) {
exp := uint(0)
var err error
for tries := 0; tries < retries; tries++ {
if *debug {
log.Printf("main: connecting to icinga, try: %v", tries+1)
}
var r io.Reader
r, err = icingaClient.EventStream(queue, filter, streamtype...)
if err != nil {
if *debug {
log.Printf("main: couldn't connect to icinga: %v", err)
log.Printf("main: waiting %v seconds before trying again.", 1<<exp)
}
time.Sleep(time.Duration(1<<exp) * time.Second)
exp++
continue
}
return r, nil
}
return nil, err
}
// rtClient interface enables to use a dummy client for testing.
type rtClient interface {
Ticket(int) (*Ticket, error)
NewTicket(string, string, string) (int, error)
UpdateTicket(*Ticket) (*Ticket, error)
CommentTicket(int, string) error
}
func main() {
flag.Parse()
if *showVersion {
fmt.Println(version)
os.Exit(0)
}
if *writeExample {
err := saveConfig("rotochute.json.example", &defaultConfig)
if err != nil {
log.Fatal("FATAL: init:", err)
}
os.Exit(0)
}
conf, err := loadConfig(*configFile)
if err != nil {
log.Fatalf("FATAL: init: Couldn't open config file %v: %v", *configFile, err)
}
if err := checkConfig(conf); err != nil {
log.Fatal("FATAL: init:", err)
}
eventCache, err := openCache(conf.Cache.File)
if err != nil {
log.Fatalf("FATAL: failed to open configured cache file: %s", err)
}
if *exportCache != "" {
var f io.WriteCloser
if *exportCache == "-" {
f = os.Stdout
} else {
f, err = os.Create(*exportCache)
if err != nil {
log.Fatal("FATAL: export:", err)
}
}
_, err := eventCache.WriteTo(f)
if err != nil {
log.Fatal("FATAL: export:", err)
}
f.Close()
eventCache.Close()
os.Exit(0)
}
if *importCache != "" {
var f io.ReadCloser
if *importCache == "-" {
f = os.Stdin
} else {
f, err = os.Open(*importCache)
if err != nil {
log.Fatal("FATAL: import:", err)
}
}
_, err := eventCache.ReadFrom(f)
if err != nil {
log.Fatal("FATAL: import:", err)
}
f.Close()
eventCache.Close()
os.Exit(0)
}
rtClient, err := NewClient(conf.RT2.URL, "", conf.RT2.Token, conf.RT2.Insecure)
//rtClient, err := rt.NewClient(conf.RT.URL, conf.RT.User, conf.RT.Password, conf.RT.Insecure)
if err != nil {
log.Fatal("FATAL: init:", err)
}
tu := newTicketUpdater(eventCache, rtClient, conf.Ticket.mappings, conf.Ticket.Nobody, conf.Ticket.Queue, conf.Ticket.ClosedStatus)
icingaClient, err := icinga2.NewClient(conf.Icinga.URL, conf.Icinga.User, conf.Icinga.Password, conf.Icinga.Insecure)
if err != nil {
log.Fatal("FATAL: init:", err)
}
r, err := openEventStreamer(conf.Icinga.Retries, icingaClient, icingaQueueName, "", event.StreamTypeNotification)
if err != nil {
log.Fatal("FATAL: init:", err)
}
dec := json.NewDecoder(r)
for {
var x event.Notification
err := dec.Decode(&x)
if err != nil {
if *debug {
log.Printf("main: %v", err)
log.Printf("main: trying to reconnect to icinga.")
}
r, err := openEventStreamer(conf.Icinga.Retries, icingaClient, icingaQueueName, "", event.StreamTypeNotification)
if err != nil {
log.Printf("FATAL: main:", err)
}
dec = json.NewDecoder(r)
continue
}
if *debug && *debugEvents {
buf, err := json.Marshal(x)
if err != nil {
log.Printf("FATAL: main:", err)
}
log.Println("main: event stream:", string(buf))
}
err = tu.update(&x)
if err != nil {
log.Fatal("FATAL: main:", err)
}
}
}

34
rotochute.csv

@ -0,0 +1,34 @@
# ignore OK events if no old state is known
OK,,false,ignore
# delete ticket if unowned and was WARNING, CRITICAL or UNKNOWN
OK,WARNING,false,delete
OK,CRITICAL,false,delete
OK,UNKNOWN,false,delete
# comment ticket if unowned and was WARNING, CRITICAL or UNKNOWN
OK,WARNING,true,comment
OK,CRITICAL,true,comment
OK,UNKNOWN,true,comment
# create tickets for WARNING, CRITICAL or UNKNOWN if not exisiting
WARNING,,false,create
CRITICAL,,false,create
UNKNOWN,,false,create
# ignore if state hasn't changed
WARNING,WARNING,false,ignore
WARNING,WARNING,true,ignore
CRITICAL,CRITICAL,false,ignore
CRITICAL,CRITICAL,true,ignore
UNKNOWN,UNKNOWN,false,ignore
UNKNOWN,UNKNOWN,true,ignore
# comment tickets on state changes
WARNING,CRITICAL,false,comment
WARNING,CRITICAL,true,comment
WARNING,UNKNOWN,false,comment
WARNING,UNKNOWN,true,comment
CRITICAL,WARNING,false,comment
CRITICAL,WARNING,true,comment
CRITICAL,UNKNOWN,false,comment
CRITICAL,UNKNOWN,true,comment
UNKNOWN,WARNING,false,comment
UNKNOWN,WARNING,true,comment
UNKNOWN,CRITICAL,false,comment
UNKNOWN,CRITICAL,true,comment

376
rt/rt.go

@ -0,0 +1,376 @@
package rt
import (
"bufio"
"crypto/tls"
"fmt"
"io"
"net/http"
"net/url"
"path/filepath"
"strconv"
"strings"
)
type Ticket struct {
ID int
Queue string
Owner string
Creator string
Subject string
Status string
Priority string
InitialPriority string
FinalPriority string
Requestors string
Cc string
AdminCc string
Created string
Starts string
Started string
Due string
Resolved string
Told string
LastUpdated string
TimeEstimated string
TimeWorked string
TimeLeft string
Text string
}
// This ignores the Text of a ticket for now.
func (t *Ticket) decode(r io.Reader) error {
s := bufio.NewScanner(r)
for s.Scan() {
if strings.HasPrefix(s.Text(), "# Ticket ") {
return fmt.Errorf("ticket doesn't exist")
}
if !strings.Contains(s.Text(), ": ") {
continue
}
fs := strings.Split(s.Text(), ": ")
if len(fs) < 2 {
continue
}
c := fs[1]
switch fs[0] {
case "id":
idStr := strings.TrimPrefix(c, "ticket/")
id, err := strconv.Atoi(idStr)
if err != nil {
return err
}
t.ID = id
case "Queue":
t.Queue = c
case "Owner":
t.Owner = c
case "Creator":
t.Creator = c
case "Subject":
t.Subject = c
case "Status":
t.Status = c
case "Priority":
t.Priority = c
case "FinalPriority":
t.FinalPriority = c
case "Requestors":
t.Requestors = c
case "Cc":
t.Cc = c
case "AdminCc":
t.AdminCc = c
case "Created":
t.Created = c
case "Starts":
t.Starts = c
case "Started":
t.Started = c
case "Due":
t.Due = c
case "Resolved":
t.Resolved = c
case "Told":
t.Told = c
case "LastUpdated":
t.LastUpdated = c
case "TimeEstimated":
t.TimeEstimated = c
case "TimeWorked":
t.TimeWorked = c
case "TimeLeft":
t.TimeLeft = c
}
}
return nil
}
func (t *Ticket) encode() string {
out := []string{}
if t.ID == 0 {
out = append(out, "id: new")
} else {
out = append(out, fmt.Sprintf("id: %v", t.ID))
}
if t.Queue != "" {
out = append(out, fmt.Sprintf("Queue: %s", t.Queue))
}
if t.Owner != "" {
out = append(out, fmt.Sprintf("Owner: %s", t.Owner))
}
if t.Subject != "" {
out = append(out, fmt.Sprintf("Subject: %s", t.Subject))
}
if t.Status != "" {
out = append(out, fmt.Sprintf("Status: %s", t.Status))
}
if t.Priority != "" {
out = append(out, fmt.Sprintf("Priority: %s", t.Priority))
}
if t.FinalPriority != "" {
out = append(out, fmt.Sprintf("FinalPriority: %s", t.FinalPriority))
}
if t.Requestors != "" {
out = append(out, fmt.Sprintf("Requestors: %s", t.Requestors))
}
if t.Cc != "" {
out = append(out, fmt.Sprintf("Cc: %s", t.Cc))
}
if t.AdminCc != "" {
out = append(out, fmt.Sprintf("AdminCc: %s", t.AdminCc))
}
if t.Starts != "" {
out = append(out, fmt.Sprintf("Starts: %s", t.Starts))
}
if t.Started != "" {
out = append(out, fmt.Sprintf("Started: %s", t.Started))
}
if t.Due != "" {
out = append(out, fmt.Sprintf("Due: %s", t.Due))
}
if t.Resolved != "" {
out = append(out, fmt.Sprintf("Resolved: %s", t.Resolved))
}
if t.TimeEstimated != "" {
out = append(out, fmt.Sprintf("TimeEstimated: %s", t.TimeEstimated))
}
if t.TimeWorked != "" {
out = append(out, fmt.Sprintf("TimeWorked: %s", t.TimeWorked))
}
if t.TimeLeft != "" {
out = append(out, fmt.Sprintf("TimeLeft: %s", t.TimeLeft))
}
if t.Text != "" {
out = append(out, fmt.Sprintf("Text: %s", t.Text))
}
return strings.Join(out, "\n")
}
// Client is a RT REST 1.0 client.
type Client struct {
url *url.URL
user string
password string
insecureSkipVerify bool
}
// NewClient prepares a Client for usage.
func NewClient(rtURL string, user, password string, insecureSkipVerify bool) (*Client, error) {
x, err := url.Parse(rtURL)
if err != nil {
return nil, err
}
return &Client{url: x, user: user, password: password, insecureSkipVerify: insecureSkipVerify}, nil
}
func (c *Client) Ticket(id int) (*Ticket, error) {
x := http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: c.insecureSkipVerify}}}
query := url.Values{}
query.Set("user", c.user)
query.Set("pass", c.password)
u := url.URL{Scheme: "https", Host: c.url.Host, Path: filepath.Join(c.url.Path, "REST", "1.0", "ticket", strconv.Itoa(id)), RawQuery: query.Encode()}
req, err := http.NewRequest("GET", u.String(), nil)
if err != nil {
return nil, err
}
res, err := x.Do(req)
if err != nil {
return nil, err
}
t := &Ticket{}
err = t.decode(res.Body)
if err != nil {
return nil, err
}
return t, nil
}
func (c *Client) NewTicket(subject string, queue string, content string) (int, error) {
x := http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: c.insecureSkipVerify}}}
query := url.Values{}
query.Set("user", c.user)
query.Set("pass", c.password)
u := url.URL{Scheme: "https", Host: c.url.Host, Path: filepath.Join(c.url.Path, "REST", "1.0", "ticket", "new"), RawQuery: query.Encode()}
ticket := &Ticket{Queue: queue, Subject: subject, Text: content}
form := url.Values{}
form.Add("content", ticket.encode())
req, err := http.NewRequest("POST", u.String(), strings.NewReader(form.Encode()))
if err != nil {
return 0, err
}
res, err := x.Do(req)
if err != nil {
return 0, err
}
s := bufio.NewScanner(res.Body)
id := 0
for s.Scan() {
if strings.HasPrefix(s.Text(), "# Ticket ") {
fs := strings.Fields(s.Text())
if len(fs) != 4 {
return 0, fmt.Errorf("response didn't contain ticket number.")
}
id, err = strconv.Atoi(fs[2])
if err != nil {
return 0, err
}
}
}
return id, nil
}
func (c *Client) UpdateTicket(ticket *Ticket) (*Ticket, error) {
x := http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: c.insecureSkipVerify}}}
query := url.Values{}
query.Set("user", c.user)
query.Set("pass", c.password)
u := url.URL{Scheme: "https", Host: c.url.Host, Path: filepath.Join(c.url.Path, "REST", "1.0", "ticket", strconv.Itoa(ticket.ID), "edit"), RawQuery: query.Encode()}
form := url.Values{}
form.Add("content", ticket.encode())
req, err := http.NewRequest("POST", u.String(), strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
res, err := x.Do(req)
if err != nil {
return nil, err
}
s := bufio.NewScanner(res.Body)
id := 0
for s.Scan() {
if strings.HasPrefix(s.Text(), "# Ticket ") {
fs := strings.Fields(s.Text())
if len(fs) != 4 {
return nil, fmt.Errorf("response didn't contain ticket number.")
}
id, err = strconv.Atoi(fs[2])
if err != nil {
return nil, err
}
}