diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..27413d7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,89 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +max_line_length = 120 +tab_width = 4 +ij_continuation_indent_size = 8 +ij_formatter_off_tag = @formatter:off +ij_formatter_on_tag = @formatter:on +ij_formatter_tags_enabled = true +ij_smart_tabs = false +ij_visual_guides = +ij_wrap_on_typing = false + +[.editorconfig] +ij_editorconfig_align_group_field_declarations = false +ij_editorconfig_space_after_colon = false +ij_editorconfig_space_after_comma = true +ij_editorconfig_space_before_colon = false +ij_editorconfig_space_before_comma = false +ij_editorconfig_spaces_around_assignment_operators = true + +[{*.go,*.go2}] +indent_style = tab +ij_continuation_indent_size = 4 +ij_go_GROUP_CURRENT_PROJECT_IMPORTS = false +ij_go_add_leading_space_to_comments = false +ij_go_add_parentheses_for_single_import = false +ij_go_call_parameters_new_line_after_left_paren = true +ij_go_call_parameters_right_paren_on_new_line = true +ij_go_call_parameters_wrap = off +ij_go_fill_paragraph_width = 80 +ij_go_group_stdlib_imports = false +ij_go_import_sorting = gofmt +ij_go_keep_indents_on_empty_lines = false +ij_go_local_group_mode = project +ij_go_local_package_prefixes = +ij_go_move_all_imports_in_one_declaration = false +ij_go_move_all_stdlib_imports_in_one_group = false +ij_go_remove_redundant_import_aliases = false +ij_go_run_go_fmt_on_reformat = true +ij_go_use_back_quotes_for_imports = false +ij_go_wrap_comp_lit = off +ij_go_wrap_comp_lit_newline_after_lbrace = true +ij_go_wrap_comp_lit_newline_before_rbrace = true +ij_go_wrap_func_params = off +ij_go_wrap_func_params_newline_after_lparen = true +ij_go_wrap_func_params_newline_before_rparen = true +ij_go_wrap_func_result = off +ij_go_wrap_func_result_newline_after_lparen = true +ij_go_wrap_func_result_newline_before_rparen = true + +[{*.markdown,*.md}] +ij_markdown_force_one_space_after_blockquote_symbol = true +ij_markdown_force_one_space_after_header_symbol = true +ij_markdown_force_one_space_after_list_bullet = true +ij_markdown_force_one_space_between_words = true +ij_markdown_format_tables = true +ij_markdown_insert_quote_arrows_on_wrap = true +ij_markdown_keep_indents_on_empty_lines = false +ij_markdown_keep_line_breaks_inside_text_blocks = true +ij_markdown_max_lines_around_block_elements = 1 +ij_markdown_max_lines_around_header = 1 +ij_markdown_max_lines_between_paragraphs = 1 +ij_markdown_min_lines_around_block_elements = 1 +ij_markdown_min_lines_around_header = 1 +ij_markdown_min_lines_between_paragraphs = 1 +ij_markdown_wrap_text_if_long = true +ij_markdown_wrap_text_inside_blockquotes = true + +[{*.yaml,*.yml}] +indent_size = 2 +ij_yaml_align_values_properties = do_not_align +ij_yaml_autoinsert_sequence_marker = true +ij_yaml_block_mapping_on_new_line = false +ij_yaml_indent_sequence_value = true +ij_yaml_keep_indents_on_empty_lines = false +ij_yaml_keep_line_breaks = true +ij_yaml_line_comment_add_space = false +ij_yaml_line_comment_add_space_on_reformat = false +ij_yaml_line_comment_at_first_column = true +ij_yaml_sequence_on_new_line = false +ij_yaml_space_before_colon = false +ij_yaml_spaces_within_braces = true +ij_yaml_spaces_within_brackets = true diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..26ac77b --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,45 @@ +name: Go Monit Exporter Deployment + +on: + push: + tags: + - "v*" + +jobs: + release: + name: Release + runs-on: ubuntu-latest + + strategy: + matrix: + goos: [ linux, darwin, windows ] + goarch: [ amd64, arm64 ] + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: 1.23.5 + + - name: Build binary + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + run: | + mkdir -p build + if [ "${GOOS}" = "windows" ]; then + BINARY_NAME=monit-exporter-${GOOS}-${GOARCH}.exe + else + BINARY_NAME=monit-exporter-${GOOS}-${GOARCH} + fi + CGO_ENABLED=0 go build -ldflags="-s -w" -o build/${BINARY_NAME} + + - name: Upload binaries to GitHub Releases + uses: softprops/action-gh-release@v1 + with: + files: build/monit-exporter-* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 62c8935..1a1abc7 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,40 @@ -.idea/ \ No newline at end of file +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf +.idea/**/aws.xml +.idea/**/contentModel.xml +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml +.idea/**/gradle.xml +.idea/**/libraries +cmake-build-*/ +.idea/**/mongoSettings.xml +*.iws +out/ +.idea_modules/ +atlassian-ide-plugin.xml +.idea/replstate.xml +.idea/sonarlint/ +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties +.idea/httpRequests +.idea/caches/build_file_checksums.ser +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +go.work +go.work.sum +.env diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 9ac6267..0000000 --- a/.travis.yml +++ /dev/null @@ -1,18 +0,0 @@ -language: go -go: - - 1.9 -script: - - go test -v ./... - - go build - - sha256sum monit_exporter > monit_exporter.sha256 -deploy: - provider: releases - api_key: - secure: s5ih9FzIXMpqLND+FINaDFcbQ0Tmp7BJQzZZ61lJs2Y+fWZAhgqPvxR1sTweOiZKKSR4oY5irskcvU5tEK6QzaKwuQH1ivtIMZjQr1LzGVoluYe3lVm5VqJTmmvZmiCNPvKeOqPUAVYvn0l9ifAnhbyk6H60yjevlfgd1RvHwggQhdPVCX6+/XKJI2BCmrSDTc5VjfHGd/sZ/9O7iiYAmbm9zAEJB1j/f7B11DvSHJ3bxSh6KWFWO9pcx0E7+k4krKFu2ICprztAvOV007xBuivX+c8my9gZ38hFBvf0oVL2uYYG7pxhUtvJdpR3PwLv4xIVim4CSPrxC3vUN7kspEFGlXNY4fsXbTdSo+QVvDrGG+VQ/3Ab/rJMHs1hF5F8eXS6jeIkt8ktXsxHlxJ0L72qaH7/gYcdHzlvP9TN6A5QEN+cSNMn1wH1SWWdegLn+JeI2fQ6ZXed5P8xTq+6mCPRhh1zyeea6cxBwUylzanF0Vo2GH26VX5A4VoT5OBw7pRc9jpVBhDLG2Z8OeXjeJUl6txB4dJYZzcDR1NIyh1bFUEfl6id9IZrmwKtnGBr2+iK9yuG74sb/+NLDbB37r9tXQ5qFVU2dQOWkUwrzEoz3m2wFTQmjVM02eh5IbwdIFSVPLkTYP+vyQSnIYf40hsVT3l542QI4iYLfAiU/XY= - file: - - monit_exporter - - monit_exporter.sha256 - skip_cleanup: true - on: - public_repo: commercetools/monit_exporter - tags: true diff --git a/README.md b/README.md index 7abea82..473d1ad 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,202 @@ # Monit Exporter for Prometheus -Simple server that periodically scrapes monit status and exports checks information via HTTP for Prometheus. +## English + +### Introduction + +Monit Exporter is a Prometheus Exporter +that scrapes Monit status in XML format and exposes the metrics via an HTTP endpoint. + +### Features + +- **Enhanced Logging:** + - Detailed logs provide insights into HTTP requests, metric collection processes, and potential issues. + +- **Exposes Prometheus-Compatible Metrics:** + - Seamlessly integrates with Prometheus for monitoring Monit-managed services. + +- **Fully Configurable via Command-Line Flags:** + - Customize exporter behavior and Monit scraping parameters as needed. + +### Installation + +1. **Install [Go](https://golang.org/dl/) (version 1.23 or higher recommended).** + +2. **Clone the repository and build:** + + ```bash + git clone https://github.com/commercetools/monit_exporter.git + cd monit_exporter + go build -o monit-exporter + ``` + +### Usage + +#### Commands + +- **serve**: Starts the Monit Exporter server. + +#### Flags + +Below is an overview of the flags defined in `cmd/root.go`: + +| Flag | Default | Description | +|--------------------|-------------------------------------------------------|-------------------------------------------------------------------------| +| `listen-address` | `localhost:9388` | The address on which the exporter will listen (e.g., '0.0.0.0:9388'). | +| `metrics-path` | `/metrics` | The HTTP path at which metrics are served (e.g., '/metrics'). | +| `ignore-ssl` | `false` | Whether to skip SSL certificate verification for Monit endpoints. | +| `monit-scrape-uri` | `http://localhost:2812/_status?format=xml&level=full` | The Monit status URL to scrape (XML format). | +| `monit-user` | *(empty)* | Basic auth username for accessing Monit. | +| `monit-password` | *(empty)* | Basic auth password for accessing Monit. | +| `log-level` | `info` | Log level for the application (debug, info, warn, error, fatal, panic). | + +**Launch the exporter with desired flags:** + +```bash +./monit-exporter serve \ + --listen-address="0.0.0.0:9388" \ + --monit-scrape-uri="http://localhost:2812/_status?format=xml&level=full" \ + --monit-user="admin" \ + --monit-password="monitpassword" \ + --log-level="info" +``` + +**Visit the metrics endpoint:** + +```bash +curl http://localhost:9388/metrics +``` + +### Running Tests + +To run the unit tests for the exporter and Monit components: + +```bash +go test ./internal/exporter -v +go test ./internal/monit -v +``` + +Ensure that all tests pass to verify the integrity of the exporter before deployment. + +### Project / Package Structure + +``` +. +├── cmd +│ ├── root.go (Defines root command and flags) +│ └── serve.go (Implements 'serve' command, server startup) +├── internal +│ ├── config +│ │ └── config.go (Holds the Config struct for the exporter) +│ ├── exporter +│ │ └── exporter.go (Implements the Prometheus Exporter logic) +│ └── monit +│ └── monit.go (Fetches and parses Monit status data) +├── main.go (Entrypoint: calls cmd.Execute()) +├── README.md (This file) +└── LICENSE (MIT License) +``` + +### License + +This project is licensed under the [MIT License](LICENSE). + +## 한국어 + +### 소개 + +Monit Exporter는 Monit 상태 정보를 XML 형식으로 수집하고 이를 Prometheus 메트릭으로 변환하여 HTTP 엔드포인트로 노출하는 익스포터입니다. + +### 기능 + +- **향상된 로깅:** + - 자세한 로그를 통해 HTTP 요청, 메트릭 수집 과정 및 잠재적인 문제를 파악할 수 있습니다. + +- **Prometheus 호환 메트릭 제공:** + - Monit에서 관리하는 서비스를 Prometheus와 원활히 통합하여 모니터링할 수 있습니다. + +- **커맨드라인 플래그로 완벽히 구성 가능:** + - 익스포터의 동작과 Monit 스크래핑 매개변수를 필요에 따라 사용자 정의할 수 있습니다. + +### 설치 + +1. **[Go](https://golang.org/dl/) (버전 1.23 이상 권장)을 설치합니다.** + +2. **레포지토리를 클론하고 빌드합니다:** + + ```bash + git clone https://github.com/commercetools/monit_exporter.git + cd monit_exporter + go build -o monit-exporter + ``` + +### 사용법 + +#### 명령어 + +- **serve**: Monit Exporter 서버를 시작합니다. + +#### 플래그 + +`cmd/root.go`에 정의된 플래그는 다음과 같습니다: + +| 플래그 | 기본값 | 설명 | +|--------------------|-------------------------------------------------------|---------------------------------------------------------| +| `listen-address` | `localhost:9388` | 익스포터가 수신할 주소 및 포트 (예: '0.0.0.0:9388'). | +| `metrics-path` | `/metrics` | 메트릭을 제공할 HTTP 경로 (예: '/metrics'). | +| `ignore-ssl` | `false` | Monit 엔드포인트에 대해 SSL 인증서 검증을 무시할지 여부. | +| `monit-scrape-uri` | `http://localhost:2812/_status?format=xml&level=full` | Monit 상태 정보를 수집할 XML URL. | +| `monit-user` | *(없음)* | Monit에 접근하기 위한 Basic auth 사용자 이름. | +| `monit-password` | *(없음)* | Monit에 접근하기 위한 Basic auth 비밀번호. | +| `log-level` | `info` | 애플리케이션의 로그 레벨 (debug, info, warn, error, fatal, panic). | + +**익스포터를 실행하려면 다음 명령어를 사용합니다:** -Build it: ```bash -go build +./monit-exporter serve \ + --listen-address="0.0.0.0:9388" \ + --monit-scrape-uri="http://localhost:2812/_status?format=xml&level=full" \ + --monit-user="admin" \ + --monit-password="monitpassword" \ + --log-level="info" ``` -Run it: +**메트릭 엔드포인트를 확인하려면 다음 명령어를 사용합니다:** ```bash -./monit_exporter +curl http://localhost:9388/metrics ``` -## Configuration +### 테스트 실행 + +익스포터 및 Monit 컴포넌트의 단위 테스트를 실행하려면: -The application will look for configuration in "config.toml" file located in the same directory. Use -conf flag to override config file name and location. +```bash +go test ./internal/exporter -v +go test ./internal/monit -v +``` + +모든 테스트를 통과시켜 익스포터의 무결성을 검증한 후 배포하십시오. + +### 프로젝트 / 패키지 구조 + +``` +. +├── cmd +│ ├── root.go (루트 명령어와 플래그 정의) +│ └── serve.go (서버 실행 명령어 구현) +├── internal +│ ├── config +│ │ └── config.go (익스포터 설정 구조체 정의) +│ ├── exporter +│ │ └── exporter.go (Prometheus 익스포터 로직 구현) +│ └── monit +│ └── monit.go (Monit 상태 수집 및 파싱) +├── main.go (진입점: cmd.Execute() 호출) +├── README.md (이 파일) +└── LICENSE (MIT 라이선스) +``` -Configuration parameters: +### 라이선스 -Parameter | Description | Default ---- | --- | --- -`listen_address` | address and port to bind | localhost:9388 -`metrics_path` | relative path to expose metrics | /metrics -`ignore_ssl` | whether of not to ignore ssl errors | false -`monit_scrape_uri` | uri to get monit status | http://localhost:2812/_status?format=xml&level=full -`monit_user` | user for monit basic auth, if needed | none -`monit_password` | password for monit status, if needed | none +이 프로젝트는 [MIT License](LICENSE)에 따라 라이선스가 부여됩니다. diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..da339bf --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,86 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +var ( + listenAddress string + metricsPath string + ignoreSSL bool + monitScrapeURI string + monitUser string + monitPassword string + logLevel string +) + +// RootCmd is the base command for this application. +var RootCmd = &cobra.Command{ + Use: "monit-exporter", + Short: "Monit Exporter for Prometheus", + Long: "Prometheus Exporter that collects Monit status information and exposes metrics.", + RunE: func(cmd *cobra.Command, args []string) error { + logrus.Debug("RootCmd is running without subcommand, displaying help message") + return cmd.Help() + }, +} + +// Execute runs the root command of the application. +func Execute() { + logrus.Debug("Execute function called: attempting to run RootCmd.Execute()") + if err := RootCmd.Execute(); err != nil { + logrus.Errorf("Error occurred while executing RootCmd: %v", err) + fmt.Println(err) + os.Exit(1) + } + logrus.Debug("Execute function finished: RootCmd.Execute() completed successfully") +} + +func init() { + RootCmd.PersistentFlags().StringVar( + &listenAddress, + "listen-address", + "localhost:9388", + "The address on which the exporter will listen (e.g., '0.0.0.0:9388').", + ) + RootCmd.PersistentFlags().StringVar( + &metricsPath, + "metrics-path", + "/metrics", + "The HTTP path at which metrics are served (e.g., '/metrics').", + ) + RootCmd.PersistentFlags().BoolVar( + &ignoreSSL, + "ignore-ssl", + false, + "Whether to skip SSL certificate verification for Monit endpoints.", + ) + RootCmd.PersistentFlags().StringVar( + &monitScrapeURI, + "monit-scrape-uri", + "http://localhost:2812/_status?format=xml&level=full", + "The Monit status URL to scrape (XML format).", + ) + RootCmd.PersistentFlags().StringVar( + &monitUser, + "monit-user", + "", + "Basic auth username for accessing Monit.", + ) + RootCmd.PersistentFlags().StringVar( + &monitPassword, + "monit-password", + "", + "Basic auth password for accessing Monit.", + ) + RootCmd.PersistentFlags().StringVar( + &logLevel, + "log-level", + "info", + "Log level for the application (debug, info, warn, error, fatal, panic).", + ) +} diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000..b82e764 --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "bytes" + "testing" + + "github.com/spf13/cobra" +) + +// TestRootCmd_Help verifies that running RootCmd without subcommands prints help. +func TestRootCmd_Help(t *testing.T) { + buf := new(bytes.Buffer) + RootCmd.SetOut(buf) + RootCmd.SetArgs([]string{}) + + err := RootCmd.Execute() + if err != nil { + t.Fatalf("RootCmd execution failed: %v", err) + } + + output := buf.String() + if len(output) == 0 { + t.Errorf("Expected help output, got empty string") + } +} + +// TestRootCmd_Execute checks if Execute() runs RootCmd properly. +func TestRootCmd_Execute(t *testing.T) { + testCmd := &cobra.Command{ + Use: "test", + Short: "Test subcommand", + Run: func(cmd *cobra.Command, args []string) {}, + } + RootCmd.AddCommand(testCmd) + defer RootCmd.RemoveCommand(testCmd) + + RootCmd.SetArgs([]string{"test"}) + Execute() +} diff --git a/cmd/serve.go b/cmd/serve.go new file mode 100644 index 0000000..ab0af5e --- /dev/null +++ b/cmd/serve.go @@ -0,0 +1,157 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + _ "embed" + + "github.com/commercetools/monit-exporter/internal/config" + "github.com/commercetools/monit-exporter/internal/exporter" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +//go:embed static/favicon.ico +var embeddedFavicon []byte + +var serveCmd = &cobra.Command{ + Use: "serve", + Short: "Run the Monit Exporter server", + Long: "Run the Monit Exporter server that collects Monit status and exposes Prometheus metrics.", + RunE: func(cmd *cobra.Command, args []string) error { + logrus.Debug("serveCmd invoked: starting Monit Exporter server") + + if err := config.SetLogLevel(logLevel); err != nil { + logrus.Errorf("Failed to set log level: %v", err) + return fmt.Errorf("failed to set log level: %w", err) + } + logrus.Debugf("Log level set to '%s'", logLevel) + + cfg := &config.Config{ + ListenAddress: listenAddress, + MetricsPath: metricsPath, + IgnoreSSL: ignoreSSL, + MonitScrapeURI: monitScrapeURI, + MonitUser: monitUser, + MonitPassword: monitPassword, + LogLevel: logLevel, + } + logrus.Debugf("Server configuration loaded: %+v", cfg) + + exp, err := exporter.NewExporter(cfg) + if err != nil { + logrus.Errorf("Failed to create exporter: %v", err) + return fmt.Errorf("failed to create exporter: %w", err) + } + logrus.Debug("Registering exporter to Prometheus") + prometheus.MustRegister(exp) + + mux := http.NewServeMux() + mux.Handle(cfg.MetricsPath, promhttp.Handler()) + mux.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { + if 0 < len(embeddedFavicon) { + w.Header().Set("Content-Type", "image/x-icon") + _, _ = w.Write(embeddedFavicon) + } else { + http.ServeFile(w, r, filepath.Join("static", "favicon.ico")) + } + }) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + logrus.Debugf("Root path request received from %s", r.RemoteAddr) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = fmt.Fprintf( + w, + `Monit Exporter

