Skip to content

Commit

Permalink
feat: add healthchecks.io service (#42)
Browse files Browse the repository at this point in the history
  • Loading branch information
rancoud authored Feb 23, 2024
1 parent 88d4b7c commit ba07768
Show file tree
Hide file tree
Showing 10 changed files with 474 additions and 3 deletions.
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,36 @@ It uses zerolog levels (from highest to lowest):
* trace (zerolog.TraceLevel, -1)

### Modules
#### Healthchecks
Uses the [Healthchecks.io](https://healthchecks.io) service to check whether `discord-bot` is online or not.
It can triggers alerts on several systems if it is down.

**If you don't want to use it, leave `uuid` empty.**

You can use your own version by using a custom `base_url`.

JSON configuration used:
```json
"healthchecks": {
"base_url": "https://hc-ping.com/",
"uuid": "00000000-0000-0000-0000-000000000000",
"started_message": "discord-bot started",
"failed_message": "discord-bot failed"
}
```

| JSON Parameter | Mandatory | Type | Default value | Description |
| --------------- | --------- | ------ | -------------------- | ----------------------------------------------------------------- |
| base_url | NO | string | https://hc-ping.com/ | url to ping, by default use the healthchecks service |
| uuid | YES | string | | uuid, on healthchecks dashboard it's after `https://hc-ping.com/` |
| started_message | NO | string | discord-bot started | message sent to healthchecks when discord-bot starts |
| failed_message | NO | string | discord-bot failed | message sent to healthchecks when discord-bot stops |

##### How it works?
Each time you start `discord-bot`, the healthchecks module will check the configuration in `config.json`.
Then, when all modules have been started, it sends a `Start` ping message to indicate that the discord-bot is up and running.
Finally, if `discord-bot` receives a signal from the OS to terminate the program, it will send a `Fail` ping message.

#### Welcome
Define the user's role when using an emoji.
You can define one or more messages in only one channel.
Expand Down
6 changes: 6 additions & 0 deletions config.template.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@
"purge_below_count_members_not_in_guild": 10
}
]
},
"healthchecks": {
"base_url": "",
"uuid": "",
"started_message": "",
"failed_message": ""
}
}
}
4 changes: 3 additions & 1 deletion configuration/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"strconv"
"strings"

"github.com/blueprintue/discord-bot/healthchecks"
"github.com/blueprintue/discord-bot/welcome"
)

Expand Down Expand Up @@ -42,7 +43,8 @@ type Log struct {
}

type Modules struct {
WelcomeConfiguration welcome.Configuration `json:"welcome"`
WelcomeConfiguration welcome.Configuration `json:"welcome"`
HealthcheckConfiguration healthchecks.Configuration `json:"healthchecks"`
}

// ReadConfiguration read `config.json` file and update values with env if found.
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.22

require (
github.com/bwmarrin/discordgo v0.27.1
github.com/crazy-max/gohealthchecks v0.4.1
github.com/ilya1st/rotatewriter v0.0.0-20171126183947-3df0c1a3ed6d
github.com/rs/zerolog v1.32.0
github.com/stretchr/testify v1.8.4
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
github.com/bwmarrin/discordgo v0.27.1 h1:ib9AIc/dom1E/fSIulrBwnez0CToJE113ZGt4HoliGY=
github.com/bwmarrin/discordgo v0.27.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/crazy-max/gohealthchecks v0.4.1 h1:gbjZzF/GxwDyP78u37B2/c2iQfq8BEjAHS3eBLM6FcQ=
github.com/crazy-max/gohealthchecks v0.4.1/go.mod h1:gkT8QSdEXZJahyswdTGDbd+q20fWm0DmWW7TWBNtgJg=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
Expand Down
133 changes: 133 additions & 0 deletions healthchecks/healthcheck.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package healthchecks

import (
"context"
"fmt"
"net/url"
"strings"

"github.com/crazy-max/gohealthchecks"
"github.com/rs/zerolog/log"
)

type Configuration struct {
BaseURL string `json:"base_url"`
UUID string `json:"uuid"`
StartedMessage string `json:"started_message"`
FailedMessage string `json:"failed_message"`
}

type Manager struct {
client *gohealthchecks.Client
baseURL *url.URL
uuid string
startedMessage string
failedMessage string
}

func NewHealthchecksManager(
config Configuration,
) *Manager {
manager := &Manager{}

log.Info().Msg("Checking configuration for Healthchecks")

if !manager.hasValidConfigurationInFile(config) {
return nil
}

return manager
}

func (m *Manager) hasValidConfigurationInFile(config Configuration) bool {
baseRawURL := config.BaseURL
if baseRawURL == "" {
log.Info().
Msg("BaseURL is empty, use default URL https://hc-ping.com/")

baseRawURL = "https://hc-ping.com/"
}

baseURL, err := url.Parse(baseRawURL)
if err != nil {
log.Error().
Err(err).
Str("base_url", baseRawURL).
Msg("BaseURL is invalid")

return false
}

if !strings.HasSuffix(baseURL.Path, "/") {
baseURL.Path += "/"
}

m.baseURL = baseURL

if config.UUID == "" {
log.Error().
Msg("UUID is empty")

return false
}

m.uuid = config.UUID

m.startedMessage = config.StartedMessage
if m.startedMessage == "" {
log.Info().
Msg(`StartedMessage is empty, use default "discord-bot started"`)

m.startedMessage = "discord-bot started"
}

m.failedMessage = config.FailedMessage
if m.failedMessage == "" {
log.Info().
Msg(`FailedMessage is empty, use default "discord-bot stopped"`)

m.failedMessage = "discord-bot stopped"
}

return true
}

func (m *Manager) Run() error {
m.client = gohealthchecks.NewClient(
&gohealthchecks.ClientOptions{
BaseURL: m.baseURL,
},
)

err := m.client.Start(
context.Background(),
gohealthchecks.PingingOptions{
UUID: m.uuid,
Logs: m.startedMessage,
},
)
if err != nil {
log.Error().
Err(err).
Msg("Could not send Start HealthChecks client")

return fmt.Errorf("%w", err)
}

return nil
}

func (m *Manager) Fail() {
err := m.client.Fail(
context.Background(),
gohealthchecks.PingingOptions{
UUID: m.uuid,
Logs: m.failedMessage,
},
)
if err != nil {
log.Error().
Err(err).
Msg("Could not send Fail HealthChecks client")
}
}
89 changes: 89 additions & 0 deletions healthchecks/healthchecks_fail_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
//nolint:paralleltest
package healthchecks_test

import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/blueprintue/discord-bot/healthchecks"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/stretchr/testify/require"
)

