diff --git a/Dockerfile b/Dockerfile index 7c81fa1..93f4090 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.19-alpine as builder +FROM golang:1.22-alpine as builder ENV CGO_ENABLED=0 ENV GOOS=linux diff --git a/cmon_sd.go b/cmon_sd.go index 042656b..c8eab56 100644 --- a/cmon_sd.go +++ b/cmon_sd.go @@ -15,58 +15,110 @@ package main import ( "encoding/json" + "errors" "log" + "log/slog" "net/http" "os" + "slices" + "sort" "strconv" "flag" "fmt" + "github.com/severalnines/cmon-proxy/cmon" "github.com/severalnines/cmon-proxy/cmon/api" "github.com/severalnines/cmon-proxy/config" ) -const namespace = "cmon" - -var cmonEndpoint string -var cmonUsername string -var cmonPassword string - type ClusterTarget struct { Target []string `json:"targets,omitempty"` Label map[string]string `json:"labels,omitempty"` } -func IndexHandler(w http.ResponseWriter, r *http.Request) { +type Cmon interface { + Authenticate() error + ControllerID() string + GetAllClusterInfo(req *api.GetAllClusterInfoRequest) (*api.GetAllClusterInfoResponse, error) +} + +type ErrorMessage struct { + Error string `json:"error"` +} + +type Service struct { + cmonClient Cmon + log *slog.Logger +} + +func NewService() (*Service, error) { + cmonEndpoint := os.Getenv("CMON_ENDPOINT") + cmonUsername := os.Getenv("CMON_USERNAME") + cmonPassword := os.Getenv("CMON_PASSWORD") + + if cmonEndpoint == "" { + cmonEndpoint = "https://127.0.0.1:9501" + } + + if cmonUsername == "" { + return nil, errors.New("CMON_USERNAME is required") + } + + if cmonPassword == "" { + return nil, errors.New("CMON_PASSWORD is required") + } - client := cmon.NewClient(&config.CmonInstance{ + cmonClient := cmon.NewClient(&config.CmonInstance{ Url: cmonEndpoint, Username: cmonUsername, Password: cmonPassword, }, 30) - err := client.Authenticate() + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + + err := cmonClient.Authenticate() + if err != nil { + return nil, err + } + + return &Service{ + cmonClient: cmonClient, + log: logger, + }, nil +} + +func (s *Service) errorResponse(w http.ResponseWriter, statusCode int, message string) { + m := ErrorMessage{Error: message} + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + json.NewEncoder(w).Encode(m) +} + +func (s *Service) IndexHandler(w http.ResponseWriter, r *http.Request) { + err := s.cmonClient.Authenticate() if err != nil { - res, err := client.Ping() - log.Println("Test: ", err, res) + s.log.Error("Error authenticating", err) + s.errorResponse(w, http.StatusUnauthorized, fmt.Sprintf("Error authenticating: %s", err.Error())) return } - res, err := client.GetAllClusterInfo(&api.GetAllClusterInfoRequest{ + res, err := s.cmonClient.GetAllClusterInfo(&api.GetAllClusterInfoRequest{ WithHosts: true, }) if err != nil { - log.Println("Test: ", err, res) + s.log.Error("Error getting cluster info", "error", err) + s.errorResponse(w, http.StatusInternalServerError, fmt.Sprintf("Error getting cluster info: %s", err.Error())) + return } clusterTarget := []ClusterTarget{} // iterate through all clusters for i, cluster := range res.Clusters { - temp := ClusterTarget{ Target: []string{}, Label: map[string]string{ @@ -74,74 +126,51 @@ func IndexHandler(w http.ResponseWriter, r *http.Request) { "ClusterName": cluster.ClusterName, "cid": strconv.FormatInt(int64(cluster.ClusterID), 10), "ClusterType": cluster.ClusterType, - "ControllerId": client.ControllerID(), + "ControllerId": s.cmonClient.ControllerID(), }, } // iterate through all hosts for given cluster for _, host := range cluster.Hosts { - - if host.Nodetype == "controller" { - continue - } - - if host.Nodetype == "prometheus" { - continue - } - - if host.Nodetype == "keepalived" { + switch host.Nodetype { + case "controller", "prometheus", "keepalived": continue } - //check host type and assign exporter port + // check host type and assign exporter port // node_exporter and process_exporter applies to any node type temp.Target = append(temp.Target, host.IP+":9100") // node exporter temp.Target = append(temp.Target, host.IP+":9011") // process exporter - if host.Nodetype == "mysql" || host.Nodetype == "galera" { - - temp.Target = append(temp.Target, host.IP+":9104") // mysql exporter - } - - if host.Nodetype == "haproxy" { - temp.Target = append(temp.Target, host.IP+":9600") // haproxy exporter - } - - if host.Nodetype == "mongo" { - if host.Role == "shardsvr" { + switch host.Nodetype { + case "mysql", "galera": + temp.Target = append(temp.Target, host.IP+":9104") + case "haproxy": + temp.Target = append(temp.Target, host.IP+":9600") + case "mongo": + switch host.Role { + case "shardsvr": temp.Target = append(temp.Target, host.IP+":9216") // mongo exporter - } - if host.Role == "mongos" { + case "mongos": temp.Target = append(temp.Target, host.IP+":9215") // mongos exporter - } - - if host.Role == "mongocfg" { + case "mongocfg": temp.Target = append(temp.Target, host.IP+":9214") // mongocfg exporter } + case "mssql": + temp.Target = append(temp.Target, host.IP+":9399") + case "postgres": + temp.Target = append(temp.Target, host.IP+":9187") + case "redis": + temp.Target = append(temp.Target, host.IP+":9121") + case "proxysql": + temp.Target = append(temp.Target, host.IP+":42004") + case "pgbouncer": + temp.Target = append(temp.Target, host.IP+":9127") } - - if host.Nodetype == "mssql" { - temp.Target = append(temp.Target, host.IP+":9399") // mssql exporter - } - - if host.Nodetype == "postgres" { - temp.Target = append(temp.Target, host.IP+":9187") // postgres exporter - } - - if host.Nodetype == "redis" { - temp.Target = append(temp.Target, host.IP+":9121") // redis exporter - } - - if host.Nodetype == "proxysql" { - temp.Target = append(temp.Target, host.IP+":42004") // proxysql exporter - } - - if host.Nodetype == "pgbouncer" { - temp.Target = append(temp.Target, host.IP+":9127") // pgbouncer exporter - } - } - temp.Target = removeDuplicateStr(temp.Target) + + sort.Strings(temp.Target) + temp.Target = slices.Compact(temp.Target) clusterTarget = append(clusterTarget, temp) i++ } @@ -151,43 +180,25 @@ func IndexHandler(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(clusterTarget) } -func removeDuplicateStr(strSlice []string) []string { - allKeys := make(map[string]bool) - list := []string{} - for _, item := range strSlice { - if _, value := allKeys[item]; !value { - allKeys[item] = true - list = append(list, item) - } - } - return list -} +func (s *Service) Handler() http.Handler { + r := http.NewServeMux() + r.HandleFunc("/", s.IndexHandler) -var port int - -func init() { - flag.IntVar(&port, "p", 8080, "Listen port.") - flag.Parse() + return r } func main() { - cmonEndpoint = os.Getenv("CMON_ENDPOINT") - cmonUsername = os.Getenv("CMON_USERNAME") - cmonPassword = os.Getenv("CMON_PASSWORD") - - if cmonEndpoint == "" { - cmonEndpoint = "https://127.0.0.1:9501" - } + var port int - if cmonUsername == "" { - log.Fatalf("Env variable CMON_USERNAME is not set.") - } + flag.IntVar(&port, "p", 8080, "Listen port.") + flag.Parse() - if cmonPassword == "" { - log.Fatalf("Env variable CMON_PASSWORD is not set.") + service, err := NewService() + if err != nil { + log.Fatalf("Error creating the service: %v", err) } - http.HandleFunc("/", IndexHandler) + mux := service.Handler() listenAddress := fmt.Sprintf(":%d", port) - log.Fatal(http.ListenAndServe(listenAddress, nil)) + log.Fatal(http.ListenAndServe(listenAddress, mux)) } diff --git a/cmon_sd_test.go b/cmon_sd_test.go new file mode 100644 index 0000000..5fca07b --- /dev/null +++ b/cmon_sd_test.go @@ -0,0 +1,177 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "reflect" + "testing" + "time" + + "github.com/severalnines/cmon-proxy/cmon/api" +) + +type CmonClientMock struct { + Fail bool +} + +func (c CmonClientMock) Authenticate() error { + return nil +} + +func (c CmonClientMock) ControllerID() string { + return "00000000-0000-0000-0000-000000000000" +} + +func (c CmonClientMock) GetAllClusterInfo(req *api.GetAllClusterInfoRequest) (*api.GetAllClusterInfoResponse, error) { + if c.Fail { + return nil, errors.New("cmon client error") + } + + return &api.GetAllClusterInfoResponse{ + Clusters: []*api.Cluster{ + { + ClusterID: 1, + ClusterName: "cluster1", + ClusterType: "postgresql_single", + Hosts: []*api.Host{ + { + Nodetype: "mysql", + IP: "127.0.0.1", + }, + { + Nodetype: "mongo", + IP: "127.0.0.1", + Role: "mongos", + }, + }, + }, + }, + }, nil +} + +func TestHandlerIndexHandlerFail(t *testing.T) { + cmonMock := CmonClientMock{ + Fail: true, + } + + b := bytes.NewBuffer(nil) + logger := slog.New(slog.NewJSONHandler(b, nil)) + + s := Service{ + cmonClient: cmonMock, + log: logger, + } + + mux := s.Handler() + ts := httptest.NewServer(mux) + + defer ts.Close() + + resp, err := http.Get(ts.URL) + if err != nil { + t.Fatalf("GET failed: %v", err) + } + + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("GET failed: expected %d, got %d", http.StatusInternalServerError, resp.StatusCode) + } + + if resp.Header.Get("Content-Type") != "application/json" { + t.Errorf("GET failed: expected %s, got %s", "application/json", resp.Header.Get("Content-Type")) + } + + errMsg := ErrorMessage{} + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Errorf("read body failed: %v", err) + } + + err = json.Unmarshal(body, &errMsg) + if err != nil { + t.Errorf("unmarshal body failed: %v", err) + } + + if errMsg.Error != "Error getting cluster info: cmon client error" { + t.Errorf("wrong error message should be 'Error getting cluster info: cmon client error', got %s", errMsg.Error) + } + + type log struct { + Time time.Time `json:"time"` + Level string `json:"level"` + Msg string `json:"msg"` + Error string `json:"error"` + } + + l := log{} + + err = json.Unmarshal(b.Bytes(), &l) + if err != nil { + t.Errorf("failed to unmarshal log: %v", err) + } + + if l.Level != "ERROR" { + t.Errorf("expected 'ERROR', got '%s'", l.Level) + } + + if l.Msg != "Error getting cluster info" { + t.Errorf("expected 'Error getting cluster info', got '%s'", l.Msg) + } + + if l.Error != "cmon client error" { + t.Errorf("expected 'cmon client error', got '%s'", l.Error) + } +} + +func TestHandlerIndexHandlerSuccess(t *testing.T) { + cmonMock := CmonClientMock{} + s := Service{ + cmonClient: cmonMock, + } + + mux := s.Handler() + ts := httptest.NewServer(mux) + + defer ts.Close() + + resp, err := http.Get(ts.URL) + if err != nil { + t.Fatalf("GET failed: %v", err) + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("GET failed: expected status OK, got %v", resp.StatusCode) + } + + if resp.Header.Get("Content-Type") != "application/json" { + t.Errorf("GET failed: expected Content-Type application/json, got %v", resp.Header.Get("Content-Type")) + } + + var clusterTarget []ClusterTarget + err = json.NewDecoder(resp.Body).Decode(&clusterTarget) + if err != nil { + t.Errorf("GET failed: %v", err) + } + + expectedClusterTarget := []ClusterTarget{} + expectedClusterTarget = append(expectedClusterTarget, ClusterTarget{ + Target: []string{"127.0.0.1:9011", "127.0.0.1:9100", "127.0.0.1:9104", "127.0.0.1:9215"}, + Label: map[string]string{ + "ClusterID": "1", + "ClusterName": "cluster1", + "ClusterType": "postgresql_single", + "ControllerId": "00000000-0000-0000-0000-000000000000", + "cid": "1", + }, + }) + + if !reflect.DeepEqual(clusterTarget, expectedClusterTarget) { + t.Errorf("GET failed: expected cluster target %v, got %v", expectedClusterTarget, clusterTarget) + } +} diff --git a/go.mod b/go.mod index 3433bd4..c797c37 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/severalnines/cmon_sd -go 1.19 +go 1.22 require github.com/severalnines/cmon-proxy v0.3.0 diff --git a/go.sum b/go.sum index d775360..5b2968b 100644 --- a/go.sum +++ b/go.sum @@ -31,6 +31,7 @@ github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SU github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM= github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -75,6 +76,7 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= @@ -132,6 +134,7 @@ go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=