Monit Exporter

Metrics

`, + cfg.MetricsPath, + ) + }) + + server := &http.Server{Addr: cfg.ListenAddress, Handler: commonLogHandler(mux)} + shutdownCh := make(chan os.Signal, 1) + signal.Notify(shutdownCh, os.Interrupt, syscall.SIGTERM) + go func() { + sig := <-shutdownCh + logrus.Infof("Received shutdown signal: %v. Attempting to stop Monit Exporter gracefully...", sig) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := server.Shutdown(ctx); err != nil { + logrus.Errorf("Graceful shutdown failed: %v", err) + } else { + logrus.Info("Server shut down gracefully") + } + }() + + logrus.Infof("Starting Monit Exporter on %s", cfg.ListenAddress) + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + logrus.Errorf("Failed to start server: %v", err) + return fmt.Errorf("failed to start server: %w", err) + } + logrus.Info("Monit Exporter stopped") + return nil + }, +} + +func init() { + RootCmd.AddCommand(serveCmd) +} + +// LoggingResponseWriter wraps a http.ResponseWriter to track status code and size. +type LoggingResponseWriter struct { + http.ResponseWriter + statusCode int + size int +} + +// NewLoggingResponseWriter creates a new LoggingResponseWriter with default status code 200. +func NewLoggingResponseWriter(w http.ResponseWriter) *LoggingResponseWriter { + return &LoggingResponseWriter{ + ResponseWriter: w, + statusCode: http.StatusOK, + } +} + +// WriteHeader sets the status code and calls the underlying ResponseWriter's WriteHeader. +func (lrw *LoggingResponseWriter) WriteHeader(code int) { + lrw.statusCode = code + lrw.ResponseWriter.WriteHeader(code) +} + +// Write writes the data and keeps track of the size in bytes. +func (lrw *LoggingResponseWriter) Write(b []byte) (int, error) { + size, err := lrw.ResponseWriter.Write(b) + lrw.size += size + return size, err +} + +// commonLogHandler returns an http.Handler that logs requests in Common Log Format. +func commonLogHandler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + lrw := NewLoggingResponseWriter(w) + next.ServeHTTP(lrw, r) + duration := time.Since(start) + logrus.Infof("[commonLogHandler] %s - - [%s] \"%s %s %s\" %d %d \"%s\" \"%s\" %.4f", + r.RemoteAddr, + start.Format("02/Jan/2006:15:04:05 -0700"), + r.Method, + r.RequestURI, + r.Proto, + lrw.statusCode, + lrw.size, + r.Referer(), + r.UserAgent(), + duration.Seconds(), + ) + logrus.Debugf("[commonLogHandler] Request processed: Method=%s, URI=%s, Duration=%.4fs", r.Method, r.RequestURI, duration.Seconds()) + }) +} diff --git a/cmd/serve_test.go b/cmd/serve_test.go new file mode 100644 index 0000000..e28f7f2 --- /dev/null +++ b/cmd/serve_test.go @@ -0,0 +1,62 @@ +package cmd + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" +) + +func TestServeCmd_Help(t *testing.T) { + buf := new(bytes.Buffer) + RootCmd.SetOut(buf) + RootCmd.SetArgs([]string{"serve", "--help"}) + + err := serveCmd.Execute() + if err != nil { + t.Fatalf("ServeCmd execution failed with --help: %v", err) + } + + output := buf.String() + if len(output) == 0 { + t.Errorf("Expected help output for serve command, got empty string") + } +} + +func TestCommonLogHandler(t *testing.T) { + dummyHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) + }) + handler := commonLogHandler(dummyHandler) + + req := httptest.NewRequest("GET", "/dummy", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + if w.Result().StatusCode != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Result().StatusCode) + } +} + +func TestNewLoggingResponseWriter(t *testing.T) { + rec := httptest.NewRecorder() + lrw := NewLoggingResponseWriter(rec) + lrw.WriteHeader(http.StatusAccepted) + + if lrw.statusCode != http.StatusAccepted { + t.Errorf("Expected status code 202, got %d", lrw.statusCode) + } + + testData := []byte("Hello, World!") + n, err := lrw.Write(testData) + if err != nil { + t.Fatalf("Write failed: %v", err) + } + if n != len(testData) { + t.Errorf("Expected %d bytes, wrote %d", len(testData), n) + } + if lrw.size != len(testData) { + t.Errorf("Expected lrw.size=%d, got %d", len(testData), lrw.size) + } +} diff --git a/cmd/static/favicon.ico b/cmd/static/favicon.ico new file mode 100644 index 0000000..34bd1fb Binary files /dev/null and b/cmd/static/favicon.ico differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..20ab80f --- /dev/null +++ b/go.mod @@ -0,0 +1,26 @@ +module github.com/commercetools/monit-exporter + +go 1.23.5 + +require ( + github.com/prometheus/client_golang v1.20.5 + github.com/sirupsen/logrus v1.9.3 + github.com/spf13/cobra v1.8.1 + golang.org/x/net v0.34.0 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/klauspost/compress v1.17.11 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.62.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/text v0.21.0 // indirect + google.golang.org/protobuf v1.36.3 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..71eb303 --- /dev/null +++ b/go.sum @@ -0,0 +1,52 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +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.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= +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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU= +google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..836e764 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,29 @@ +package config + +import ( + "github.com/sirupsen/logrus" +) + +// Config holds the configuration values needed by the Monit Exporter. +type Config struct { + ListenAddress string + MetricsPath string + IgnoreSSL bool + MonitScrapeURI string + MonitUser string + MonitPassword string + LogLevel string +} + +// SetLogLevel sets the global log level of logrus based on the given string. +func SetLogLevel(levelStr string) error { + logrus.Debugf("SetLogLevel called with levelStr=%s", levelStr) + level, err := logrus.ParseLevel(levelStr) + if err != nil { + logrus.Errorf("Failed to parse log level: %v", err) + return err + } + logrus.SetLevel(level) + logrus.Infof("Log level successfully set to '%s'", level.String()) + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..183d27d --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,24 @@ +package config + +import ( + "testing" + + "github.com/sirupsen/logrus" +) + +func TestSetLogLevel_Success(t *testing.T) { + err := SetLogLevel("debug") + if err != nil { + t.Fatalf("Expected no error for valid level 'debug', got %v", err) + } + if logrus.GetLevel() != logrus.DebugLevel { + t.Errorf("Expected log level=DebugLevel, got %s", logrus.GetLevel()) + } +} + +func TestSetLogLevel_Invalid(t *testing.T) { + err := SetLogLevel("notalevel") + if err == nil { + t.Fatal("Expected an error for invalid log level 'notalevel', got nil") + } +} diff --git a/internal/exporter/exporter.go b/internal/exporter/exporter.go new file mode 100644 index 0000000..9bbbff5 --- /dev/null +++ b/internal/exporter/exporter.go @@ -0,0 +1,416 @@ +package exporter + +import ( + "errors" + "slices" + "strconv" + "sync" + + "github.com/commercetools/monit-exporter/internal/config" + "github.com/commercetools/monit-exporter/internal/monit" + "github.com/prometheus/client_golang/prometheus" + "github.com/sirupsen/logrus" +) + +const ( + namespace = "monit" +) + +var ( + // ErrNilConfig is returned when a nil config is provided to NewExporter. + ErrNilConfig = errors.New("config is nil") +) + +// serviceTypes maps Monit service type integers to descriptive strings. +var serviceTypes = map[int]string{ + 0: "Filesystem", + 1: "Directory", + 2: "File", + 3: "Process", + 4: "Remote host", + 5: "System", + 6: "Fifo", + 7: "Program", + 8: "Network", +} + +// Exporter collects Monit metrics and exposes them to Prometheus. +type Exporter struct { + cfg *config.Config + mutex sync.Mutex + + up prometheus.Gauge + status *prometheus.GaugeVec + + blockUsage *prometheus.GaugeVec + blockTotal *prometheus.GaugeVec + blockPercent *prometheus.GaugeVec + + inodeUsage *prometheus.GaugeVec + inodeTotal *prometheus.GaugeVec + inodePercent *prometheus.GaugeVec + + portResponseTime *prometheus.GaugeVec + + systemLoadAvg01 *prometheus.GaugeVec + systemLoadAvg05 *prometheus.GaugeVec + systemLoadAvg15 *prometheus.GaugeVec + + systemCPUUser *prometheus.GaugeVec + systemCPUSystem *prometheus.GaugeVec + systemCPUWait *prometheus.GaugeVec + + systemMemPercent *prometheus.GaugeVec + systemMemKilobytes *prometheus.GaugeVec + systemSwapPercent *prometheus.GaugeVec + systemSwapKilobytes *prometheus.GaugeVec +} + +// NewExporter creates a new Exporter using the given Config. +func NewExporter(cfg *config.Config) (*Exporter, error) { + if cfg == nil { + logrus.Error("NewExporter: config is nil") + return nil, ErrNilConfig + } + + logrus.Debugf("NewExporter: creating exporter with ListenAddress=%s, MonitScrapeURI=%s", + cfg.ListenAddress, cfg.MonitScrapeURI) + + labelNames := []string{"service_name", "service_type", "service_monitor_status"} + + return &Exporter{ + cfg: cfg, + + up: prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: namespace, + Name: "exporter_up", + Help: "Indicates whether the Monit endpoint is reachable (1) or not (0).", + }), + status: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: namespace, + Name: "exporter_service_check", + Help: "Indicates the status field from Monit.", + }, + labelNames, + ), + + blockUsage: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: namespace, + Name: "service_block_usage_bytes", + Help: "Block usage for filesystem-based services.", + }, + labelNames, + ), + blockTotal: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: namespace, + Name: "service_block_total_bytes", + Help: "Block total capacity for filesystem-based services.", + }, + labelNames, + ), + blockPercent: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: namespace, + Name: "service_block_usage_percent", + Help: "Block usage percentage for filesystem-based services.", + }, + labelNames, + ), + + inodeUsage: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: namespace, + Name: "service_inode_usage", + Help: "Inode usage for filesystem-based services.", + }, + labelNames, + ), + inodeTotal: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: namespace, + Name: "service_inode_total", + Help: "Total number of inodes for filesystem-based services.", + }, + labelNames, + ), + inodePercent: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: namespace, + Name: "service_inode_usage_percent", + Help: "Inode usage percentage for filesystem-based services.", + }, + labelNames, + ), + + portResponseTime: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: namespace, + Name: "service_port_response_seconds", + Help: "Response time in seconds for port-based checks.", + }, + labelNames, + ), + + systemLoadAvg01: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: namespace, + Name: "service_system_loadavg_01", + Help: "1-minute load average for system-based services.", + }, + labelNames, + ), + systemLoadAvg05: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: namespace, + Name: "service_system_loadavg_05", + Help: "5-minute load average for system-based services.", + }, + labelNames, + ), + systemLoadAvg15: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: namespace, + Name: "service_system_loadavg_15", + Help: "15-minute load average for system-based services.", + }, + labelNames, + ), + + systemCPUUser: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: namespace, + Name: "service_system_cpu_user_percent", + Help: "CPU usage in user space (percent).", + }, + labelNames, + ), + systemCPUSystem: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: namespace, + Name: "service_system_cpu_system_percent", + Help: "CPU usage in kernel space (percent).", + }, + labelNames, + ), + systemCPUWait: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: namespace, + Name: "service_system_cpu_wait_percent", + Help: "CPU usage waiting for I/O (percent).", + }, + labelNames, + ), + + systemMemPercent: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: namespace, + Name: "service_system_memory_usage_percent", + Help: "Memory usage percentage for system-based services.", + }, + labelNames, + ), + systemMemKilobytes: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: namespace, + Name: "service_system_memory_usage_kilobytes", + Help: "Memory usage in kilobytes for system-based services.", + }, + labelNames, + ), + systemSwapPercent: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: namespace, + Name: "service_system_swap_usage_percent", + Help: "Swap usage percentage for system-based services.", + }, + labelNames, + ), + systemSwapKilobytes: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: namespace, + Name: "service_system_swap_usage_kilobytes", + Help: "Swap usage in kilobytes for system-based services.", + }, + labelNames, + ), + }, nil +} + +// Describe sends the descriptors of each metric to the provided channel. +func (e *Exporter) Describe(ch chan<- *prometheus.Desc) { + e.up.Describe(ch) + e.status.Describe(ch) + + e.blockUsage.Describe(ch) + e.blockTotal.Describe(ch) + e.blockPercent.Describe(ch) + + e.inodeUsage.Describe(ch) + e.inodeTotal.Describe(ch) + e.inodePercent.Describe(ch) + + e.portResponseTime.Describe(ch) + + e.systemLoadAvg01.Describe(ch) + e.systemLoadAvg05.Describe(ch) + e.systemLoadAvg15.Describe(ch) + + e.systemCPUUser.Describe(ch) + e.systemCPUSystem.Describe(ch) + e.systemCPUWait.Describe(ch) + + e.systemMemPercent.Describe(ch) + e.systemMemKilobytes.Describe(ch) + e.systemSwapPercent.Describe(ch) + e.systemSwapKilobytes.Describe(ch) + + logrus.Debug("Exporter.Describe: described all metrics to the channel") +} + +// Collect is called by the Prometheus registry to gather metrics. +func (e *Exporter) Collect(ch chan<- prometheus.Metric) { + e.mutex.Lock() + defer e.mutex.Unlock() + + logrus.Debug("Exporter.Collect: resetting metrics before scrape") + + e.status.Reset() + e.blockUsage.Reset() + e.blockTotal.Reset() + e.blockPercent.Reset() + e.inodeUsage.Reset() + e.inodeTotal.Reset() + e.inodePercent.Reset() + e.portResponseTime.Reset() + e.systemLoadAvg01.Reset() + e.systemLoadAvg05.Reset() + e.systemLoadAvg15.Reset() + e.systemCPUUser.Reset() + e.systemCPUSystem.Reset() + e.systemCPUWait.Reset() + e.systemMemPercent.Reset() + e.systemMemKilobytes.Reset() + e.systemSwapPercent.Reset() + e.systemSwapKilobytes.Reset() + + err := e.scrape() + if err != nil { + logrus.Errorf("Exporter.Collect: scrape error: %v", err) + } + + e.up.Collect(ch) + e.status.Collect(ch) + e.blockUsage.Collect(ch) + e.blockTotal.Collect(ch) + e.blockPercent.Collect(ch) + e.inodeUsage.Collect(ch) + e.inodeTotal.Collect(ch) + e.inodePercent.Collect(ch) + e.portResponseTime.Collect(ch) + e.systemLoadAvg01.Collect(ch) + e.systemLoadAvg05.Collect(ch) + e.systemLoadAvg15.Collect(ch) + e.systemCPUUser.Collect(ch) + e.systemCPUSystem.Collect(ch) + e.systemCPUWait.Collect(ch) + e.systemMemPercent.Collect(ch) + e.systemMemKilobytes.Collect(ch) + e.systemSwapPercent.Collect(ch) + e.systemSwapKilobytes.Collect(ch) + + logrus.Debug("Exporter.Collect: metrics collected and sent to the channel") +} + +// scrape fetches Monit status and updates the metrics. +func (e *Exporter) scrape() error { + logrus.Debug("Exporter.scrape: fetching Monit status") + data, err := monit.FetchMonitStatus(e.cfg) + if err != nil { + logrus.Warnf("Exporter.scrape: failed to fetch Monit status: %v", err) + e.up.Set(0) + e.status.Reset() + return err + } + logrus.Debugf("Exporter.scrape: successfully fetched Monit status (%d bytes)", len(data)) + + parsed, err := monit.ParseMonitStatus(data) + if err != nil { + logrus.Warnf("Exporter.scrape: failed to parse Monit status: %v", err) + e.up.Set(0) + e.status.Reset() + return err + } + logrus.Debug("Exporter.scrape: successfully parsed Monit status") + + e.up.Set(1) + logrus.Debug("Exporter.scrape: set exporter_up to 1 (Monit is reachable)") + + for service := range slices.Values(parsed.Services) { + serviceType, ok := serviceTypes[service.Type] + if !ok { + serviceType = "unknown" + logrus.Warnf("Exporter.scrape: unknown service service_type=%d, serviceNameservice_name=%s", service.Type, service.Name) + } + serviceMonitorStatus := strconv.Itoa(service.Monitor) + + e.status.With(prometheus.Labels{ + "service_name": service.Name, + "service_type": serviceType, + "service_monitor_status": serviceMonitorStatus, + }).Set(float64(service.Status)) + + logrus.Debugf( + "Exporter.scrape: service_name=%s, service_type=%s, service_monitor_status=%d, service_status=%d", + service.Name, + serviceType, + service.Monitor, + service.Status, + ) + + e.collectServiceMetrics(service, serviceType, serviceMonitorStatus) + } + return nil +} + +// collectServiceMetrics updates detailed metrics for a single Monit service. +func (e *Exporter) collectServiceMetrics(service monit.Service, serviceType, serviceMonitorStatus string) { + labels := prometheus.Labels{ + "service_name": service.Name, + "service_type": serviceType, + "service_monitor_status": serviceMonitorStatus, + } + + if service.Block != nil { + e.blockUsage.With(labels).Set(service.Block.Usage) + e.blockTotal.With(labels).Set(service.Block.Total) + e.blockPercent.With(labels).Set(service.Block.Percent) + } + + if service.Inode != nil { + e.inodeUsage.With(labels).Set(float64(service.Inode.Usage)) + e.inodeTotal.With(labels).Set(float64(service.Inode.Total)) + e.inodePercent.With(labels).Set(service.Inode.Percent) + } + + if service.Port != nil { + e.portResponseTime.With(labels).Set(service.Port.Responsetime) + } + + if service.System != nil { + e.systemLoadAvg01.With(labels).Set(service.System.Load.Avg01) + e.systemLoadAvg05.With(labels).Set(service.System.Load.Avg05) + e.systemLoadAvg15.With(labels).Set(service.System.Load.Avg15) + + e.systemCPUUser.With(labels).Set(service.System.CPU.User) + e.systemCPUSystem.With(labels).Set(service.System.CPU.System) + e.systemCPUWait.With(labels).Set(service.System.CPU.Wait) + + e.systemMemPercent.With(labels).Set(service.System.Memory.Percent) + e.systemMemKilobytes.With(labels).Set(float64(service.System.Memory.Kilobyte)) + e.systemSwapPercent.With(labels).Set(service.System.Swap.Percent) + e.systemSwapKilobytes.With(labels).Set(float64(service.System.Swap.Kilobyte)) + } +} diff --git a/internal/exporter/exporter_test.go b/internal/exporter/exporter_test.go new file mode 100644 index 0000000..4144581 --- /dev/null +++ b/internal/exporter/exporter_test.go @@ -0,0 +1,116 @@ +package exporter + +import ( + "fmt" + "github.com/prometheus/client_golang/prometheus" + "net/http" + "net/http/httptest" + "testing" + + "github.com/commercetools/monit-exporter/internal/config" + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/sirupsen/logrus" +) + +// TestNewExporter_NilConfig verifies that providing a nil config returns an error. +func TestNewExporter_NilConfig(t *testing.T) { + t.Log("Testing NewExporter with nil config") + exp, err := NewExporter(nil) + if err == nil { + t.Errorf("Expected ErrNilConfig, got no error") + } + if exp != nil { + t.Errorf("Expected nil Exporter, got a non-nil instance") + } +} + +// TestNewExporter_ValidConfig verifies that a valid config creates an Exporter successfully. +func TestNewExporter_ValidConfig(t *testing.T) { + t.Log("Testing NewExporter with a valid config") + cfg := &config.Config{ListenAddress: "0.0.0.0:9999"} + exp, err := NewExporter(cfg) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if exp == nil { + t.Fatal("Expected a non-nil Exporter, got nil") + } +} + +// TestExporter_Collect_Success uses a mock server returning a valid Monit XML response. +func TestExporter_Collect_Success(t *testing.T) { + t.Log("Testing Exporter.Collect with a successful Monit response") + + mockXML := ` + + 5.26.0 + + + rootfs + 0 + 1 + + ` + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Logf("Mock server received request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprintln(w, mockXML) + })) + defer server.Close() + + cfg := &config.Config{ + MonitScrapeURI: server.URL, + } + exp, err := NewExporter(cfg) + if err != nil { + t.Fatalf("Failed to create Exporter: %v", err) + } + + ch := make(chan prometheus.Metric) + go func() { + exp.Collect(ch) + close(ch) + }() + + for range ch { + } + + upValue := testutil.ToFloat64(exp.up) + if upValue != 1 { + t.Errorf("Expected exporter_up=1, got %f", upValue) + } +} + +// TestExporter_Collect_MonitError uses a mock server that returns an HTTP error. +func TestExporter_Collect_MonitError(t *testing.T) { + t.Log("Testing Exporter.Collect when Monit returns an error status code") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "some error", http.StatusBadRequest) + })) + defer server.Close() + + cfg := &config.Config{MonitScrapeURI: server.URL} + exp, err := NewExporter(cfg) + if err != nil { + t.Fatalf("Failed to create Exporter: %v", err) + } + + ch := make(chan prometheus.Metric) + go func() { + exp.Collect(ch) + close(ch) + }() + + upValue := testutil.ToFloat64(exp.up) + if upValue != 0 { + t.Errorf("Expected exporter_up=0 on error, got %f", upValue) + } +} + +// Example optional test: verifying logs (only if needed). +func TestExporter_Logs(t *testing.T) { + logrus.SetLevel(logrus.DebugLevel) + t.Log("Testing Exporter logs at DebugLevel (mock scenario)") +} diff --git a/internal/monit/monit.go b/internal/monit/monit.go new file mode 100644 index 0000000..7f4aea3 --- /dev/null +++ b/internal/monit/monit.go @@ -0,0 +1,247 @@ +package monit + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/xml" + "fmt" + "golang.org/x/net/html/charset" + "io" + "net/http" + "time" + + "github.com/commercetools/monit-exporter/internal/config" + "github.com/sirupsen/logrus" +) + +// Monit represents the top-level XML element . +type Monit struct { + XMLName xml.Name `xml:"monit"` + Server Server `xml:"server"` + Platform Platform `xml:"platform"` + Services []Service `xml:"service"` +} + +// Server represents the element in the Monit XML. +type Server struct { + ID string `xml:"id"` + Incarnation int64 `xml:"incarnation"` + Version string `xml:"version"` + Uptime int64 `xml:"uptime"` + Poll int `xml:"poll"` + StartDelay int `xml:"startdelay"` + Localhostname string `xml:"localhostname"` + Controlfile string `xml:"controlfile"` + HTTPD HTTPD `xml:"httpd"` +} + +// HTTPD represents the element in the Monit XML. +type HTTPD struct { + Address string `xml:"address"` + Port int `xml:"port"` + SSL int `xml:"ssl"` +} + +// Platform represents the element in the Monit XML. +type Platform struct { + Name string `xml:"name"` + Release string `xml:"release"` + Version string `xml:"version"` + Machine string `xml:"machine"` + CPU int `xml:"cpu"` + Memory int64 `xml:"memory"` + Swap int64 `xml:"swap"` +} + +// Service represents the element in the Monit XML. +type Service struct { + Type int `xml:"type,attr"` + Name string `xml:"name"` + CollectedSec int64 `xml:"collected_sec"` + CollectedUsec int64 `xml:"collected_usec"` + Status int `xml:"status"` + StatusHint int `xml:"status_hint"` + Monitor int `xml:"monitor"` + MonitorMode int `xml:"monitormode"` + OnReboot int `xml:"onreboot"` + PendingAction int `xml:"pendingaction"` + Fstype string `xml:"fstype,omitempty"` + Fsflags string `xml:"fsflags,omitempty"` + Mode string `xml:"mode,omitempty"` + UID int `xml:"uid,omitempty"` + GID int `xml:"gid,omitempty"` + Block *Block `xml:"block,omitempty"` + Inode *Inode `xml:"inode,omitempty"` + Read string `xml:"read,omitempty"` + Write string `xml:"write,omitempty"` + Port *Port `xml:"port,omitempty"` + System *System `xml:"system,omitempty"` + Link *Link `xml:"link,omitempty"` +} + +// Block represents the element under a filesystem service. +type Block struct { + Percent float64 `xml:"percent"` + Usage float64 `xml:"usage"` + Total float64 `xml:"total"` +} + +// Inode represents the element under a filesystem service. +type Inode struct { + Percent float64 `xml:"percent"` + Usage int `xml:"usage"` + Total int `xml:"total"` +} + +// Port represents the element, typically for remote host checks. +type Port struct { + Hostname string `xml:"hostname"` + Portnumber int `xml:"portnumber"` + Request string `xml:"request"` + Protocol string `xml:"protocol"` + Type string `xml:"type"` + Responsetime float64 `xml:"responsetime"` + Certificate Certificate `xml:"certificate"` +} + +// Certificate represents the element under . +type Certificate struct { + Valid int `xml:"valid"` +} + +// System represents the element, usually present in type="5" (System) services. +type System struct { + Load Load `xml:"load"` + CPU CPU `xml:"cpu"` + Memory Memory `xml:"memory"` + Swap Swap `xml:"swap"` +} + +// Load represents the element under . +type Load struct { + Avg01 float64 `xml:"avg01"` + Avg05 float64 `xml:"avg05"` + Avg15 float64 `xml:"avg15"` +} + +// CPU represents the element under . +type CPU struct { + User float64 `xml:"user"` + System float64 `xml:"system"` + Wait float64 `xml:"wait"` +} + +// Memory represents the element under . +type Memory struct { + Percent float64 `xml:"percent"` + Kilobyte int `xml:"kilobyte"` +} + +// Swap represents the element under . +type Swap struct { + Percent float64 `xml:"percent"` + Kilobyte int `xml:"kilobyte"` +} + +// Link represents the element under a network service. +type Link struct { + State int `xml:"state"` + Speed int64 `xml:"speed"` + Duplex int `xml:"duplex"` + Download Download `xml:"download"` + Upload Upload `xml:"upload"` +} + +// Download represents the element under . +type Download struct { + Packets Packets `xml:"packets"` + Bytes Bytes `xml:"bytes"` + Errors Errors `xml:"errors"` +} + +// Upload represents the element under . +type Upload struct { + Packets Packets `xml:"packets"` + Bytes Bytes `xml:"bytes"` + Errors Errors `xml:"errors"` +} + +// Packets represents the element under or . +type Packets struct { + Now int `xml:"now"` + Total int `xml:"total"` +} + +// Bytes represents the element under or . +type Bytes struct { + Now int `xml:"now"` + Total int `xml:"total"` +} + +// Errors represents the element under or . +type Errors struct { + Now int `xml:"now"` + Total int `xml:"total"` +} + +// FetchMonitStatus sends an HTTP GET request to the Monit endpoint and returns the response body. +func FetchMonitStatus(cfg *config.Config) ([]byte, error) { + logrus.Debugf("FetchMonitStatus: MonitScrapeURI=%s, IgnoreSSL=%t", cfg.MonitScrapeURI, cfg.IgnoreSSL) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", cfg.MonitScrapeURI, nil) + if err != nil { + logrus.Errorf("FetchMonitStatus: failed to create HTTP request: %v", err) + return nil, fmt.Errorf("unable to create request: %w", err) + } + req.SetBasicAuth(cfg.MonitUser, cfg.MonitPassword) + + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.IgnoreSSL}, + } + client := &http.Client{Transport: tr} + + logrus.Debug("FetchMonitStatus: sending request to Monit") + resp, err := client.Do(req) + if err != nil { + logrus.Errorf("FetchMonitStatus: HTTP request failed: %v", err) + return nil, fmt.Errorf("unable to fetch Monit status: %w", err) + } + defer func() { + if cerr := resp.Body.Close(); cerr != nil { + logrus.Warnf("FetchMonitStatus: failed to close response body: %v", cerr) + } + }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + logrus.Errorf("FetchMonitStatus: non-2xx status code: %d", resp.StatusCode) + return nil, fmt.Errorf("monit returned non-2xx status code: %d", resp.StatusCode) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + logrus.Errorf("FetchMonitStatus: failed to read response body: %v", err) + return nil, fmt.Errorf("unable to read Monit status: %w", err) + } + logrus.Debugf("FetchMonitStatus: successfully received response (%d bytes)", len(data)) + return data, nil +} + +// ParseMonitStatus parses the XML data and returns a Monit struct. +func ParseMonitStatus(data []byte) (Monit, error) { + logrus.Debug("ParseMonitStatus: starting XML parsing") + var statusChunk Monit + reader := bytes.NewReader(data) + decoder := xml.NewDecoder(reader) + decoder.CharsetReader = charset.NewReaderLabel + + if err := decoder.Decode(&statusChunk); err != nil { + logrus.Errorf("ParseMonitStatus: XML parsing failed: %v", err) + return Monit{}, fmt.Errorf("failed to parse Monit XML: %w", err) + } + logrus.Debugf("ParseMonitStatus: successfully parsed. Services count=%d", len(statusChunk.Services)) + return statusChunk, nil +} diff --git a/internal/monit/monit_test.go b/internal/monit/monit_test.go new file mode 100644 index 0000000..7095202 --- /dev/null +++ b/internal/monit/monit_test.go @@ -0,0 +1,75 @@ +package monit + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/commercetools/monit-exporter/internal/config" +) + +// TestFetchMonitStatus_Success checks if FetchMonitStatus can retrieve mock XML successfully. +func TestFetchMonitStatus_Success(t *testing.T) { + t.Log("Testing FetchMonitStatus with a mock server providing valid XML") + + mockXML := `5.26.0` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, mockXML) + })) + defer server.Close() + + cfg := &config.Config{MonitScrapeURI: server.URL, IgnoreSSL: false} + data, err := FetchMonitStatus(cfg) + if err != nil { + t.Fatalf("FetchMonitStatus returned error: %v", err) + } + if len(data) == 0 { + t.Errorf("Expected non-empty data, got empty") + } +} + +// TestFetchMonitStatus_Non2xx checks if FetchMonitStatus returns an error on 400 status code. +func TestFetchMonitStatus_Non2xx(t *testing.T) { + t.Log("Testing FetchMonitStatus with a mock server returning 400 status") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "some error", http.StatusBadRequest) + })) + defer server.Close() + + cfg := &config.Config{MonitScrapeURI: server.URL} + _, err := FetchMonitStatus(cfg) + if err == nil { + t.Fatal("Expected an error for HTTP 400 status, got nil") + } +} + +// TestParseMonitStatus_Success verifies parsing a valid Monit XML. +func TestParseMonitStatus_Success(t *testing.T) { + t.Log("Testing ParseMonitStatus with a valid XML string") + + mockXML := `5.26.0rootfs` + monitData, err := ParseMonitStatus([]byte(mockXML)) + if err != nil { + t.Fatalf("ParseMonitStatus failed: %v", err) + } + + if len(monitData.Services) != 1 { + t.Errorf("Expected 1 service, got %d", len(monitData.Services)) + } + if monitData.Services[0].Name != "rootfs" { + t.Errorf("Expected service name 'rootfs', got '%s'", monitData.Services[0].Name) + } +} + +// TestParseMonitStatus_Error verifies error handling for malformed XML. +func TestParseMonitStatus_Error(t *testing.T) { + t.Log("Testing ParseMonitStatus with malformed XML string") + + invalidXML := `missing closing tags` + _, err := ParseMonitStatus([]byte(invalidXML)) + if err == nil { + t.Fatal("Expected an XML parse error, got nil") + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..14dc70b --- /dev/null +++ b/main.go @@ -0,0 +1,13 @@ +package main + +import ( + "github.com/commercetools/monit-exporter/cmd" + "github.com/sirupsen/logrus" +) + +// main is the entry point of the Monit Exporter application. +func main() { + logrus.Debug("main() function invoked: calling cmd.Execute()") + cmd.Execute() + logrus.Debug("main() function completed: cmd.Execute() returned without error") +} diff --git a/monit_exporter.go b/monit_exporter.go deleted file mode 100644 index 5051341..0000000 --- a/monit_exporter.go +++ /dev/null @@ -1,221 +0,0 @@ -package main - -import ( - "bytes" - "crypto/tls" - "encoding/xml" - "flag" - "io/ioutil" - "net/http" - "sync" - - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/log" - "github.com/spf13/viper" - "golang.org/x/net/html/charset" -) - -const ( - namespace = "monit" // Prefix for Prometheus metrics. -) - -var configFile = flag.String("conf", "./config.toml", "Configuration file for exporter") - -var serviceTypes = map[int]string{ - 0: "filesystem", - 1: "directory", - 2: "file", - 3: "program with pidfile", - 4: "remote host", - 5: "system", - 6: "fifo", - 7: "program with path", - 8: "network", -} - -type monitXML struct { - MonitServices []monitService `xml:"service"` -} - -// Simplified structure of monit check. -type monitService struct { - Type int `xml:"type,attr"` - Name string `xml:"name"` - Status int `xml:"status"` - Monitored string `xml:"monitor"` -} - -// Exporter collects monit stats from the given URI and exports them using -// the prometheus metrics package. -type Exporter struct { - config *Config - mutex sync.RWMutex - client *http.Client - - up prometheus.Gauge - checkStatus *prometheus.GaugeVec -} - -type Config struct { - listen_address string - metrics_path string - ignore_ssl bool - monit_scrape_uri string - monit_user string - monit_password string -} - -func FetchMonitStatus(c *Config) ([]byte, error) { - client := &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: c.ignore_ssl}, - }, - } - - req, err := http.NewRequest("GET", c.monit_scrape_uri, nil) - if err != nil { - log.Errorf("Unable to create request: %v", err) - } - - req.SetBasicAuth(c.monit_user, c.monit_password) - resp, err := client.Do(req) - if err != nil { - log.Error("Unable to fetch monit status") - return nil, err - } - data, err := ioutil.ReadAll(resp.Body) - if err != nil { - log.Fatal("Unable to read monit status") - return nil, err - } - defer resp.Body.Close() - return data, nil -} - -func ParseMonitStatus(data []byte) (monitXML, error) { - var statusChunk monitXML - reader := bytes.NewReader(data) - decoder := xml.NewDecoder(reader) - - // Parsing status results to structure - decoder.CharsetReader = charset.NewReaderLabel - err := decoder.Decode(&statusChunk) - return statusChunk, err -} - -func ParseConfig() *Config { - flag.Parse() - - v := viper.New() - - v.SetDefault("listen_address", "localhost:9388") - v.SetDefault("metrics_path", "/metrics") - v.SetDefault("ignore_ssl", false) - v.SetDefault("monit_scrape_uri", "http://localhost:2812/_status?format=xml&level=full") - v.SetDefault("monit_user", "") - v.SetDefault("monit_password", "") - v.SetConfigFile(*configFile) - v.SetConfigType("toml") - err := v.ReadInConfig() // Find and read the config file - if err != nil { // Handle errors reading the config file - log.Printf("Error reading config file: %s. Using defaults.", err) - } - - return &Config{ - listen_address: v.GetString("listen_address"), - metrics_path: v.GetString("metrics_path"), - ignore_ssl: v.GetBool("ignore_ssl"), - monit_scrape_uri: v.GetString("monit_scrape_uri"), - monit_user: v.GetString("monit_user"), - monit_password: v.GetString("monit_password"), - } -} - -// Returns an initialized Exporter. -func NewExporter(c *Config) (*Exporter, error) { - - return &Exporter{ - config: c, - up: prometheus.NewGauge(prometheus.GaugeOpts{ - Namespace: namespace, - Name: "exporter_up", - Help: "Monit status availability", - }), - checkStatus: prometheus.NewGaugeVec(prometheus.GaugeOpts{ - Namespace: namespace, - Name: "exporter_service_check", - Help: "Monit service check info", - }, - []string{"check_name", "type", "monitored"}, - ), - }, nil -} - -// Describe describes all the metrics ever exported by the monit exporter. It -// implements prometheus.Collector. -func (e *Exporter) Describe(ch chan<- *prometheus.Desc) { - e.up.Describe(ch) - e.checkStatus.Describe(ch) -} - -func (e *Exporter) scrape() error { - data, err := FetchMonitStatus(e.config) - if err != nil { - // set "monit_exporter_up" gauge to 0, remove previous metrics from e.checkStatus vector - e.up.Set(0) - e.checkStatus.Reset() - log.Errorf("Error getting monit status: %v", err) - return err - } else { - parsedData, err := ParseMonitStatus(data) - if err != nil { - e.up.Set(0) - e.checkStatus.Reset() - log.Errorf("Error parsing data from monit: %v", err) - } else { - e.up.Set(1) - // Constructing metrics - for _, service := range parsedData.MonitServices { - e.checkStatus.With(prometheus.Labels{"check_name": service.Name, "type": serviceTypes[service.Type], "monitored": service.Monitored}).Set(float64(service.Status)) - } - } - return err - } -} - -// Collect fetches the stats from configured monit location and delivers them -// as Prometheus metrics. It implements prometheus.Collector. -func (e *Exporter) Collect(ch chan<- prometheus.Metric) { - e.mutex.Lock() // Protect metrics from concurrent collects. - defer e.mutex.Unlock() - e.checkStatus.Reset() - e.scrape() - e.up.Collect(ch) - e.checkStatus.Collect(ch) - return -} - -func main() { - - config := ParseConfig() - exporter, err := NewExporter(config) - - if err != nil { - log.Fatal(err) - } - prometheus.MustRegister(exporter) - - log.Printf("Starting monit_exporter: %s", config.listen_address) - http.Handle(config.metrics_path, prometheus.Handler()) - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte(` - Monit Exporter - -

