package command
import (
"fmt"
"log/slog"
"os/exec"
"strings"
)
type ICommandRunner interface {
Command(name string, arg ...string) *exec.Cmd
Run(cmd *exec.Cmd) error
}
type CommandRunner struct {
}
func (c CommandRunner) Command(name string, arg ...string) *exec.Cmd {
return exec.Command(name, arg...)
}
func (c CommandRunner) Run(cmd *exec.Cmd) error {
return cmd.Run()
}
type FakeCommandRunner struct {
Calls []string
cmd *exec.Cmd
runErr error
stdout string
stderr string
}
func (c *FakeCommandRunner) Command(name string, arg ...string) *exec.Cmd {
c.Calls = append(c.Calls, fmt.Sprintf("%s %s", name, strings.Join(arg, " ")))
return exec.Command(name, arg...)
}
func (c *FakeCommandRunner) Run(cmd *exec.Cmd) error {
c.cmd = cmd
cmd.Stderr.Write([]byte(c.stderr))
cmd.Stdout.Write([]byte(c.stdout))
return c.runErr
}
func Execute(icr ICommandRunner, command string, isDryRun bool) {
slog.Debug("processing command: " + fmt.Sprint(command))
if isDryRun {
slog.Info("dry run: " + command)
} else {
cmd := icr.Command("bash", "-c", command)
var stdout strings.Builder
var stderr strings.Builder
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := icr.Run(cmd); err != nil {
slog.Error("command execution failed: " + command)
slog.Error(err.Error())
return
}
slog.Info("stdout: " + stdout.String())
if stderr.String() != "" {
slog.Error("stderr: " + stderr.String())
}
}
}
package config
import (
"fmt"
"io"
"log/slog"
"os"
"gopkg.in/yaml.v3"
)
var DefaultSubstitutionPrefix = "~"
var DefaultAlertNameKey = "alertname"
type Action struct {
Name string
Alertname string
Command string
}
type Config struct {
Version string
Actions []Action
SubstitutionPrefix string
AlertNameKey string
}
type IConfigReader interface {
Open(string) (io.ReadCloser, error)
ReadAll(io.Reader) ([]byte, error)
Unmarshal([]byte, interface{}) error
}
type ConfigReader struct{}
func (cr ConfigReader) Open(path string) (io.ReadCloser, error) {
return os.Open(path)
}
func (cr ConfigReader) ReadAll(r io.Reader) ([]byte, error) {
return io.ReadAll(r)
}
func (cr ConfigReader) Unmarshal(bytes []byte, v interface{}) error {
return yaml.Unmarshal(bytes, v)
}
func Read(icr IConfigReader, path string) (Config, error) {
var config Config
file, err := icr.Open(path)
if err != nil {
slog.Error(err.Error())
return config, err
}
defer file.Close()
bytes, err := icr.ReadAll(file)
if err != nil {
slog.Error(err.Error())
return config, err
}
if err := icr.Unmarshal(bytes, &config); err != nil {
slog.Error(err.Error())
return config, err
}
if config.SubstitutionPrefix == "" {
slog.Info("no substitution prefix defined, using default: " + DefaultSubstitutionPrefix)
config.SubstitutionPrefix = DefaultSubstitutionPrefix
}
if config.AlertNameKey == "" {
slog.Info("no alertname key defined, using default: " + DefaultAlertNameKey)
config.AlertNameKey = DefaultAlertNameKey
}
return config, nil
}
func IsValid(config Config) (result bool) {
if config.Version != "v1" {
slog.Error("wrong config version: " + config.Version)
return false
}
if len(config.Actions) == 0 {
slog.Error("no actions defined")
return false
}
for _, action := range config.Actions {
if action.Name == "" {
slog.Error("empty name in action: " + fmt.Sprint(action))
return false
}
if action.Alertname == "" {
slog.Error("empty alertname in action: " + fmt.Sprint(action))
return false
}
if action.Command == "" {
slog.Error("empty command in action: " + fmt.Sprint(action))
return false
}
}
for i, action := range config.Actions {
for j, action2 := range config.Actions {
if i != j && action.Alertname == action2.Alertname {
slog.Error("multiple actions are not allowed for the same alertname: " + fmt.Sprint(action) + " and " + fmt.Sprint(action2))
return false
}
}
}
slog.Debug("config: " + fmt.Sprintf("%+v", config))
return true
}
package healthz
import (
"log/slog"
"net/http"
)
func ServeHTTP(w http.ResponseWriter, r *http.Request) {
slog.Debug("/healthz request")
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}
package logging
import (
"errors"
"io"
"log/slog"
"strings"
)
// Init initializes logger with log level and writer
// log_level: debug | info | warn | error
// writer has to be provided, use os.Stdout | buffer | file
func Init(log_level string, writer io.Writer) (e error) {
e = nil
leveler := new(slog.LevelVar)
h := slog.NewJSONHandler(writer, &slog.HandlerOptions{Level: leveler})
slog.SetDefault(slog.New(h))
switch log_level {
case "debug":
leveler.Set(slog.LevelDebug)
case "info":
leveler.Set(slog.LevelInfo)
case "warn":
leveler.Set(slog.LevelWarn)
case "error":
leveler.Set(slog.LevelError)
default:
slog.Error("wrong value in --log-level=" + log_level)
e = errors.New("wrong value in --log-level=" + log_level)
}
slog.Info("--log-level=" + strings.ToLower(leveler.Level().String()))
return
}
package notification
import (
"encoding/json"
"fmt"
"log/slog"
)
type AlertExternal struct {
Status string
Labels map[string]string
}
type NotificationExternal struct {
Alerts []AlertExternal
}
func ReadAlertmanagerNotification(bytes []byte) (NotificationExternal, error) {
slog.Debug("incomming bytes: " + fmt.Sprintf("%+v", string(bytes)))
var ne NotificationExternal
err := json.Unmarshal(bytes, &ne)
if err != nil {
slog.Error("cannot unmarshal incomming bytes: " + fmt.Sprintf("%+v", string(bytes)))
slog.Error(err.Error())
}
return ne, err
}
package notification
import (
"actioneer/internal/state"
"fmt"
"log/slog"
)
type Alert struct {
Status string
Name string
Labels map[string]string
}
type Notification struct {
Alerts []Alert
}
func ToInternal(ne NotificationExternal, state state.State) Notification {
slog.Debug("Converting external notification to internal")
n := Notification{}
for _, ae := range ne.Alerts {
alertName, ok := getAlertName(ae, state.AlertNameKey)
if !ok {
slog.Warn("no alert name label=[" + state.AlertNameKey + "], skipping=[" + fmt.Sprintf("%+v", ae)+"]")
continue
}
n.Alerts = append(n.Alerts, Alert{
Status: ae.Status,
Name: alertName,
Labels: ae.Labels,
})
n.Alerts[len(n.Alerts)-1].Labels["status"] = ae.Status
}
return n
}
func getAlertName(alert AlertExternal, alertNameKey string) (string, bool) {
slog.Debug("Getting alert name from external alert")
alertName := ""
ok := false
if alertName, ok = alert.Labels[alertNameKey]; !ok {
slog.Debug("no alert name label=[" + alertNameKey + "], skipping=[" + fmt.Sprintf("%+v", alert)+"]")
}
return alertName, ok
}
package processor
import (
"actioneer/internal/command"
"actioneer/internal/notification"
"actioneer/internal/state"
"fmt"
"log/slog"
"strings"
)
func CheckActionNeeded(state state.State, alert notification.Alert) bool {
_, found := state.GetActionByAlertName(alert.Name)
if found && (alert.Status == "firing") {
slog.Debug("action found for alert=[" + fmt.Sprint(alert.Name)+"]")
return true
}
slog.Debug("actions not found for alert=[" + fmt.Sprint(alert.Name)+"]")
return false
}
func CheckTemplateLabelsPresent(action state.Action, realLabelValues map[string]string) error {
for _, templateKey := range action.TemplateKeys {
if _, ok := realLabelValues[templateKey]; !ok {
errString := "no label '" + templateKey + "' were present on the alert, action=[" + fmt.Sprintf("%+v", action.Name) + "] cannot be taken for alert=[" + fmt.Sprintf("%+v", action.Alertname)+"]"
slog.Error(errString)
err := fmt.Errorf(errString)
return err
}
}
return nil
}
func ExtractRealLabelValues(alert notification.Alert) (map[string]string) {
realLabelValues := make(map[string]string)
for k, v := range alert.Labels {
realLabelValues[k] = v
}
return realLabelValues
}
func CompileCommandTemplate(action state.Action, realLabelValues map[string]string, substitutionPrefix string) string {
commandReady := action.CommandTemplate
for k, v := range realLabelValues {
commandReady = strings.ReplaceAll(commandReady, substitutionPrefix+k, v)
}
return commandReady
}
func TakeActions(shell command.ICommandRunner, state state.State, notification notification.Notification, isDryRun bool) error {
slog.Debug("incomming notification=[" + fmt.Sprint(notification)+"]")
if len(notification.Alerts) == 0 {
slog.Error("no alerts in notification=[" + fmt.Sprint(notification)+"]")
return nil
}
for _, alert := range notification.Alerts {
if !CheckActionNeeded(state, alert) {
continue
}
action, _ := state.GetActionByAlertName(alert.Name)
slog.Debug("command template=[" + fmt.Sprint(action.CommandTemplate)+"]")
realLabelValues := ExtractRealLabelValues(alert)
slog.Debug("found lables on the real alert=[" + fmt.Sprint(realLabelValues)+"]")
err := CheckTemplateLabelsPresent(action, realLabelValues)
if err != nil {
slog.Error(err.Error())
return err
}
commandReady := CompileCommandTemplate(action, realLabelValues, state.SubstitutionPrefix)
command.Execute(shell, commandReady, isDryRun)
}
return nil
}
package state
import (
"actioneer/internal/config"
"strings"
)
type Action struct {
Name string
Alertname string
CommandTemplate string
TemplateKeys []string
}
type State struct {
SubstitutionPrefix string
AlertNameKey string
Actions []Action
}
func InitTemplateKeys(template string, substitutionPrefix string) (templateKeys []string) {
for _, commandToken := range strings.Split(template, " ") {
if strings.HasPrefix(commandToken, substitutionPrefix) {
templateKeys = append(templateKeys, strings.TrimPrefix(commandToken, substitutionPrefix))
}
}
return
}
func InitState(config config.Config) (state State) {
state.SubstitutionPrefix = config.SubstitutionPrefix
state.AlertNameKey = config.AlertNameKey
for _, action := range config.Actions {
state.Actions = append(state.Actions, Action{
Name: action.Name,
Alertname: action.Alertname,
CommandTemplate: action.Command,
TemplateKeys: InitTemplateKeys(action.Command, config.SubstitutionPrefix),
})
}
return
}
func (s State) GetActionByAlertName(alertname string) (action Action, found bool) {
for _, action := range s.Actions {
if action.Alertname == alertname {
return action, true
}
}
return action, false
}
package testing_helper
import (
"actioneer/internal/state"
"strings"
)
type Dict map[string]string
type Itesting interface {
Error(args ...interface{})
Errorf(format string, args ...interface{})
}
func GetState(options map[string]string) state.State {
alertNameKey := "alertname"
if options["alertname"] != "" {
alertNameKey = options["alertname"]
}
return state.State{
AlertNameKey: alertNameKey,
}
}
func AssertNil(t Itesting, err error) {
if err != nil {
t.Error("expected no error, got: " + err.Error())
}
}
func AssertNotNil(t Itesting, err error) {
if err == nil {
t.Error("expected error, got nil")
}
}
func AssertEqual(t Itesting, expected, actual interface{}) {
if expected != actual {
t.Errorf("expected %+v, got %+v", expected, actual)
}
}
func AssertStringContains(t Itesting, expected, actual string) {
if !strings.Contains(actual, expected) {
t.Errorf("expected string to contain '%s', got '%s'", expected, actual)
}
}