Bladeren bron

Add health check

pull/27/head
J.P. Neverwas 2 jaren geleden
bovenliggende
commit
966ff13db5
11 gewijzigde bestanden met toevoegingen van 313 en 2 verwijderingen
  1. 1
    0
      .gitignore
  2. 4
    0
      Dockerfile
  3. 5
    2
      examples/irccat.json
  4. 43
    0
      healthcheck/healthcheck.go
  5. 44
    0
      httplistener/health.go
  6. 88
    0
      httplistener/health_test.go
  7. 6
    0
      httplistener/httplistener.go
  8. 4
    0
      irc.go
  9. 16
    0
      main.go
  10. 53
    0
      util/timestamp.go
  11. 49
    0
      util/timestamp_test.go

+ 1
- 0
.gitignore Bestand weergeven

@@ -1,2 +1,3 @@
1 1
 irccat
2 2
 irccat.json
3
+health.timestamp

+ 4
- 0
Dockerfile Bestand weergeven

@@ -21,8 +21,12 @@ RUN CGO_ENABLED=0 go get -t -v ./... && go build -a .
21 21
 # Step two: copy over the binary and root certs
22 22
 FROM scratch
23 23
 COPY --from=build /go/bin/irccat /irccat
24
+COPY --from=build /go/bin/healthcheck /healthcheck
24 25
 COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
25 26
 
27
+# Docker-only (no OCI)
28
+HEALTHCHECK --interval=60s --timeout=10s --start-period=600s CMD ["/healthcheck"]
29
+
26 30
 EXPOSE 12345
27 31
 EXPOSE 8045
28 32
 

+ 5
- 2
examples/irccat.json Bestand weergeven

@@ -17,7 +17,8 @@
17 17
 	    "irccat": "#irccat-dev"
18 18
 	}
19 19
        }
20
-    }
20
+    },
21
+    "health_endpoint": "/healthz"
21 22
   },
22 23
   "irc": {
23 24
     "server": "irc.example.com:6697",
@@ -30,7 +31,9 @@
30 31
     "sasl_login": "",
31 32
     "sasl_pass": "",
32 33
     "channels": ["#channel"],
33
-    "keys": {"#channel": "join_key"}
34
+    "keys": {"#channel": "join_key"},
35
+    "health_file": "./health.timestamp",
36
+    "health_period": "5m"
34 37
   },
