Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ services:
- `mail` to send mails
- `save` to save structured execution reports to a directory
- `slack` to send messages via a slack webhook
- `teams` to send messages via a Microsoft Teams webhook

#### Options
- `smtp-host` - address of the SMTP server.
Expand All @@ -136,13 +137,16 @@ services:
- `slack-webhook` - URL of the slack webhook.
- `slack-only-on-error` - only send a slack message if the execution was not successful.

- `teams-webhook` - URL of the Teams incoming webhook.
- `teams-only-on-error` - only send a Teams message if the execution was not successful.

### Overlap
**Ofelia** can prevent that a job is run twice in parallel (e.g. if the first execution didn't complete before a second execution was scheduled. If a job has the option `no-overlap` set, it will not be run concurrently.

## Installation

The easiest way to deploy **ofelia** is using *Docker*. See examples above.

If don't want to run **ofelia** using our *Docker* image you can download a binary from [releases](https://github.com/mcuadros/ofelia/releases) page.
If you don't want to run **ofelia** using our *Docker* image you can download a binary from [releases](https://github.com/mcuadros/ofelia/releases) page.

> Why the project is named Ofelia? Ofelia is the name of the office assistant from the Spanish comic [Mortadelo y Filemón](https://en.wikipedia.org/wiki/Mort_%26_Phil)
10 changes: 10 additions & 0 deletions cli/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ var IsDockerEnv bool
type Config struct {
Global struct {
middlewares.SlackConfig `mapstructure:",squash"`
middlewares.TeamsConfig `mapstructure:",squash"`
middlewares.SaveConfig `mapstructure:",squash"`
middlewares.MailConfig `mapstructure:",squash"`
}
Expand Down Expand Up @@ -144,6 +145,7 @@ func (c *Config) buildLogger() core.Logger {

func (c *Config) buildSchedulerMiddlewares(sh *core.Scheduler) {
sh.Use(middlewares.NewSlack(&c.Global.SlackConfig))
sh.Use(middlewares.NewTeams(&c.Global.TeamsConfig))
sh.Use(middlewares.NewSave(&c.Global.SaveConfig))
sh.Use(middlewares.NewMail(&c.Global.MailConfig))
}
Expand All @@ -153,13 +155,15 @@ type ExecJobConfig struct {
core.ExecJob `mapstructure:",squash"`
middlewares.OverlapConfig `mapstructure:",squash"`
middlewares.SlackConfig `mapstructure:",squash"`
middlewares.TeamsConfig `mapstructure:",squash"`
middlewares.SaveConfig `mapstructure:",squash"`
middlewares.MailConfig `mapstructure:",squash"`
}

func (c *ExecJobConfig) buildMiddlewares() {
c.ExecJob.Use(middlewares.NewOverlap(&c.OverlapConfig))
c.ExecJob.Use(middlewares.NewSlack(&c.SlackConfig))
c.ExecJob.Use(middlewares.NewTeams(&c.TeamsConfig))
c.ExecJob.Use(middlewares.NewSave(&c.SaveConfig))
c.ExecJob.Use(middlewares.NewMail(&c.MailConfig))
}
Expand All @@ -169,6 +173,7 @@ type RunServiceConfig struct {
core.RunServiceJob `mapstructure:",squash"`
middlewares.OverlapConfig `mapstructure:",squash"`
middlewares.SlackConfig `mapstructure:",squash"`
middlewares.TeamsConfig `mapstructure:",squash"`
middlewares.SaveConfig `mapstructure:",squash"`
middlewares.MailConfig `mapstructure:",squash"`
}
Expand All @@ -177,13 +182,15 @@ type RunJobConfig struct {
core.RunJob `mapstructure:",squash"`
middlewares.OverlapConfig `mapstructure:",squash"`
middlewares.SlackConfig `mapstructure:",squash"`
middlewares.TeamsConfig `mapstructure:",squash"`
middlewares.SaveConfig `mapstructure:",squash"`
middlewares.MailConfig `mapstructure:",squash"`
}

func (c *RunJobConfig) buildMiddlewares() {
c.RunJob.Use(middlewares.NewOverlap(&c.OverlapConfig))
c.RunJob.Use(middlewares.NewSlack(&c.SlackConfig))
c.RunJob.Use(middlewares.NewTeams(&c.TeamsConfig))
c.RunJob.Use(middlewares.NewSave(&c.SaveConfig))
c.RunJob.Use(middlewares.NewMail(&c.MailConfig))
}
Expand All @@ -193,20 +200,23 @@ type LocalJobConfig struct {
core.LocalJob `mapstructure:",squash"`
middlewares.OverlapConfig `mapstructure:",squash"`
middlewares.SlackConfig `mapstructure:",squash"`
middlewares.TeamsConfig `mapstructure:",squash"`
middlewares.SaveConfig `mapstructure:",squash"`
middlewares.MailConfig `mapstructure:",squash"`
}

func (c *LocalJobConfig) buildMiddlewares() {
c.LocalJob.Use(middlewares.NewOverlap(&c.OverlapConfig))
c.LocalJob.Use(middlewares.NewSlack(&c.SlackConfig))
c.LocalJob.Use(middlewares.NewTeams(&c.TeamsConfig))
c.LocalJob.Use(middlewares.NewSave(&c.SaveConfig))
c.LocalJob.Use(middlewares.NewMail(&c.MailConfig))
}

func (c *RunServiceConfig) buildMiddlewares() {
c.RunServiceJob.Use(middlewares.NewOverlap(&c.OverlapConfig))
c.RunServiceJob.Use(middlewares.NewSlack(&c.SlackConfig))
c.RunServiceJob.Use(middlewares.NewTeams(&c.TeamsConfig))
c.RunServiceJob.Use(middlewares.NewSave(&c.SaveConfig))
c.RunServiceJob.Use(middlewares.NewMail(&c.MailConfig))
}
148 changes: 148 additions & 0 deletions middlewares/teams.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package middlewares

import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"strings"

"github.com/mcuadros/ofelia/core"
)

var (
teamsAvatarURL = "https://raw.githubusercontent.com/mcuadros/ofelia/master/static/avatar.png"
)

// TeamsConfig configuration for the Teams middleware
type TeamsConfig struct {
TeamsWebhook string `gcfg:"teams-webhook" mapstructure:"teams-webhook"`
TeamsOnlyOnError bool `gcfg:"teams-only-on-error" mapstructure:"teams-only-on-error"`
}

func NewTeams(c *TeamsConfig) core.Middleware {
var m core.Middleware
if !IsEmpty(c) {
m = &Teams{*c}
}

return m
}

// Teams middleware calls to a Teams input-hook after every execution of a job
type Teams struct {
TeamsConfig
}

// ContinueOnStop returns always true
func (m *Teams) ContinueOnStop() bool {
return true
}

// Run sends a message to the Teams channel, its close stop the execution to
// collect the metrics
func (m *Teams) Run(ctx *core.Context) error {
err := ctx.Next()
ctx.Stop(err)

if ctx.Execution.Failed || !m.TeamsOnlyOnError {
m.pushMessage(ctx)
}

return err
}

func (m *Teams) pushMessage(ctx *core.Context) {
content, _ := json.Marshal(m.buildMessage(ctx))
reader := bytes.NewReader(content)

r, err := http.Post(m.TeamsWebhook, "application/json", reader)
if err != nil {
ctx.Logger.Errorf("Teams error calling %q error: %q", m.TeamsWebhook, err)
} else if r.StatusCode != 200 {
body, _ := ioutil.ReadAll(r.Body)
ctx.Logger.Errorf("Teams error non-200 status code calling %q: %v", m.TeamsWebhook, string(body))
}
}

func (m *Teams) buildMessage(ctx *core.Context) *teamsMessage {
msg := newTeamsMessage()

title := fmt.Sprintf(
"Job *%q* finished in *%s*, command `%s`",
ctx.Job.GetName(), ctx.Execution.Duration, ctx.Job.GetCommand(),
)

s1 := teamsMessageSections{
ActivityTitle: title,
ActivitySubtitle: "Execution successful",
ActivityImage: teamsAvatarURL,
Facts: make([]teamsMessageSectionFact, 0),
Markdown: true,
}

if ctx.Execution.Failed {
msg.ThemeColor = "F35A00"
msg.Summary = "Execution failed"
s1.ActivitySubtitle = fmt.Sprintf("Execution failed: %v", ctx.Execution.Error.Error())
} else if ctx.Execution.Skipped {
msg.ThemeColor = "FFA500"
msg.Summary = "Execution skipped"
s1.ActivitySubtitle = fmt.Sprintf("Execution skipped")
}

msg.Sections = append(msg.Sections, s1)

if isSuccess(ctx.Execution) {
s2 := teamsMessageSections{
ActivityTitle: "Execution results",
ActivityText: strings.ReplaceAll(ctx.Execution.OutputStream.String(), "\n", "<br>"),
ActivityImage: "",
Facts: nil,
Markdown: true,
}
msg.Sections = append(msg.Sections, s2)
}

return msg
}

func isSuccess(e *core.Execution) bool {
if e.Failed || e.Skipped {
return false
}
return true
}

func newTeamsMessage() *teamsMessage {
return &teamsMessage{
Type: "MessageCard",
Context: "http://schema.org/extensions",
ThemeColor: "0076D7",
Summary: "Execution successful",
Sections: make([]teamsMessageSections, 0),
}
}

type teamsMessage struct {
Type string `json:"@type"`
Context string `json:"@context"`
ThemeColor string `json:"themeColor"`
Summary string `json:"summary"`
Sections []teamsMessageSections `json:"sections"`
}

type teamsMessageSections struct {
ActivityTitle string `json:"activityTitle"`
ActivitySubtitle string `json:"activitySubtitle"`
ActivityImage string `json:"activityImage"`
ActivityText string `json:"activityText"`
Facts []teamsMessageSectionFact `json:"facts"`
Markdown bool `json:"markdown"`
}

type teamsMessageSectionFact struct {
Name string `json:"name"`
Value string `json:"value"`
}
68 changes: 68 additions & 0 deletions middlewares/teams_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package middlewares

import (
"encoding/json"
"errors"
. "gopkg.in/check.v1"
"io/ioutil"
"net/http"
"net/http/httptest"
)

type SuiteTeams struct {
BaseSuite
}

var _ = Suite(&SuiteTeams{})

func (s *SuiteTeams) TestNewTeamsEmpty(c *C) {
c.Assert(NewTeams(&TeamsConfig{}), IsNil)
}

func (s *SuiteTeams) TestRunTeamsSuccess(c *C) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var m teamsMessage
b, _ := ioutil.ReadAll(r.Body)
_ = json.Unmarshal(b, &m)
c.Assert(m.Summary, Equals, "Execution successful")
}))

defer ts.Close()

s.ctx.Start()
s.ctx.Stop(nil)

m := NewTeams(&TeamsConfig{TeamsWebhook: ts.URL})
c.Assert(m.Run(s.ctx), IsNil)
}

func (s *SuiteTeams) TestRunTeamsSuccessFailed(c *C) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var m teamsMessage
b, _ := ioutil.ReadAll(r.Body)
_ = json.Unmarshal(b, &m)
c.Assert(m.Summary, Equals, "Execution failed")
}))

defer ts.Close()

s.ctx.Start()
s.ctx.Stop(errors.New("foo"))

m := NewTeams(&TeamsConfig{TeamsWebhook: ts.URL})
c.Assert(m.Run(s.ctx), IsNil)
}

func (s *SuiteTeams) TestRunTeamsSuccessOnError(c *C) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c.Assert(true, Equals, false)
}))

defer ts.Close()

s.ctx.Start()
s.ctx.Stop(nil)

m := NewTeams(&TeamsConfig{TeamsWebhook: ts.URL, TeamsOnlyOnError: true})
c.Assert(m.Run(s.ctx), IsNil)
}