Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: initial support for healthchecks.io #480

Merged
merged 2 commits into from
Sep 24, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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: 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
80 changes: 80 additions & 0 deletions internal/hook/types/healthchecks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
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"
"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 event == v1.Hook_CONDITION_CHECK_START ||
event == v1.Hook_CONDITION_PRUNE_START ||
event == v1.Hook_CONDITION_SNAPSHOT_START {
PingUrl += "/start"
}

// Send a "fail" signal to healthchecks.io when the hook is failing.
if event == v1.Hook_CONDITION_UNKNOWN ||
event == v1.Hook_CONDITION_ANY_ERROR ||
event == v1.Hook_CONDITION_CHECK_ERROR ||
event == v1.Hook_CONDITION_PRUNE_ERROR ||
event == v1.Hook_CONDITION_SNAPSHOT_ERROR {
Copy link
Owner

@garethgeorge garethgeorge Sep 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

perhaps my only nit is it may be worth declaring some utilities or constants for this e.g. isStartCondition, isErrorCondition, isSuccessCondition so that we can be consistent about this going forward.

These blocks probably also should have handling for CONDITION_SNAPSHOT_SUCCESS, CONDITION_PRUNE_SUCCESS, and CONDITION_CHECK_SUCCESS

This is something that could be added to protoutil e.g. protoutil/conditions.go . It would be good to think about how to add a test that asserts that the functions are updated whenever the enum definition changes, but that's also something I can followup with :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I already thought about it,
I can create an array with the constants and the function or, a function only using switch like this example

func isStartCondition(event v1.Hook_Condition) bool {
	switch event {
	case v1.Hook_CONDITION_SNAPSHOT_START,
		v1.Hook_CONDITION_PRUNE_START,
		v1.Hook_CONDITION_CHECK_START:
		return true
	default:
		return false
	}
}

What do you see better?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a couple of arrays and slices.Contains as the check would be a good way to define this.

The test I'm thinking of would look somewhat similar to https://github.com/garethgeorge/backrest/blob/main/internal/hook/hook_test.go#L10-L16 e.g. just assert that each enum value exists in at least one of the declared arrays categorizing them.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've been looking for a more elegant solution, see what you think.

Copy link
Owner

@garethgeorge garethgeorge Sep 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dictionary approach looks good to me! Great work & thanks for adding this hook. I think it's a very intuitive behavior.

PingUrl += "/fail"
}

// Send a "log" signal to healthchecks.io when the hook is ending.
if event == v1.Hook_CONDITION_SNAPSHOT_END {
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
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
Loading