mirror of
https://github.com/garethgeorge/backrest.git
synced 2025-11-28 18:23:22 +00:00
225 lines
6.4 KiB
Go
225 lines
6.4 KiB
Go
package hook
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"slices"
|
|
"strings"
|
|
"text/template"
|
|
"time"
|
|
|
|
v1 "github.com/garethgeorge/backrest/gen/go/v1"
|
|
"github.com/garethgeorge/backrest/internal/oplog"
|
|
"github.com/garethgeorge/backrest/internal/rotatinglog"
|
|
"go.uber.org/zap"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
var (
|
|
defaultTemplate = `{{ .Summary }}`
|
|
)
|
|
|
|
type HookExecutor struct {
|
|
config *v1.Config
|
|
oplog *oplog.OpLog
|
|
logStore *rotatinglog.RotatingLog
|
|
}
|
|
|
|
func NewHookExecutor(config *v1.Config, oplog *oplog.OpLog, bigOutputStore *rotatinglog.RotatingLog) *HookExecutor {
|
|
return &HookExecutor{
|
|
config: config,
|
|
oplog: oplog,
|
|
logStore: bigOutputStore,
|
|
}
|
|
}
|
|
|
|
// ExecuteHooks schedules tasks for the hooks subscribed to the given event. The vars map is used to substitute variables
|
|
// Hooks are pulled both from the provided plan and from the repo config.
|
|
func (e *HookExecutor) ExecuteHooks(flowID int64, repo *v1.Repo, plan *v1.Plan, events []v1.Hook_Condition, vars HookVars) error {
|
|
operationBase := v1.Operation{
|
|
Status: v1.OperationStatus_STATUS_INPROGRESS,
|
|
PlanId: plan.GetId(),
|
|
RepoId: repo.GetId(),
|
|
InstanceId: e.config.Instance,
|
|
FlowId: flowID,
|
|
}
|
|
|
|
vars.Repo = repo
|
|
vars.Plan = plan
|
|
vars.CurTime = time.Now()
|
|
|
|
for idx, hook := range repo.GetHooks() {
|
|
h := (*Hook)(hook)
|
|
event := firstMatchingCondition(h, events)
|
|
if event == v1.Hook_CONDITION_UNKNOWN {
|
|
continue
|
|
}
|
|
|
|
name := fmt.Sprintf("repo/%v/hook/%v", repo.Id, idx)
|
|
operation := proto.Clone(&operationBase).(*v1.Operation)
|
|
operation.DisplayMessage = "running " + name
|
|
operation.UnixTimeStartMs = curTimeMs()
|
|
operation.Op = &v1.Operation_OperationRunHook{
|
|
OperationRunHook: &v1.OperationRunHook{
|
|
Name: name,
|
|
Condition: event,
|
|
},
|
|
}
|
|
zap.L().Info("running hook", zap.String("plan", repo.Id), zap.Int64("opId", operation.Id), zap.String("hook", name))
|
|
if err := e.executeHook(operation, h, event, vars); err != nil {
|
|
zap.S().Errorf("error on repo hook %v on condition %v: %v", idx, event.String(), err)
|
|
if isHaltingError(err) {
|
|
return fmt.Errorf("repo hook %v on condition %v: %w", idx, event.String(), err)
|
|
}
|
|
}
|
|
}
|
|
|
|
for idx, hook := range plan.GetHooks() {
|
|
h := (*Hook)(hook)
|
|
event := firstMatchingCondition(h, events)
|
|
if event == v1.Hook_CONDITION_UNKNOWN {
|
|
continue
|
|
}
|
|
|
|
name := fmt.Sprintf("plan/%v/hook/%v", plan.Id, idx)
|
|
operation := proto.Clone(&operationBase).(*v1.Operation)
|
|
operation.DisplayMessage = "running " + name
|
|
operation.UnixTimeStartMs = curTimeMs()
|
|
operation.Op = &v1.Operation_OperationRunHook{
|
|
OperationRunHook: &v1.OperationRunHook{
|
|
Name: name,
|
|
Condition: event,
|
|
},
|
|
}
|
|
|
|
zap.L().Info("running hook", zap.String("plan", plan.Id), zap.Int64("opId", operation.Id), zap.String("hook", name))
|
|
if err := e.executeHook(operation, h, event, vars); err != nil {
|
|
zap.S().Errorf("error on plan hook %v on condition %v: %v", idx, event.String(), err)
|
|
if isHaltingError(err) {
|
|
return fmt.Errorf("plan hook %v on condition %v: %w", idx, event.String(), err)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func firstMatchingCondition(hook *Hook, events []v1.Hook_Condition) v1.Hook_Condition {
|
|
for _, event := range events {
|
|
if slices.Contains(hook.Conditions, event) {
|
|
return event
|
|
}
|
|
}
|
|
return v1.Hook_CONDITION_UNKNOWN
|
|
}
|
|
|
|
func (e *HookExecutor) executeHook(op *v1.Operation, hook *Hook, event v1.Hook_Condition, vars HookVars) error {
|
|
if err := e.oplog.Add(op); err != nil {
|
|
zap.S().Errorf("execute hook: add operation: %v", err)
|
|
return errors.New("couldn't create operation")
|
|
}
|
|
|
|
output := &bytes.Buffer{}
|
|
fmt.Fprintf(output, "triggering condition: %v\n", event.String())
|
|
|
|
var retErr error
|
|
if err := hook.Do(event, vars, io.MultiWriter(output)); err != nil {
|
|
output.Write([]byte(fmt.Sprintf("Error: %v", err)))
|
|
err = applyHookErrorPolicy(hook.OnError, err)
|
|
var cancelErr *HookErrorRequestCancel
|
|
if errors.As(err, &cancelErr) {
|
|
// if it was a cancel then it successfully indicated it's intent to the caller
|
|
// no error should be displayed in the UI.
|
|
op.Status = v1.OperationStatus_STATUS_SUCCESS
|
|
} else {
|
|
op.Status = v1.OperationStatus_STATUS_ERROR
|
|
}
|
|
retErr = err
|
|
} else {
|
|
op.Status = v1.OperationStatus_STATUS_SUCCESS
|
|
}
|
|
|
|
outputRef, err := e.logStore.Write(output.Bytes())
|
|
if err != nil {
|
|
retErr = errors.Join(retErr, fmt.Errorf("write logstore: %w", err))
|
|
}
|
|
op.Logref = outputRef
|
|
|
|
op.UnixTimeEndMs = curTimeMs()
|
|
if err := e.oplog.Update(op); err != nil {
|
|
retErr = errors.Join(retErr, fmt.Errorf("update oplog: %w", err))
|
|
}
|
|
return retErr
|
|
}
|
|
|
|
func curTimeMs() int64 {
|
|
return time.Now().UnixNano() / 1000000
|
|
}
|
|
|
|
type Hook v1.Hook
|
|
|
|
func (h *Hook) Do(event v1.Hook_Condition, vars HookVars, output io.Writer) error {
|
|
if !slices.Contains(h.Conditions, event) {
|
|
return nil
|
|
}
|
|
|
|
vars.Event = event
|
|
|
|
switch action := h.Action.(type) {
|
|
case *v1.Hook_ActionCommand:
|
|
return h.doCommand(action, vars, output)
|
|
case *v1.Hook_ActionDiscord:
|
|
return h.doDiscord(action, vars, output)
|
|
case *v1.Hook_ActionGotify:
|
|
return h.doGotify(action, vars, output)
|
|
case *v1.Hook_ActionSlack:
|
|
return h.doSlack(action, vars, output)
|
|
case *v1.Hook_ActionShoutrrr:
|
|
return h.doShoutrrr(action, vars, output)
|
|
default:
|
|
return fmt.Errorf("unknown hook action: %v", action)
|
|
}
|
|
}
|
|
|
|
func (h *Hook) renderTemplate(text string, vars HookVars) (string, error) {
|
|
template, err := template.New("template").Parse(text)
|
|
if err != nil {
|
|
return "", fmt.Errorf("parse template: %w", err)
|
|
}
|
|
|
|
buf := &bytes.Buffer{}
|
|
if err := template.Execute(buf, vars); err != nil {
|
|
return "", fmt.Errorf("execute template: %w", err)
|
|
}
|
|
|
|
return buf.String(), nil
|
|
}
|
|
|
|
func (h *Hook) renderTemplateOrDefault(template string, defaultTmpl string, vars HookVars) (string, error) {
|
|
if strings.Trim(template, " ") == "" {
|
|
return h.renderTemplate(defaultTmpl, vars)
|
|
}
|
|
return h.renderTemplate(template, vars)
|
|
}
|
|
|
|
func applyHookErrorPolicy(onError v1.Hook_OnError, err error) error {
|
|
if err == nil || errors.As(err, &HookErrorFatal{}) || errors.As(err, &HookErrorRequestCancel{}) {
|
|
return err
|
|
}
|
|
|
|
if onError == v1.Hook_ON_ERROR_CANCEL {
|
|
return &HookErrorRequestCancel{Err: err}
|
|
} else if onError == v1.Hook_ON_ERROR_FATAL {
|
|
return &HookErrorFatal{Err: err}
|
|
}
|
|
return err
|
|
}
|
|
|
|
// isHaltingError returns true if the error is a fatal error or a request to cancel the operation
|
|
func isHaltingError(err error) bool {
|
|
var fatalErr *HookErrorFatal
|
|
var cancelErr *HookErrorRequestCancel
|
|
return errors.As(err, &fatalErr) || errors.As(err, &cancelErr)
|
|
}
|