Skip to content

Commit

Permalink
Implement startup manager with hot reload (kuskoman#360)
Browse files Browse the repository at this point in the history
* Add startup manager

* Refactor startup manager

* Try to get reload working

* Fix prometheus reloading

* Remove comments etc

* Make hot reload functional

* Fix for vim

* Make it optable via hot-reload flag

* Change to small size letters

* Make file watcher react to modification

* Use default nil value for collector

* Make changes according to review

* Small fix

* Check if configs are equal on reloading

* Refactor according to the request

* Change flag name to -watch

* Fix linting

* Ran go mod tidy

* Exclude startup manager from codecov

* Patch codecov plz shut up

* Remove ignore section from codecov definition

* Tidy gomod

* Start refactoring hot reload functionality

* Tidy gomod

* More debug, still not working

* Add a few more logs

* Handle slog settings

* Change logs to be lowercased

* Make file watcher actually watch files

* Handle unregistering prometheus

* Start extracting file watcher

* Fix file watcher test

* Split code into even more packages

* Fix linter errors

* Start recreating startup manager

* Upgrade dependencies

* Add multiple reloads to startup manager

* Add error handling to reloader

* Try to trace why application is exitting after config change

* Fix error handling in startup_manager

* Refactor getting slog logger

* Fix linter errors

* Fix lint

* Potential fix to tests

* Fix tests

* Fix linting

* Fix patchcov maybe

* Upgrade deps

---------

Co-authored-by: Jakub Surdej <kubasurdej@gmail.com>
  • Loading branch information
satk0 and kuskoman authored Nov 7, 2024
1 parent 83436db commit 754e0c6
Show file tree
Hide file tree
Showing 24 changed files with 1,530 additions and 128 deletions.
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ out/main-%:
run:
go run cmd/exporter/main.go

#: Runs the Go Exporter application with watching the configuration file
run-and-watch-config:
go run cmd/exporter/main.go -watch

#: Builds a binary executable for Linux
build-linux: out/main-linux
#: Builds a binary executable for Darwin
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ See more in the [Migration](#migration) section.

- `make all`: Builds binary executables for all OS (Win, Darwin, Linux).
- `make run`: Runs the Go Exporter application.
- `make run-and-watch-config`: Runs the Go Exporter application with watching the configuration file.
- `make build-linux`: Builds a binary executable for Linux.
- `make build-darwin`: Builds a binary executable for Darwin.
- `make build-windows`: Builds a binary executable for Windows.
Expand Down
74 changes: 13 additions & 61 deletions cmd/exporter/main.go
Original file line number Diff line number Diff line change
@@ -1,82 +1,34 @@
package main

import (
"flag"
"fmt"
"log"
"context"
"log/slog"
"os"
"strconv"

"github.com/joho/godotenv"
"github.com/prometheus/client_golang/prometheus"

"github.com/kuskoman/logstash-exporter/internal/server"
"github.com/kuskoman/logstash-exporter/pkg/config"
"github.com/kuskoman/logstash-exporter/pkg/manager"
"github.com/kuskoman/logstash-exporter/internal/flags"
"github.com/kuskoman/logstash-exporter/internal/startup_manager"
)

func main() {
versionFlag := flag.Bool("version", false, "prints the version and exits")
helpFlag := flag.Bool("help", false, "prints the help message and exits")
configLocationFlag := flag.String("config", config.ExporterConfigLocation, "location of the exporter config file")

flag.Parse()

if *helpFlag {
fmt.Printf("Usage of %s:\n", os.Args[0])
fmt.Println()
fmt.Println("Flags:")
flag.PrintDefaults()
flagsConfig, err := flags.ParseFlags(os.Args[1:])
if err != nil {
slog.Error("failed to parse flags", "err", err)
return
}

if *versionFlag {
fmt.Printf("%s\n", config.SemanticVersion)
return
if shouldExit := flags.HandleFlags(flagsConfig); shouldExit {
os.Exit(0)
}

warn := godotenv.Load()

exporterConfig, err := config.GetConfig(*configLocationFlag)
startupManager, err := startup_manager.NewStartupManager(flagsConfig.ConfigLocation, flagsConfig)
if err != nil {
log.Fatalf("failed to get exporter config: %s", err)
slog.Error("failed to create startup manager", "err", err)
os.Exit(1)
}

logger, err := config.SetupSlog(exporterConfig.Logging.Level, exporterConfig.Logging.Format)
if err != nil {
log.Printf("failed to load .env file: %s", err)
log.Fatalf("failed to setup slog: %s", err)
}

slog.SetDefault(logger)

if warn != nil {
slog.Warn("failed to load .env file", "error", warn)
}

host := exporterConfig.Server.Host
port := strconv.Itoa(exporterConfig.Server.Port)

slog.Debug("application starting... ")
versionInfo := config.GetVersionInfo()
slog.Info(versionInfo.String())

slog.Debug("http timeout", "timeout", exporterConfig.Logstash.HttpTimeout)

collectorManager := manager.NewCollectorManager(
exporterConfig.Logstash.Servers,
exporterConfig.Logstash.HttpTimeout,
exporterConfig.Logstash.HttpInsecure,
)
prometheus.MustRegister(collectorManager)

appServer := server.NewAppServer(host, port, exporterConfig, exporterConfig.Logstash.HttpTimeout)

slog.Info("starting server on", "host", host, "port", port)
if err := appServer.ListenAndServe(); err != nil {
slog.Error("failed to listen and serve", "err", err)
ctx := context.TODO()
if err := startupManager.Initialize(ctx); err != nil {
slog.Error("critical error", "error", err)
os.Exit(1)
}
}
6 changes: 3 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ module github.com/kuskoman/logstash-exporter
go 1.23

require (
github.com/joho/godotenv v1.5.1
github.com/fsnotify/fsnotify v1.8.0
github.com/prometheus/client_golang v1.20.5
gopkg.in/yaml.v2 v2.4.0
)

require (
github.com/gkampitakis/ciinfo v0.3.0 // indirect
github.com/gkampitakis/go-diff v1.3.2 // indirect
github.com/klauspost/compress v1.17.10 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/maruel/natural v1.1.1 // indirect
Expand All @@ -28,7 +28,7 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/gkampitakis/go-snaps v0.5.7
github.com/prometheus/client_model v0.6.1
github.com/prometheus/common v0.60.0 // indirect
github.com/prometheus/common v0.60.1 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
golang.org/x/sys v0.26.0 // indirect
google.golang.org/protobuf v1.35.1 // indirect
Expand Down
12 changes: 6 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gkampitakis/ciinfo v0.3.0 h1:gWZlOC2+RYYttL0hBqcoQhM7h1qNkVqvRCV1fOvpAv8=
github.com/gkampitakis/ciinfo v0.3.0/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo=
github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M=
Expand All @@ -13,10 +15,8 @@ github.com/gkampitakis/go-snaps v0.5.7 h1:uVGjHR4t4pPHU944udMx7VKHpwepZXmvDMF+yD
github.com/gkampitakis/go-snaps v0.5.7/go.mod h1:ZABkO14uCuVxBHAXAfKG+bqNz+aa1bGPAg8jkI0Nk8Y=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.17.10 h1:oXAz+Vh0PMUvJczoi+flxpnBEPxoER1IaAnU/NMPtT0=
github.com/klauspost/compress v1.17.10/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
Expand All @@ -34,8 +34,8 @@ github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.60.0 h1:+V9PAREWNvJMAuJ1x1BaWl9dewMW4YrHZQbx0sJNllA=
github.com/prometheus/common v0.60.0/go.mod h1:h0LYf1R1deLSKtD4Vdg8gy4RuOvENW2J/h19V5NADQw=
github.com/prometheus/common v0.60.1 h1:FUas6GcOw66yB/73KC+BOZoFJmbo/1pojoILArPAaSc=
github.com/prometheus/common v0.60.1/go.mod h1:h0LYf1R1deLSKtD4Vdg8gy4RuOvENW2J/h19V5NADQw=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
Expand Down
90 changes: 90 additions & 0 deletions internal/file_utils/file_test_utils.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package file_utils

import (
"os"
"testing"
"time"
)

func CreateTempFileInDir(t *testing.T, content, dir string) string {
t.Helper()

tempFile, err := os.CreateTemp(dir, "testfile")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}

if _, err := tempFile.WriteString(content); err != nil {
tempFile.Close()
t.Fatalf("failed to write to temp file: %v", err)
}

if err := tempFile.Close(); err != nil {
t.Fatalf("failed to close temp file: %v", err)
}

return tempFile.Name()
}

func AppendToFilex3(t *testing.T, file, content string) {
t.Helper()
// ************ Add a content three times to make sure its written ***********
f, err := os.OpenFile(file, os.O_APPEND | os.O_WRONLY, 0644)
if err != nil {
t.Fatalf("failed to open a file: %v", err)
}

defer f.Close()

if err := f.Sync(); err != nil {
t.Fatalf("failed to sync file: %v", err)
}

if _, err := f.Write([]byte(content)); err != nil {
f.Close() // ignore error; Write error takes precedence
t.Fatalf("failed to write to file: %v", err)
}
if err := f.Sync(); err != nil {
t.Fatalf("failed to sync file: %v", err)
}

time.Sleep(50 * time.Millisecond) // give system time to sync write change before delete
if _, err := f.Write([]byte(content)); err != nil {
f.Close() // ignore error; Write error takes precedence
t.Fatalf("failed to write to file: %v", err)
}

if err := f.Sync(); err != nil {
t.Fatalf("failed to sync file: %v", err)
}
time.Sleep(50 * time.Millisecond) // give system time to sync write change before delete
if _, err := f.Write([]byte(content)); err != nil {
f.Close() // ignore error; Write error takes precedence
t.Fatalf("failed to write to file: %v", err)
}
if err := f.Sync(); err != nil {
t.Fatalf("failed to sync file: %v", err)
}
}

// CreateTempFile creates a temporary file with the given content and returns the path to it.
func CreateTempFile(t *testing.T, content string) string {
return CreateTempFileInDir(t, content, "")
}

func RemoveDir(t *testing.T, path string) {
t.Helper()

if err := os.RemoveAll(path); err != nil {
t.Errorf("failed to remove temp file: %v", err)
}
}

// RemoveFile removes the file at the given path.
func RemoveFile(t *testing.T, path string) {
t.Helper()

if err := os.Remove(path); err != nil {
t.Errorf("failed to remove temp file: %v", err)
}
}
131 changes: 131 additions & 0 deletions internal/file_utils/file_test_utils_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package file_utils

import (
"os"
"testing"
)

func TestCreateTempFile(t *testing.T) {
t.Run("creates temporary file with given content", func(t *testing.T) {
content := "hello world"
path := CreateTempFile(t, content)
defer RemoveFile(t, path)

// Check if the file exists
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Fatalf("expected file to exist, but it does not: %v", err)
}

// Read the file content and verify it matches
readContent, err := os.ReadFile(path)
if err != nil {
t.Fatalf("failed to read temp file: %v", err)
}

if string(readContent) != content {
t.Errorf("expected file content to be '%s', got '%s'", content, string(readContent))
}
})
}

func TestCreateTempFileInDir(t *testing.T) {
t.Run("creates temporary file with given content in directory", func(t *testing.T) {
content := "hello world"
dname, err := os.MkdirTemp("", "sampledir")
if err != nil {
t.Fatalf("failed to create dir: %v", err)
}

path := CreateTempFileInDir(t, content, dname)
defer RemoveDir(t, dname)

// Check if the file exists
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Fatalf("expected file to exist, but it does not: %v", err)
}

// Read the file content and verify it matches
readContent, err := os.ReadFile(path)
if err != nil {
t.Fatalf("failed to read temp file: %v", err)
}

if string(readContent) != content {
t.Errorf("expected file content to be '%s', got '%s'", content, string(readContent))
}
})
}

func TestAppendToFilex3(t *testing.T) {
t.Run("removes the dir at the given path", func(t *testing.T) {
content := "hello world"
new_content := "!"
expected := "hello world!!!"
path := CreateTempFile(t, content)
defer RemoveFile(t, path)

// Read the file content before modification and verify it matches
readContent, err := os.ReadFile(path)
if err != nil {
t.Fatalf("failed to read temp file: %v", err)
}

if string(readContent) != content {
t.Errorf("expected file content to be '%s', got '%s'", content, string(readContent))
}

AppendToFilex3(t, path, new_content)

// Read the file content after modification and verify it matches
readContent, err = os.ReadFile(path)
if err != nil {
t.Fatalf("failed to read temp file: %v", err)
}

if string(readContent) != expected {
t.Errorf("expected file content to be '%s', got '%s'", content, string(readContent))
}

})
}

func TestRemoveDir(t *testing.T) {
t.Run("removes the dir at the given path", func(t *testing.T) {
dname, err := os.MkdirTemp("", "sampledir")
if err != nil {
t.Fatalf("failed to create dir: %v", err)
}

// Ensure the dir exists before removing
if _, err := os.Stat(dname); os.IsNotExist(err) {
t.Fatalf("expected file to exist, but it does not: %v", err)
}

RemoveDir(t, dname)

// Ensure the dir does not exist after removing
if _, err := os.Stat(dname); !os.IsNotExist(err) {
t.Errorf("expected file to be removed, but it still exists")
}

})
}

func TestRemoveFile(t *testing.T) {
t.Run("removes the file at the given path", func(t *testing.T) {
content := "file to be deleted"
path := CreateTempFile(t, content)

// Ensure the file exists before removing
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Fatalf("expected file to exist, but it does not: %v", err)
}

RemoveFile(t, path)

// Ensure the file does not exist after removing
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("expected file to be removed, but it still exists")
}
})
}
Loading

0 comments on commit 754e0c6

Please sign in to comment.