Skip to content

Commit

Permalink
feat: initial support for healthchecks.io notifications (#480)
Browse files Browse the repository at this point in the history
  • Loading branch information
inode64 authored Sep 24, 2024
1 parent 7e65f1a commit f6ee51f
Show file tree
Hide file tree
Showing 14 changed files with 410 additions and 104 deletions.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Backrest is a web-accessible backup solution built on top of [restic](https://re

By building on restic, Backrest leverages restic's mature feature set. Restic provides fast, reliable, and secure backup operations.

Backrest itself is built in Golang (matching restic's implementation) and is shipped as a self-contained and light weight binary with no dependecies other than restic. This project aims to be the easiest way to setup and get started with backups on any system. You can expect to be able to perform all operations from the web interface but should you ever need more control, you are free to browse your repo and perform operations using the [restic cli](https://restic.readthedocs.io/en/latest/manual_rest.html). Additionally, Backrest can safely detect and import your existing snapshots (or externally created snapshots on an ongoing basis).
Backrest itself is built in Golang (matching restic's implementation) and is shipped as a self-contained and light weight binary with no dependencies other than restic. This project aims to be the easiest way to setup and get started with backups on any system. You can expect to be able to perform all operations from the web interface but should you ever need more control, you are free to browse your repo and perform operations using the [restic cli](https://restic.readthedocs.io/en/latest/manual_rest.html). Additionally, Backrest can safely detect and import your existing snapshots (or externally created snapshots on an ongoing basis).

**Preview**

Expand All @@ -37,8 +37,8 @@ Backrest itself is built in Golang (matching restic's implementation) and is shi
- Multi-platform support (Linux, macOS, Windows, FreeBSD, [Docker](https://hub.docker.com/r/garethgeorge/backrest))
- Import your existing restic repositories
- Cron scheduled backups and health operations (e.g. prune, check, forget)
- UI for browing and restoring files from snapshots
- Configurable backup notifications (e.g. Discord, Slack, Shoutrrr, Gotify)
- UI for browsing and restoring files from snapshots
- Configurable backup notifications (e.g. Discord, Slack, Shoutrrr, Gotify, Healthchecks)
- Add shell command hooks to run before and after backup operations.
- Compatible with rclone remotes
- Backup to any restic supported storage (e.g. S3, B2, Azure, GCS, local, SFTP, and all [rclone remotes](https://rclone.org/))
Expand Down
282 changes: 188 additions & 94 deletions gen/go/v1/config.pb.go

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion internal/hook/hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ func newOneoffRunHookTask(title, instanceID, repoID, planID string, parentOp *v1
clone.FieldByName("Event").Set(reflect.ValueOf(event))
}

if err := h.Execute(ctx, hook, clone, taskRunner); err != nil {
if err := h.Execute(ctx, hook, clone, taskRunner, event); err != nil {
err = applyHookErrorPolicy(hook.OnError, err)
return err
}
Expand Down
2 changes: 1 addition & 1 deletion internal/hook/types/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ func (commandHandler) Name() string {
return "command"
}

func (commandHandler) Execute(ctx context.Context, h *v1.Hook, vars interface{}, runner tasks.TaskRunner) error {
func (commandHandler) Execute(ctx context.Context, h *v1.Hook, vars interface{}, runner tasks.TaskRunner, event v1.Hook_Condition) error {
command, err := hookutil.RenderTemplate(h.GetActionCommand().GetCommand(), vars)
if err != nil {
return fmt.Errorf("template rendering: %w", err)
Expand Down
2 changes: 1 addition & 1 deletion internal/hook/types/discord.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ func (discordHandler) Name() string {
return "discord"
}

func (discordHandler) Execute(ctx context.Context, h *v1.Hook, vars interface{}, runner tasks.TaskRunner) error {
func (discordHandler) Execute(ctx context.Context, h *v1.Hook, vars interface{}, runner tasks.TaskRunner, event v1.Hook_Condition) error {
payload, err := hookutil.RenderTemplateOrDefault(h.GetActionDiscord().GetTemplate(), hookutil.DefaultTemplate, vars)
if err != nil {
return fmt.Errorf("template rendering: %w", err)
Expand Down
2 changes: 1 addition & 1 deletion internal/hook/types/gotify.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ func (gotifyHandler) Name() string {
return "gotify"
}

func (gotifyHandler) Execute(ctx context.Context, h *v1.Hook, vars interface{}, runner tasks.TaskRunner) error {
func (gotifyHandler) Execute(ctx context.Context, h *v1.Hook, vars interface{}, runner tasks.TaskRunner, event v1.Hook_Condition) error {
g := h.GetActionGotify()

payload, err := hookutil.RenderTemplateOrDefault(g.GetTemplate(), hookutil.DefaultTemplate, vars)
Expand Down
75 changes: 75 additions & 0 deletions internal/hook/types/healthchecks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package types

import (
"bytes"
"context"
"encoding/json"
"fmt"
"reflect"

v1 "github.com/garethgeorge/backrest/gen/go/v1"
"github.com/garethgeorge/backrest/internal/hook/hookutil"
"github.com/garethgeorge/backrest/internal/orchestrator/tasks"
"github.com/garethgeorge/backrest/internal/protoutil"
"go.uber.org/zap"
)

type healthchecksHandler struct{}

func (healthchecksHandler) Name() string {
return "healthchecks"
}

func (healthchecksHandler) Execute(ctx context.Context, cmd *v1.Hook, vars interface{}, runner tasks.TaskRunner, event v1.Hook_Condition) error {
payload, err := hookutil.RenderTemplateOrDefault(cmd.GetActionHealthchecks().GetTemplate(), hookutil.DefaultTemplate, vars)
if err != nil {
return fmt.Errorf("template rendering: %w", err)
}

l := runner.Logger(ctx)
l.Sugar().Infof("Sending healthchecks message to %s", cmd.GetActionHealthchecks().GetWebhookUrl())
l.Debug("Sending healthchecks message", zap.String("payload", payload))

PingUrl := cmd.GetActionHealthchecks().GetWebhookUrl()

// Send a "start" signal to healthchecks.io when the hook is starting.
if protoutil.IsStartCondition(event) {
PingUrl += "/start"
}

// Send a "fail" signal to healthchecks.io when the hook is failing.
if protoutil.IsErrorCondition(event) {
PingUrl += "/fail"
}

// Send a "log" signal to healthchecks.io when the hook is ending.
if protoutil.IsLogCondition(event) {
PingUrl += "/log"
}

type Message struct {
Text string `json:"text"`
}

request := Message{
Text: payload,
}

requestBytes, _ := json.Marshal(request)

body, err := hookutil.PostRequest(PingUrl, "application/json", bytes.NewReader(requestBytes))
if err != nil {
return fmt.Errorf("sending healthchecks message to %q: %w", PingUrl, err)
}

l.Debug("Healthchecks response", zap.String("body", body))
return nil
}

func (healthchecksHandler) ActionType() reflect.Type {
return reflect.TypeOf(&v1.Hook_ActionHealthchecks{})
}

func init() {
DefaultRegistry().RegisterHandler(&healthchecksHandler{})
}
2 changes: 1 addition & 1 deletion internal/hook/types/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,6 @@ func (r *HandlerRegistry) GetHandler(hook *v1.Hook) (Handler, error) {

type Handler interface {
Name() string
Execute(ctx context.Context, hook *v1.Hook, vars interface{}, runner tasks.TaskRunner) error
Execute(ctx context.Context, hook *v1.Hook, vars interface{}, runner tasks.TaskRunner, event v1.Hook_Condition) error
ActionType() reflect.Type
}
2 changes: 1 addition & 1 deletion internal/hook/types/shoutrrr.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ func (shoutrrrHandler) Name() string {
return "shoutrrr"
}

func (shoutrrrHandler) Execute(ctx context.Context, h *v1.Hook, vars interface{}, runner tasks.TaskRunner) error {
func (shoutrrrHandler) Execute(ctx context.Context, h *v1.Hook, vars interface{}, runner tasks.TaskRunner, event v1.Hook_Condition) error {
payload, err := hookutil.RenderTemplateOrDefault(h.GetActionShoutrrr().GetTemplate(), hookutil.DefaultTemplate, vars)
if err != nil {
return fmt.Errorf("template rendering: %w", err)
Expand Down
2 changes: 1 addition & 1 deletion internal/hook/types/slack.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ func (slackHandler) Name() string {
return "slack"
}

func (slackHandler) Execute(ctx context.Context, cmd *v1.Hook, vars interface{}, runner tasks.TaskRunner) error {
func (slackHandler) Execute(ctx context.Context, cmd *v1.Hook, vars interface{}, runner tasks.TaskRunner, event v1.Hook_Condition) error {
payload, err := hookutil.RenderTemplateOrDefault(cmd.GetActionSlack().GetTemplate(), hookutil.DefaultTemplate, vars)
if err != nil {
return fmt.Errorf("template rendering: %w", err)
Expand Down
49 changes: 49 additions & 0 deletions internal/protoutil/conditions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package protoutil

import (
v1 "github.com/garethgeorge/backrest/gen/go/v1"
)

var startConditionsMap = map[v1.Hook_Condition]bool{
v1.Hook_CONDITION_CHECK_START: true,
v1.Hook_CONDITION_PRUNE_START: true,
v1.Hook_CONDITION_SNAPSHOT_START: true,
}

var errorConditionsMap = map[v1.Hook_Condition]bool{
v1.Hook_CONDITION_ANY_ERROR: true,
v1.Hook_CONDITION_CHECK_ERROR: true,
v1.Hook_CONDITION_PRUNE_ERROR: true,
v1.Hook_CONDITION_SNAPSHOT_ERROR: true,
v1.Hook_CONDITION_UNKNOWN: true,
}

var logConditionsMap = map[v1.Hook_Condition]bool{
v1.Hook_CONDITION_SNAPSHOT_END: true,
}

var successConditionsMap = map[v1.Hook_Condition]bool{
v1.Hook_CONDITION_CHECK_SUCCESS: true,
v1.Hook_CONDITION_PRUNE_SUCCESS: true,
v1.Hook_CONDITION_SNAPSHOT_SUCCESS: true,
}

// IsErrorCondition returns true if the event is an error condition.
func IsErrorCondition(event v1.Hook_Condition) bool {
return errorConditionsMap[event]
}

// IsLogCondition returns true if the event is a log condition.
func IsLogCondition(event v1.Hook_Condition) bool {
return logConditionsMap[event]
}

// IsStartCondition returns true if the event is a start condition.
func IsStartCondition(event v1.Hook_Condition) bool {
return startConditionsMap[event]
}

// IsSuccessCondition returns true if the event is a success condition.
func IsSuccessCondition(event v1.Hook_Condition) bool {
return successConditionsMap[event]
}
6 changes: 6 additions & 0 deletions proto/v1/config.proto
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ message Hook {
Gotify action_gotify = 103 [json_name="actionGotify"];
Slack action_slack = 104 [json_name="actionSlack"];
Shoutrrr action_shoutrrr = 105 [json_name="actionShoutrrr"];
Healthchecks action_healthchecks = 106 [json_name="actionHealthchecks"];
}

message Command {
Expand Down Expand Up @@ -201,6 +202,11 @@ message Hook {
string shoutrrr_url = 1 [json_name="shoutrrrUrl"];
string template = 2 [json_name="template"];
}

message Healthchecks {
string webhook_url = 1 [json_name="webhookUrl"];
string template = 2 [json_name="template"];
}
}

message Auth {
Expand Down
50 changes: 50 additions & 0 deletions webui/gen/ts/v1/config_pb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -891,6 +891,12 @@ export class Hook extends Message<Hook> {
*/
value: Hook_Shoutrrr;
case: "actionShoutrrr";
} | {
/**
* @generated from field: v1.Hook.Healthchecks action_healthchecks = 106;
*/
value: Hook_Healthchecks;
case: "actionHealthchecks";
} | { case: undefined; value?: undefined } = { case: undefined };

constructor(data?: PartialMessage<Hook>) {
Expand All @@ -909,6 +915,7 @@ export class Hook extends Message<Hook> {
{ no: 103, name: "action_gotify", kind: "message", T: Hook_Gotify, oneof: "action" },
{ no: 104, name: "action_slack", kind: "message", T: Hook_Slack, oneof: "action" },
{ no: 105, name: "action_shoutrrr", kind: "message", T: Hook_Shoutrrr, oneof: "action" },
{ no: 106, name: "action_healthchecks", kind: "message", T: Hook_Healthchecks, oneof: "action" },
]);

static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): Hook {
Expand Down Expand Up @@ -1400,6 +1407,49 @@ export class Hook_Shoutrrr extends Message<Hook_Shoutrrr> {
}
}

/**
* @generated from message v1.Hook.Healthchecks
*/
export class Hook_Healthchecks extends Message<Hook_Healthchecks> {
/**
* @generated from field: string webhook_url = 1;
*/
webhookUrl = "";

/**
* @generated from field: string template = 2;
*/
template = "";

constructor(data?: PartialMessage<Hook_Healthchecks>) {
super();
proto3.util.initPartial(data, this);
}

static readonly runtime: typeof proto3 = proto3;
static readonly typeName = "v1.Hook.Healthchecks";
static readonly fields: FieldList = proto3.util.newFieldList(() => [
{ no: 1, name: "webhook_url", kind: "scalar", T: 9 /* ScalarType.STRING */ },
{ no: 2, name: "template", kind: "scalar", T: 9 /* ScalarType.STRING */ },
]);

static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): Hook_Healthchecks {
return new Hook_Healthchecks().fromBinary(bytes, options);
}

static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): Hook_Healthchecks {
return new Hook_Healthchecks().fromJson(jsonValue, options);
}

static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): Hook_Healthchecks {
return new Hook_Healthchecks().fromJsonString(jsonString, options);
}

static equals(a: Hook_Healthchecks | PlainMessage<Hook_Healthchecks> | undefined, b: Hook_Healthchecks | PlainMessage<Hook_Healthchecks> | undefined): boolean {
return proto3.util.equals(Hook_Healthchecks, a, b);
}
}

/**
* @generated from message v1.Auth
*/
Expand Down
32 changes: 32 additions & 0 deletions webui/src/components/HooksFormList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export interface HookFields {
actionWebhook?: any;
actionSlack?: any;
actionShoutrrr?: any;
actionHealthchecks?: any;
}

export const hooksListTooltipText = (
Expand Down Expand Up @@ -353,6 +354,37 @@ const hookTypes: {
);
},
},
{
name: "Healthchecks",
template: {
actionHealthchecks: {
webhookUrl: "",
template: "{{ .Summary }}",
},
conditions: [],
},
oneofKey: "actionHealthchecks",
component: ({ field }: { field: FormListFieldData }) => {
return (
<>
<Form.Item
name={[field.name, "actionHealthchecks", "webhookUrl"]}
rules={[requiredField("Ping URL is required"), { type: "url" }]}
>
<Input
addonBefore={<div style={{ width: "8em" }}>Ping URL</div>}
/>
</Form.Item>
Text Template:
<Form.Item name={[field.name, "actionHealthchecks", "template"]}>
<Input.TextArea
style={{ width: "100%", fontFamily: "monospace" }}
/>
</Form.Item>
</>
);
},
},
];

const findHookTypeName = (field: HookFields): string => {
Expand Down

0 comments on commit f6ee51f

Please sign in to comment.