Implement Slack and Discord alerting
This commit is contained in:
parent
057096853e
commit
81ff97bc8d
|
|
@ -21,7 +21,6 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
|
|
@ -29,6 +28,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/go-logr/logr"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
|
|
@ -36,6 +36,7 @@ import (
|
|||
"sigs.k8s.io/controller-runtime/pkg/controller"
|
||||
|
||||
kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1alpha1"
|
||||
"github.com/fluxcd/kustomize-controller/internal/alert"
|
||||
"github.com/fluxcd/kustomize-controller/internal/lockedfile"
|
||||
sourcev1 "github.com/fluxcd/source-controller/api/v1alpha1"
|
||||
)
|
||||
|
|
@ -118,7 +119,9 @@ func (r *KustomizationReconciler) Reconcile(req ctrl.Request) (ctrl.Result, erro
|
|||
// we can't rely on exponential backoff because it will prolong the execution too much,
|
||||
// instead we requeue every half a minute.
|
||||
requeueAfter := 30 * time.Second
|
||||
log.Error(err, "Dependencies do not meet ready condition, retrying in "+requeueAfter.String())
|
||||
msg := fmt.Sprintf("Dependencies do not meet ready condition, retrying in %s", requeueAfter.String())
|
||||
log.Error(err, msg)
|
||||
r.alert(kustomization, msg, "info")
|
||||
return ctrl.Result{RequeueAfter: requeueAfter}, nil
|
||||
}
|
||||
log.Info("All dependencies area ready, proceeding with apply")
|
||||
|
|
@ -128,6 +131,7 @@ func (r *KustomizationReconciler) Reconcile(req ctrl.Request) (ctrl.Result, erro
|
|||
syncedKustomization, err := r.sync(*kustomization.DeepCopy(), source)
|
||||
if err != nil {
|
||||
log.Error(err, "Kustomization apply failed")
|
||||
r.alert(kustomization, err.Error(), "error")
|
||||
}
|
||||
|
||||
// update status
|
||||
|
|
@ -292,7 +296,7 @@ func (r *KustomizationReconciler) validate(kustomization kustomizev1.Kustomizati
|
|||
output, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return err
|
||||
return fmt.Errorf("validation timeout: %w", err)
|
||||
}
|
||||
return fmt.Errorf("validation failed: %s", string(output))
|
||||
}
|
||||
|
|
@ -314,19 +318,31 @@ func (r *KustomizationReconciler) apply(kustomization kustomizev1.Kustomization,
|
|||
output, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return err
|
||||
return fmt.Errorf("apply timeout: %w", err)
|
||||
}
|
||||
return fmt.Errorf("apply failed: %s", string(output))
|
||||
}
|
||||
|
||||
resources := r.parseApplyOutput(output)
|
||||
r.Log.WithValues(
|
||||
strings.ToLower(kustomization.Kind),
|
||||
fmt.Sprintf("%s/%s", kustomization.GetNamespace(), kustomization.GetName()),
|
||||
).Info(
|
||||
fmt.Sprintf("Kustomization applied in %s",
|
||||
time.Now().Sub(start).String()),
|
||||
"output", r.parseApplyOutput(output),
|
||||
"output", resources,
|
||||
)
|
||||
|
||||
var diff bool
|
||||
for _, action := range resources {
|
||||
if action != "unchanged" {
|
||||
diff = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if diff {
|
||||
r.alert(kustomization, string(output), "info")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -334,6 +350,7 @@ func (r *KustomizationReconciler) checkHealth(kustomization kustomizev1.Kustomiz
|
|||
timeout := kustomization.GetTimeout() + (time.Second * 1)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
var alerts string
|
||||
|
||||
for _, check := range kustomization.Spec.HealthChecks {
|
||||
cmd := fmt.Sprintf("kubectl -n %s rollout status %s %s --timeout=%s",
|
||||
|
|
@ -342,18 +359,25 @@ func (r *KustomizationReconciler) checkHealth(kustomization kustomizev1.Kustomiz
|
|||
output, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return err
|
||||
return fmt.Errorf("health check timeout for %s '%s/%s': %w",
|
||||
check.Kind, check.Namespace, check.Name, err)
|
||||
}
|
||||
return fmt.Errorf("health check failed for %s '%s/%s': %s",
|
||||
check.Kind, check.Namespace, check.Name, string(output))
|
||||
} else {
|
||||
msg := fmt.Sprintf("Health check passed for %s '%s/%s'",
|
||||
check.Kind, check.Namespace, check.Name)
|
||||
r.Log.WithValues(
|
||||
strings.ToLower(kustomization.Kind),
|
||||
fmt.Sprintf("%s/%s", kustomization.GetNamespace(), kustomization.GetName()),
|
||||
).Info(fmt.Sprintf("Health check passed for %s '%s/%s'",
|
||||
check.Kind, check.Namespace, check.Name))
|
||||
).Info(msg)
|
||||
alerts += msg + "\n"
|
||||
}
|
||||
}
|
||||
|
||||
if alerts != "" {
|
||||
r.alert(kustomization, alerts, "info")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -409,3 +433,57 @@ func (r *KustomizationReconciler) checkDependencies(kustomization kustomizev1.Ku
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *KustomizationReconciler) getProfiles(kustomization kustomizev1.Kustomization) ([]kustomizev1.Profile, error) {
|
||||
list := make([]kustomizev1.Profile, 0)
|
||||
var profiles kustomizev1.ProfileList
|
||||
err := r.List(context.TODO(), &profiles, client.InNamespace(kustomization.GetNamespace()))
|
||||
if err != nil {
|
||||
return list, err
|
||||
}
|
||||
|
||||
// filter profiles that match this kustomization taking into account '*' wildcard
|
||||
for _, profile := range profiles.Items {
|
||||
for _, name := range profile.Spec.Kustomizations {
|
||||
if name == kustomization.GetName() || name == "*" {
|
||||
list = append(list, profile)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (r *KustomizationReconciler) alert(kustomization kustomizev1.Kustomization, msg string, verbosity string) {
|
||||
profiles, err := r.getProfiles(kustomization)
|
||||
if err != nil {
|
||||
r.Log.WithValues(
|
||||
strings.ToLower(kustomization.Kind),
|
||||
fmt.Sprintf("%s/%s", kustomization.GetNamespace(), kustomization.GetName()),
|
||||
).Error(err, "unable to list profiles")
|
||||
return
|
||||
}
|
||||
|
||||
for _, profile := range profiles {
|
||||
if settings := profile.Spec.Alert; settings != nil {
|
||||
provider, err := alert.NewProvider(settings.Type, settings.Address, settings.Username, settings.Channel)
|
||||
if err != nil {
|
||||
r.Log.WithValues(
|
||||
strings.ToLower(kustomization.Kind),
|
||||
fmt.Sprintf("%s/%s", kustomization.GetNamespace(), kustomization.GetName()),
|
||||
).Error(err, "unable to configure alert provider")
|
||||
continue
|
||||
}
|
||||
if settings.Verbosity == verbosity || verbosity == "error" {
|
||||
err = provider.Post(kustomization.GetName(), kustomization.GetNamespace(), msg, verbosity)
|
||||
if err != nil {
|
||||
r.Log.WithValues(
|
||||
strings.ToLower(kustomization.Kind),
|
||||
fmt.Sprintf("%s/%s", kustomization.GetNamespace(), kustomization.GetName()),
|
||||
).Error(err, "unable to send alert")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,128 @@
|
|||
package alert
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Provider holds the information needed to post alerts
|
||||
type Provider struct {
|
||||
URL string
|
||||
Username string
|
||||
Channel string
|
||||
}
|
||||
|
||||
// Payload holds the channel and attachments
|
||||
type Payload struct {
|
||||
Channel string `json:"channel"`
|
||||
Username string `json:"username"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Attachments []Attachment `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
// Attachment holds the markdown message body
|
||||
type Attachment struct {
|
||||
Color string `json:"color"`
|
||||
AuthorName string `json:"author_name"`
|
||||
Text string `json:"text"`
|
||||
MrkdwnIn []string `json:"mrkdwn_in"`
|
||||
}
|
||||
|
||||
// NewProvider validates the URL and returns a provider object
|
||||
func NewProvider(providerType string, hookURL string, username string, channel string) (*Provider, error) {
|
||||
hook, err := url.ParseRequestURI(hookURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid hook URL %s", hookURL)
|
||||
}
|
||||
|
||||
if providerType == "discord" {
|
||||
// https://birdie0.github.io/discord-webhooks-guide/other/slack_formatting.html
|
||||
if !strings.HasSuffix(hookURL, "/slack") {
|
||||
hook.Path = path.Join(hook.Path, "slack")
|
||||
hookURL = hook.String()
|
||||
}
|
||||
}
|
||||
|
||||
if username == "" {
|
||||
return nil, errors.New("empty username")
|
||||
}
|
||||
|
||||
if channel == "" {
|
||||
return nil, errors.New("empty channel")
|
||||
}
|
||||
|
||||
return &Provider{
|
||||
Channel: channel,
|
||||
URL: hookURL,
|
||||
Username: username,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Post message to the provider hook URL
|
||||
func (s *Provider) Post(name string, namespace string, message string, severity string) error {
|
||||
payload := Payload{
|
||||
Channel: s.Channel,
|
||||
Username: s.Username,
|
||||
}
|
||||
|
||||
color := "good"
|
||||
if severity == "error" {
|
||||
color = "danger"
|
||||
}
|
||||
|
||||
a := Attachment{
|
||||
Color: color,
|
||||
AuthorName: fmt.Sprintf("%s/%s", namespace, name),
|
||||
Text: message,
|
||||
MrkdwnIn: []string{"text"},
|
||||
}
|
||||
|
||||
payload.Attachments = []Attachment{a}
|
||||
|
||||
err := postMessage(s.URL, payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("postMessage failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func postMessage(address string, payload interface{}) error {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshalling notification payload failed: %w", err)
|
||||
}
|
||||
|
||||
b := bytes.NewBuffer(data)
|
||||
|
||||
req, err := http.NewRequest("POST", address, b)
|
||||
if err != nil {
|
||||
return fmt.Errorf("http.NewRequest failed: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-type", "application/json")
|
||||
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
res, err := http.DefaultClient.Do(req.WithContext(ctx))
|
||||
if err != nil {
|
||||
return fmt.Errorf("sending notification failed: %w", err)
|
||||
}
|
||||
|
||||
defer res.Body.Close()
|
||||
statusCode := res.StatusCode
|
||||
if statusCode != 200 {
|
||||
body, _ := ioutil.ReadAll(res.Body)
|
||||
return fmt.Errorf("sending notification failed: %s", string(body))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Loading…
Reference in New Issue