35 38
   "commands": {
36 39
     "auth_channel": "#channel",

+ 43
- 0
healthcheck/healthcheck.go Bestand weergeven

@@ -0,0 +1,43 @@
1
+// Command healthcheck reads a file containing a UNIX UTC timestamp and exits
2
+// nonzero after printing a message to stderr if the allotted time has elapsed
3
+// since a value was last recorded. Config value irc.health_period specifies
4
+// the max allotted time plus a small cushion for lag. It defaults to fifteen
5
+// minutes, which is go-ircevent's default PING frequency (as of
6
+// github.com/thoj/go-ircevent@v0.0.0-20210723090443-73e444401d64). Optional
7
+// config item irc.health_file designates the path to the timestamp file. If
8
+// unset, the program says so (to stderr) and exits zero.
9
+package main
10
+
11
+import (
12
+	"log"
13
+	"time"
14
+
15
+	"github.com/irccloud/irccat/util"
16
+	"github.com/spf13/viper"
17
+)
18
+
19
+var lagInterval = 10 * time.Second // for testing
20
+var defaultPeriod = 15 * time.Minute
21
+
22
+func main() {
23
+	viper.SetConfigName("irccat")
24
+	viper.AddConfigPath("/etc")
25
+	viper.AddConfigPath(".")
26
+	viper.SetDefault("irc.health_period", defaultPeriod)
27
+
28
+	if err := viper.ReadInConfig(); err != nil {
29
+		log.Fatalln("Error reading config file. Exiting.")
30
+	}
31
+
32
+	healthFile := viper.GetString("irc.health_file")
33
+	if healthFile == "" {
34
+		log.Println("Config option irc.health_file unset; exiting.")
35
+		return
36
+	}
37
+
38
+	freq := lagInterval + viper.GetDuration("irc.health_period")
39
+
40
+	if err := util.CheckTimestamp(healthFile, freq); err != nil {
41
+		log.Fatalln(err.Error())
42
+	}
43
+}

+ 44
- 0
httplistener/health.go Bestand weergeven

@@ -0,0 +1,44 @@
1
+package httplistener
2
+
3
+import (
4
+	"fmt"
5
+	"net/http"
6
+	"time"
7
+
8
+	"github.com/irccloud/irccat/util"
9
+	"github.com/spf13/viper"
10
+)
11
+
12
+var LagInterval = 10 * time.Second // for testing
13
+var DefaultPeriod = 15 * time.Minute
14
+
15
+// healthHandler returns non-2xx if the configured timeout has elapsed.
16
+//
17
+// This mainly exists to present an interface for supervisors relying on
18
+// liveliness/readiness probes (e.g., for Kubernetes deployments). However, a
19
+// conservative client could query it before sending a payload. See also
20
+// /healthcheck in this same pkg.
21
+func (hl *HTTPListener) healthHandler(
22
+	w http.ResponseWriter,
23
+	request *http.Request,
24
+) {
25
+	if request.Method != "GET" {
26
+		http.NotFound(w, request)
27
+		return
28
+	}
29
+	healthFile := viper.GetString("irc.health_file")
30
+	if healthFile == "" {
31
+		http.NotFound(w, request)
32
+		return
33
+	}
34
+	viper.SetDefault("irc.health_period", DefaultPeriod)
35
+
36
+	freq := LagInterval + viper.GetDuration("irc.health_period")
37
+	err := util.CheckTimestamp(healthFile, freq)
38
+	if err != nil {
39
+		log.Criticalf("%s", err)
40
+		http.Error(w, err.Error(), http.StatusInternalServerError)
41
+		return
42
+	}
43
+	fmt.Fprintln(w, "OK")
44
+}

+ 88
- 0
httplistener/health_test.go Bestand weergeven

@@ -0,0 +1,88 @@
1
+package httplistener
2
+
3
+import (
4
+	"bytes"
5
+	"context"
6
+	"fmt"
7
+	"io"
8
+	"path"
9
+	"testing"
10
+	"time"
11
+
12
+	"net/http"
13
+
14
+	"github.com/irccloud/irccat/util"
15
+	"github.com/juju/loggo"
16
+	"github.com/spf13/viper"
17
+)
18
+
19
+var origLag = LagInterval
20
+
21
+var configFmt = `---
22
+irc:
23
+  health_file: %s
24
+  health_period: 1ms
25
+http:
26
+  health_endpoint: /testing/healthz
27
+`
28
+
29
+func startServer(t *testing.T) {
30
+	hl := HTTPListener{
31
+		http: http.Server{Addr: "localhost:18045"},
32
+	}
33
+	http.HandleFunc(viper.GetString("http.health_endpoint"), hl.healthHandler)
34
+	go hl.http.ListenAndServe()
35
+	t.Cleanup(func() {hl.http.Shutdown(context.Background())})
36
+}
37
+
38
+func getOne(t *testing.T) (*http.Response, string) {
39
+	res, err := http.Get("http://localhost:18045/testing/healthz")
40
+	if err != nil {
41
+		t.Error(err)
42
+	}
43
+	got, err := io.ReadAll(res.Body)
44
+	if err != nil {
45
+		t.Error(err)
46
+	}
47
+	err = res.Body.Close()
48
+	if err != nil {
49
+		t.Error(err)
50
+	}
51
+	return res, string(got)
52
+}
53
+
54
+func TestHealthHandler(t *testing.T) {
55
+	writer, err := loggo.RemoveWriter("default")
56
+	if err != nil {
57
+		t.Error(err)
58
+	}
59
+	t.Cleanup(func() {loggo.DefaultContext().AddWriter("default", writer)})
60
+	LagInterval = 0
61
+	t.Cleanup(func() {LagInterval = origLag})
62
+	dir := t.TempDir()
63
+	now := time.Now()
64
+	file := path.Join(dir, "timestamp")
65
+	if err := util.WriteTimestamp(file, now); err != nil {
66
+		t.Error(err)
67
+	}
68
+	viper.SetConfigType("yaml")
69
+	config := []byte(fmt.Sprintf(configFmt, file))
70
+	viper.ReadConfig(bytes.NewBuffer(config))
71
+	startServer(t)
72
+	time.Sleep(time.Millisecond)
73
+	// Fail
74
+	resp, got := getOne(t)
75
+	if resp.StatusCode != 500 {
76
+		t.Error("unexpected status", resp.Status)
77
+	}
78
+	t.Log(resp.Status, got)
79
+	// Success
80
+	viper.Set("irc.health_period", time.Second)
81
+	resp, got = getOne(t)
82
+	if resp.StatusCode != 200 {
83
+		t.Error("unexpected failure", resp.Status)
84
+	}
85
+	if string(got) != "OK\n" {
86
+		t.Error("unexpected output", string(got))
87
+	}
88
+}

+ 6
- 0
httplistener/httplistener.go Bestand weergeven

@@ -45,6 +45,12 @@ func New(irc *irc.Connection) (*HTTPListener, error) {
45 45
 		mux.HandleFunc("/prometheus", hl.prometheusHandler)
46 46
 	}
47 47
 
48
+	if viper.IsSet("http.health_endpoint") {
49
+		ep := viper.GetString("http.health_endpoint")
50
+		log.Infof("Listening for HTTP GET requests at", ep)
51
+		mux.HandleFunc(ep, hl.healthHandler)
52
+	}
53
+
48 54
 	hl.http.Handler = mux
49 55
 	if viper.GetBool("http.tls") {
50 56
 		go hl.http.ListenAndServeTLS(viper.GetString("http.tls_cert"), viper.GetString("http.tls_key"))

+ 4
- 0
irc.go Bestand weergeven

@@ -17,6 +17,9 @@ func (i *IRCCat) connectIRC(debug bool) error {
17 17
 	irccon.Timeout = time.Second * 15
18 18
 	irccon.RequestCaps = []string{"away-notify", "account-notify", "draft/message-tags-0.2"}
19 19
 	irccon.UseTLS = viper.GetBool("irc.tls")
20
+	if viper.IsSet("irc.health_period") {
21
+		irccon.PingFreq = viper.GetDuration("irc.health_period")
22
+	}
20 23
 
21 24
 	if viper.IsSet("irc.sasl_pass") && viper.GetString("irc.sasl_pass") != "" {
22 25
 		if viper.IsSet("irc.sasl_login") && viper.GetString("irc.sasl_login") != "" {
@@ -52,6 +55,7 @@ func (i *IRCCat) connectIRC(debug bool) error {
52 55
 	irccon.AddCallback("QUIT", i.handleQuit)
53 56
 	irccon.AddCallback("KILL", i.handleQuit)
54 57
 	irccon.AddCallback("NICK", i.handleNick)
58
+	irccon.AddCallback("PONG", i.handlePong)
55 59
 
56 60
 	return nil
57 61
 }

+ 16
- 0
main.go Bestand weergeven

@@ -3,6 +3,8 @@ package main
3 3
 import (
4 4
 	"flag"
5 5
 	"fmt"
6
+	"time"
7
+
6 8
 	"github.com/deckarep/golang-set"
7 9
 	"github.com/fsnotify/fsnotify"
8 10
 	"github.com/irccloud/irccat/httplistener"
@@ -13,6 +15,7 @@ import (
13 15
 	"os"
14 16
 	"os/signal"
15 17
 	"syscall"
18
+	"github.com/irccloud/irccat/util"
16 19
 )
17 20
 
18 21
 var log = loggo.GetLogger("main")
@@ -106,3 +109,16 @@ func (i *IRCCat) handleConfigChange(e fsnotify.Event) {
106 109
 		i.channels.Remove(channel)
107 110
 	}
108 111
 }
112
+
113
+// handlePong writes the current time in UNIX seconds to a designated file.
114
+// Opt in by specifying a file path for config item irc.health_file.
115
+func (i *IRCCat) handlePong(e *irc.Event) {
116
+	healthFile := viper.GetString("irc.health_file")
117
+	if healthFile == "" {
118
+		return
119
+	}
120
+	err:= util.WriteTimestamp(healthFile, time.Now())
121
+	if err != nil {
122
+		log.Criticalf("%s", err)
123
+	}
124
+}

+ 53
- 0
util/timestamp.go Bestand weergeven

@@ -0,0 +1,53 @@
1
+package util
2
+
3
+import (
4
+	"fmt"
5
+	"os"
6
+	"strconv"
7
+	"strings"
8
+	"time"
9
+)
10
+
11
+func isExpired(lastStamp int64, period time.Duration) bool {
12
+	return time.Now().Sub(time.Unix(lastStamp, 0)) > period
13
+}
14
+
15
+func getTimestamp(tsFile string) (int64, error) {
16
+	raw, err := os.ReadFile(tsFile)
17
+	if err != nil {
18
+		return 0, fmt.Errorf("Couldn't read timestamp file: %s", err)
19
+	}
20
+	s := strings.TrimRight(string(raw), "\n")
21
+	ts, err := strconv.ParseInt(s, 10, 64)
22
+	if err != nil {
23
+		// Parsing error already includes offending text
24
+		return 0, fmt.Errorf("Couldn't parse timestamp: %s", err)
25
+	}
26
+	return ts, nil
27
+}
28
+
29
+// WriteTimestamp creates a timestamp file.
30
+func WriteTimestamp(tsFile string, t time.Time) error {
31
+	s := fmt.Sprintf("%d\n", t.Unix())
32
+	err := os.WriteFile(tsFile, []byte(s), 0666)
33
+	if err != nil {
34
+		return fmt.Errorf("Couldn't write to timestamp file: %s", err)
35
+	}
36
+	return nil
37
+}
38
+
39
+// CheckTimestamp reads a timestamp file and returns an error if it's expired.
40
+//
41
+// The file should contain a decimal representation of a Unix timestamp and an
42
+// optional LF newline.
43
+func CheckTimestamp(tsFile string, period time.Duration) error {
44
+	ts, err := getTimestamp(tsFile)
45
+	if err != nil {
46
+		return err
47
+	}
48
+	if isExpired(ts, period) {
49
+		diff := time.Now().Sub(time.Unix(ts, 0).Add(period))
50
+		return fmt.Errorf("Timeout exceeded by %s", diff.String())
51
+	}
52
+	return nil
53
+}

+ 49
- 0
util/timestamp_test.go Bestand weergeven

@@ -0,0 +1,49 @@
1
+package util
2
+
3
+import (
4
+	"os"
5
+	"path"
6
+	"testing"
7
+	"time"
8
+)
9
+
10
+func TestIsExpired(t *testing.T) {
11
+	ts := time.Now().Unix()
12
+	if isExpired(ts, time.Second) {
13
+		t.Error("Shouldn't have expired")
14
+	}
15
+	if !isExpired(ts, time.Microsecond) {
16
+		t.Error("Should've expired")
17
+	}
18
+}
19
+
20
+func TestCheckTimestamp(t *testing.T) {
21
+	dir := t.TempDir()
22
+	now := time.Now()
23
+	file := path.Join(dir, "timestamp")
24
+	WriteTimestamp(file, now)
25
+	if err := CheckTimestamp(file, time.Second); err != nil {
26
+		t.Error(err)
27
+	}
28
+	err := CheckTimestamp(file, time.Microsecond)
29
+	if err == nil {
30
+		t.Error("Did not raise timeout")
31
+	}
32
+	t.Log(err)
33
+	fake := path.Join(dir, "fake")
34
+	// File missing
35
+	err = CheckTimestamp(fake, time.Second)
36
+	if err == nil {
37
+		t.Error("Did not raise missing file error")
38
+	}
39
+	t.Log(err)
40
+	// Wrong format
41
+	if err := os.WriteFile(fake, []byte{':', '('}, 0600); err != nil {
42
+		t.Error(err)
43
+	}
44
+	err = CheckTimestamp(fake, time.Second)
45
+	if err == nil {
46
+		t.Error("Did not raise bad parse error")
47
+	}
48
+	t.Log(err)
49
+}

Laden…
Annuleren
Opslaan