func TestFail(t *testing.T) {
var bufferLogs bytes.Buffer
log.Logger = zerolog.New(&bufferLogs).Level(zerolog.TraceLevel).With().Logger()

currentRequestIdx := 0

svr := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
if currentRequestIdx == 0 {
require.Equal(t, "/00000000-0000-0000-0000-000000000000/start", req.RequestURI)
startedMessage, err := io.ReadAll(req.Body)
require.NoError(t, err)
require.Equal(t, "starts", string(startedMessage))
} else {
require.Equal(t, "/00000000-0000-0000-0000-000000000000/fail", req.RequestURI)
failedMessage, err := io.ReadAll(req.Body)
require.NoError(t, err)
require.Equal(t, "stops", string(failedMessage))
}

currentRequestIdx++

res.WriteHeader(http.StatusOK)
}))
defer svr.Close()

healthchecksManager := healthchecks.NewHealthchecksManager(healthchecks.Configuration{
BaseURL: svr.URL,
UUID: "00000000-0000-0000-0000-000000000000",
StartedMessage: "starts",
FailedMessage: "stops",
})
require.NotNil(t, healthchecksManager)

err := healthchecksManager.Run()
require.NoError(t, err)

bufferLogs.Reset()

healthchecksManager.Fail()

parts := strings.Split(bufferLogs.String(), "\n")
require.Equal(t, ``, parts[0])
}

func TestFail_Errors(t *testing.T) {
var bufferLogs bytes.Buffer
log.Logger = zerolog.New(&bufferLogs).Level(zerolog.TraceLevel).With().Logger()

svr := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, _ *http.Request) {
res.WriteHeader(http.StatusInternalServerError)
}))
defer svr.Close()

healthchecksManager := healthchecks.NewHealthchecksManager(healthchecks.Configuration{
BaseURL: svr.URL,
UUID: "00000000-0000-0000-0000-000000000000",
StartedMessage: "starts",
FailedMessage: "stops",
})
require.NotNil(t, healthchecksManager)

err := healthchecksManager.Run()
require.Error(t, err)

bufferLogs.Reset()

healthchecksManager.Fail()

parts := strings.Split(bufferLogs.String(), "\n")
require.Equal(t, `{"level":"error","error":"HTTP error 500","message":"Could not send Fail HealthChecks client"}`, parts[0])
require.Equal(t, ``, parts[1])
}
74 changes: 74 additions & 0 deletions healthchecks/healthchecks_run_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//nolint:paralleltest
package healthchecks_test

import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/blueprintue/discord-bot/healthchecks"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/stretchr/testify/require"
)

func TestRun(t *testing.T) {
var bufferLogs bytes.Buffer
log.Logger = zerolog.New(&bufferLogs).Level(zerolog.TraceLevel).With().Logger()

svr := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
require.Equal(t, "/00000000-0000-0000-0000-000000000000/start", req.RequestURI)
startedMessage, err := io.ReadAll(req.Body)
require.NoError(t, err)
require.Equal(t, "starts", string(startedMessage))

res.WriteHeader(http.StatusOK)
}))
defer svr.Close()

healthchecksManager := healthchecks.NewHealthchecksManager(healthchecks.Configuration{
BaseURL: svr.URL,
UUID: "00000000-0000-0000-0000-000000000000",
StartedMessage: "starts",
FailedMessage: "stops",
})
require.NotNil(t, healthchecksManager)

bufferLogs.Reset()

err := healthchecksManager.Run()
require.NoError(t, err)

parts := strings.Split(bufferLogs.String(), "\n")
require.Equal(t, ``, parts[0])
}

func TestRun_Errors(t *testing.T) {
var bufferLogs bytes.Buffer
log.Logger = zerolog.New(&bufferLogs).Level(zerolog.TraceLevel).With().Logger()

svr := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, _ *http.Request) {
res.WriteHeader(http.StatusInternalServerError)
}))
defer svr.Close()

healthchecksManager := healthchecks.NewHealthchecksManager(healthchecks.Configuration{
BaseURL: svr.URL,
UUID: "00000000-0000-0000-0000-000000000000",
StartedMessage: "starts",
FailedMessage: "stops",
})
require.NotNil(t, healthchecksManager)

bufferLogs.Reset()

err := healthchecksManager.Run()
require.Error(t, err)

parts := strings.Split(bufferLogs.String(), "\n")
require.Equal(t, `{"level":"error","error":"HTTP error 500","message":"Could not send Start HealthChecks client"}`, parts[0])
require.Equal(t, ``, parts[1])
}
Loading

0 comments on commit ba07768

Please sign in to comment.