Monit Exporter

-

Metrics

- - `)) - }) - - log.Fatal(http.ListenAndServe(config.listen_address, nil)) -} diff --git a/monit_exporter_test.go b/monit_exporter_test.go deleted file mode 100644 index 084871a..0000000 --- a/monit_exporter_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package main - -import ( - "net/http" - "net/http/httptest" - "testing" - - "fmt" - "io/ioutil" - "time" -) - -const ( - monitStatus = `acfbb9e9118e68d3754761a79d3aae1615046052145.23.0136736600fc566edc8b68/opt/monit/etc/monitrc
172.17.0.2
28120
Linux4.9.27-moby#1 SMP Thu May 11 04:01:18 UTC 2017x86_64420467681048572fc566edc8b681505209672232150010000.000.000.000.10.10.16.51336280.00
` - monitServiceName = `fc566edc8b68` - monitUser = `user` - monitPassword = `password` -) - -func TestMonitStatus(t *testing.T) { - - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte(monitStatus)) - }) - server := httptest.NewServer(handler) - config := ParseConfig() - config.monit_scrape_uri = server.URL - e, err := NewExporter(config) - if err != nil { - t.Error("Unexpected error during exporter creation") - } - err = e.scrape() - if err != nil { - t.Error("Unexpected execution error:", err) - } -} - -func TestFieldsParsing(t *testing.T) { - parsedData, err := ParseMonitStatus([]byte(monitStatus)) - if err != nil { - t.Error("Unable to parse XML:", err) - } - if parsedData.MonitServices[0].Name != monitServiceName { - t.Errorf("want Name %d, have %d.", monitServiceName, parsedData.MonitServices[0].Name) - } -} - -func TestMonitUnavailable(t *testing.T) { - mConfig := &Config{ - monit_scrape_uri: "http://localhost:1/status", - } - e, err := NewExporter(mConfig) - if err != nil { - t.Error("Unexpected error during exporter creation") - } - err = e.scrape() - if err == nil { - t.Error("Unexpected succsessful execution") - } -} - -func TestHttpQueryExporter(t *testing.T) { - go main() - time.Sleep(50 * time.Millisecond) - address := "127.0.0.1:9388" - resp, err := http.Get(fmt.Sprintf("http://%s/metrics", address)) - if err != nil { - t.Fatal(err) - } - b, err := ioutil.ReadAll(resp.Body) - if err != nil { - t.Error(err) - } - if err := resp.Body.Close(); err != nil { - t.Error(err) - } - if want, have := http.StatusOK, resp.StatusCode; want != have { - t.Errorf("want /metrics status code %d, have %d. Body:\n%s", want, have, b) - } -} - -func AuthHandler(w http.ResponseWriter, r *http.Request) { - user, pass, _ := r.BasicAuth() - if user == monitUser && pass == monitPassword { - w.Write([]byte(monitStatus)) - - } else { - http.Error(w, "Unauthorized.", 401) - } -} - -func TestBasicAuth(t *testing.T) { - handler := http.HandlerFunc(AuthHandler) - server := httptest.NewServer(handler) - config := ParseConfig() - config.monit_scrape_uri = server.URL - config.monit_user = monitUser - config.monit_password = monitPassword - e, err := NewExporter(config) - if err != nil { - t.Error("Unexpected error during exporter creation") - } - err = e.scrape() - if err != nil { - t.Error("Unexpected execution error:", err) - } -} - -func TestBasicAuthFail(t *testing.T) { - handler := http.HandlerFunc(AuthHandler) - server := httptest.NewServer(handler) - config := ParseConfig() - config.monit_scrape_uri = server.URL - config.monit_user = monitUser - config.monit_password = monitPassword + "qwe" - e, err := NewExporter(config) - if err != nil { - t.Error("Unexpected error during exporter creation") - } - err = e.scrape() - if err == nil { - t.Error("Unexpected execution success:") - } -}