pax_global_header00006660000000000000000000000064150033610310014502gustar00rootroot0000000000000052 comment=5c0fbd735afee59ea9740ccb5d2f4279af369aaa script_exporter-3.0.1/000077500000000000000000000000001500336103100147375ustar00rootroot00000000000000script_exporter-3.0.1/.dockerignore000066400000000000000000000000071500336103100174100ustar00rootroot00000000000000**/bin script_exporter-3.0.1/.editorconfig000066400000000000000000000004121500336103100174110ustar00rootroot00000000000000# editorconfig.org root = true [*] indent_style = space indent_size = 2 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true [*.{go,mod,sum}] indent_style = tab indent_size = 4 [Makefile] indent_style = tab indent_size = 4 script_exporter-3.0.1/.github/000077500000000000000000000000001500336103100162775ustar00rootroot00000000000000script_exporter-3.0.1/.github/FUNDING.yml000066400000000000000000000001021500336103100201050ustar00rootroot00000000000000github: [ricoberger] custom: ["https://www.paypal.me/ricoberger"] script_exporter-3.0.1/.github/dependabot.yml000066400000000000000000000015001500336103100211230ustar00rootroot00000000000000--- version: 2 updates: - package-ecosystem: "gomod" directory: "/" schedule: interval: "monthly" reviewers: - "ricoberger" assignees: - "ricoberger" labels: - "changelog: changed" groups: gomod: patterns: - "*" - package-ecosystem: "docker" directory: "/" schedule: interval: "monthly" reviewers: - "ricoberger" assignees: - "ricoberger" labels: - "changelog: changed" groups: docker: patterns: - "*" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "monthly" reviewers: - "ricoberger" assignees: - "ricoberger" labels: - "changelog: changed" groups: github-actions: patterns: - "*" script_exporter-3.0.1/.github/release.yaml000066400000000000000000000012141500336103100206010ustar00rootroot00000000000000name-template: "$RESOLVED_VERSION" tag-template: "$RESOLVED_VERSION" version-template: "v$MAJOR.$MINOR.$PATCH" categories: - title: "Added" labels: - "changelog: added" - title: "Fixed" labels: - "changelog: fixed" - title: "Changed" labels: - "changelog: changed" version-resolver: minor: labels: - "changelog: added" patch: labels: - "changelog: changed" - "changelog: fixed" default: patch category-template: "### $TITLE" change-template: "- #$NUMBER: $TITLE @$AUTHOR" template: | $CHANGES replacers: - search: ":warning:" replace: ":warning: _Breaking change:_ :warning:" script_exporter-3.0.1/.github/workflows/000077500000000000000000000000001500336103100203345ustar00rootroot00000000000000script_exporter-3.0.1/.github/workflows/continuous-delivery.yaml000066400000000000000000000114001500336103100252430ustar00rootroot00000000000000--- name: Continuous Delivery on: push: branches: - main pull_request: release: types: - published jobs: binaries: name: Binaries runs-on: ubuntu-latest if: github.event_name == 'pull_request' || (github.event_name == 'release' && github.event.action == 'published') strategy: matrix: platform: [ { os: "darwin", arch: "amd64" }, { os: "darwin", arch: "arm64" }, { os: "linux", arch: "amd64" }, { os: "linux", arch: "arm64" }, { os: "windows", arch: "amd64" }, { os: "windows", arch: "arm64" }, ] steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup uses: actions/setup-go@v5 with: go-version-file: go.mod cache: true cache-dependency-path: go.sum - name: Install Dependencies run: | go mod download - name: Build run: | export GOOS=${{ matrix.platform.os }} export GOARCH=${{ matrix.platform.arch }} export CGO_ENABLED=0 make build if [[ ${{ matrix.platform.os }} == "windows" ]]; then cp bin/script_exporter script_exporter.exe tar -czf script_exporter-${{ matrix.platform.os }}-${{ matrix.platform.arch }}.tar.gz script_exporter.exe else cp bin/script_exporter script_exporter tar -czf script_exporter-${{ matrix.platform.os }}-${{ matrix.platform.arch }}.tar.gz script_exporter fi - name: Upload Artifact (PR) if: ${{ github.event_name == 'pull_request' }} uses: actions/upload-artifact@v4 with: name: script_exporter-${{ matrix.platform.os }}-${{ matrix.platform.arch }}.tar.gz path: script_exporter-${{ matrix.platform.os }}-${{ matrix.platform.arch }}.tar.gz if-no-files-found: error - name: Upload Artifact (Release) uses: shogo82148/actions-upload-release-asset@v1 if: ${{ github.event_name == 'release' && github.event.action == 'published' }} with: upload_url: ${{ github.event.release.upload_url }} asset_path: script_exporter-${{ matrix.platform.os }}-${{ matrix.platform.arch }}.tar.gz docker: name: Docker runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' || (github.event_name == 'release' && github.event.action == 'published') steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Docker Metadata id: metadata uses: docker/metadata-action@v5 with: images: | ghcr.io/${{ github.repository }} tags: | type=ref,event=branch type=semver,pattern={{raw}} - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and Push Docker Image id: docker_build uses: docker/build-push-action@v6 with: push: true context: . file: ./Dockerfile platforms: linux/386,linux/amd64,linux/arm/v7,linux/arm64/v8 cache-from: type=gha cache-to: type=gha,mode=max tags: ${{ steps.metadata.outputs.tags }} labels: ${{ steps.metadata.outputs.labels }} helm: name: Helm runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' permissions: contents: read packages: write steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Lint run: | helm lint ./charts/script-exporter - name: Template run: | helm template ./charts/script-exporter - name: Set Version id: version run: | echo VERSION=$(yq -r .version ./charts/script-exporter/Chart.yaml) >> $GITHUB_ENV - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} registry: ghcr.io - name: Package and Push Helm Chart run: | helm package ./charts/script-exporter --version ${{ env.VERSION }} helm push ./script-exporter-${{ env.VERSION }}.tgz oci://ghcr.io/${{ github.repository_owner }}/charts script_exporter-3.0.1/.github/workflows/continuous-integration.yaml000066400000000000000000000022001500336103100257410ustar00rootroot00000000000000name: Continuous Integration on: push: branches: - main pull_request: branches: - main jobs: go: name: Go runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup uses: actions/setup-go@v5 with: go-version-file: go.mod cache: true cache-dependency-path: go.sum - name: Lint uses: golangci/golangci-lint-action@v7 - name: Test run: | make test - name: Build run: | make build docker: name: Docker runs-on: ubuntu-latest steps: - name: Checkout Code uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build Docker Image id: docker_build uses: docker/build-push-action@v6 with: push: false context: . file: ./Dockerfile platforms: linux/amd64 script_exporter-3.0.1/.github/workflows/release.yaml000066400000000000000000000007141500336103100226420ustar00rootroot00000000000000name: Release on: push: branches: - main permissions: contents: read jobs: changelog: permissions: contents: write pull-requests: write name: Changelog runs-on: ubuntu-latest steps: - name: Update Changelog uses: release-drafter/release-drafter@v6 with: config-name: release.yaml disable-autolabeler: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} script_exporter-3.0.1/.gitignore000066400000000000000000000023111500336103100167240ustar00rootroot00000000000000################################ ############ macOS ############# ################################ # General .DS_Store .AppleDouble .LSOverride # Icon must end with two \r Icon # Thumbnails ._* # Files that might appear in the root of a volume .DocumentRevisions-V100 .fseventsd .Spotlight-V100 .TemporaryItems .Trashes .VolumeIcon.icns .com.apple.timemachine.donotpresent # Directories potentially created on remote AFP share .AppleDB .AppleDesktop Network Trash Folder Temporary Items .apdisk ################################ ####### VisualStudioCode ####### ################################ .vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json ################################ ############## Go ############## ################################ # Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib # Test binary, build with `go test -c` *.test # Output of the go coverage tool, specifically when used with LiteIDE *.out ################################ ########### Jetbrains ########## ################################ .idea/ ################################ ############ Custom ############ ################################ # Build directory bin script_exporter-3.0.1/.golangci.yaml000066400000000000000000000011501500336103100174610ustar00rootroot00000000000000version: "2" linters: default: none enable: - bodyclose - gosec - govet - ineffassign - noctx - staticcheck - unused - whitespace exclusions: generated: lax presets: - comments - common-false-positives - legacy - std-error-handling paths: - third_party$ - builtin$ - examples$ formatters: enable: - gofmt - goimports settings: goimports: local-prefixes: - github.com/ricoberger/script_exporter exclusions: generated: lax paths: - third_party$ - builtin$ - examples$ script_exporter-3.0.1/Dockerfile000066400000000000000000000007131500336103100167320ustar00rootroot00000000000000FROM golang:1.24.1 AS build WORKDIR /script_exporter COPY go.mod go.sum /script_exporter/ RUN go mod download COPY . . RUN export CGO_ENABLED=0 && make build FROM alpine:3.21.3 RUN apk add --no-cache --update bash curl jq ca-certificates tini python3 RUN mkdir /script_exporter COPY --from=build /script_exporter/bin/script_exporter /script_exporter WORKDIR /script_exporter USER nobody ENTRYPOINT [ "/sbin/tini", "--", "/script_exporter/script_exporter" ] script_exporter-3.0.1/LICENSE000066400000000000000000000020541500336103100157450ustar00rootroot00000000000000MIT License Copyright (c) 2025 Rico Berger Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. script_exporter-3.0.1/Makefile000066400000000000000000000015071500336103100164020ustar00rootroot00000000000000BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD) BUILDTIME ?= $(shell date '+%Y-%m-%d@%H:%M:%S') BUILDUSER ?= $(shell id -un) REVISION ?= $(shell git rev-parse HEAD) VERSION ?= $(shell git describe --tags) .PHONY: build build: @go build -ldflags "-X github.com/prometheus/common/version.Version=${VERSION} \ -X github.com/prometheus/common/version.Revision=${REVISION} \ -X github.com/prometheus/common/version.Branch=${BRANCH} \ -X github.com/prometheus/common/version.BuildUser=${BUILDUSER} \ -X github.com/prometheus/common/version.BuildDate=${BUILDTIME}" \ -o ./bin/script_exporter ./cmd; .PHONY: test test: # Run tests and generate coverage report. To view the coverage report in a # browser run "go tool cover -html=coverage.out". go test -covermode=atomic -coverpkg=./... -coverprofile=coverage.out -v ./... script_exporter-3.0.1/README.md000066400000000000000000000307701500336103100162250ustar00rootroot00000000000000# Script Exporter The Script Exporter is a [Prometheus](https://prometheus.io) exporter to execute scripts and collect metrics from the output or the exit status. The scripts to be executed are defined via a configuration file. In the configuration file several scripts can be specified. The script which should be executed is indicated by a parameter in the scrap configuration. The output of the script is captured and is provided for Prometheus. Even if the script does not produce any output, the exit status and the duration of the execution are provided. ## Building and Running To run the Script Exporter you can use the one of the binaries from the [release](https://github.com/ricoberger/script_exporter/releases) page or the [Docker image](https://github.com/ricoberger/script_exporter/pkgs/container/script_exporter). You can also build the Script Exporter by yourself by running the following commands: ```sh git clone https://github.com/ricoberger/script_exporter.git cd script_exporter make build ``` Afterwards you can run the Script Exporter with the [example configuration](./scripts.yaml), by using the following command: ```sh ./bin/script_exporter ``` To run the examples via Docker or Docker Compose the following commands can be used. The Docker Compose setup will also start a Prometheus instance with a [scrape configuration](./prometheus.yaml) for all scripts. ```sh # Docker docker build -f ./Dockerfile -t ghcr.io/ricoberger/script_exporter:latest . docker run --rm -it --name script_exporter -p 9469:9469 -v $(pwd)/scripts.yaml:/script_exporter/scripts.yaml -v $(pwd)/prober/scripts:/script_exporter/prober/scripts ghcr.io/ricoberger/script_exporter:latest # Docker Compose docker compose -f docker-compose.yaml up --build --force-recreate ``` Then visit [http://localhost:9469](http://localhost:9469) in the browser of your choice. There you have access to the following examples: - [output](http://localhost:9469/probe?script=output): Parses the returned output from the script and only return valid Prometheus metrics. - [ping](http://localhost:9469/probe?script=ping&prefix=test¶ms=target&target=example.com): Pings the specified address in the `target` parameter and returns if it was successful or not. - [showtimeout](http://localhost:9469/probe?script=showtimeout&timeout=42): Reports whether or not the script is being run with a timeout from Prometheus, and what it is. - [docker](http://localhost:9469/probe?script=docker): Example using `docker exec` to return the number of files in a Docker container. - [sleep](http://localhost:9469/probe?script=sleep¶ms=seconds&seconds=20): Execute a script, which executes a `sleep` command with the duration provided in the `seconds` parameter. The command will be canceled after 10 seconds. - [cache](http://localhost:9469/probe?script=cache¶ms=seconds&seconds=5): Execute a script, which executes a `sleep` command with the duration provided in the `seconds` parameter. The output of the script will be cached for 60 seconds, so that follow up requests will be faaster. You can also deploy the Script Exporter to Kubernetes via Helm: ```sh helm upgrade --install script-exporter oci://ghcr.io/ricoberger/charts/script-exporter --version ``` ## Usage and Configuration The Script Exporter is configured via a configuration file and command-line flags (such as what configuration file to load, what port to listen on, and the logging format and level). The Script Exporter can reload its configuration file at runtime. If the new configuration is not well-formed, the changes will not be applied. A configuration reload is triggered by sending a `SIGHUP` to the Script Exporter process or by sending a HTTP POST request to the `/-/reload` endpoint. ### Command-Line Flags ```plaintext usage: script_exporter [] Flags: -h, --[no-]help Show context-sensitive help (also try --help-long and --help-man). --config.files="scripts.yaml" Configuration files. To specify multiple configuration files glob patterns can be used. --[no-]config.check If true, validate the configuration files and then exit. --[no-]log.env If true, environment variables passed to a script will be logged. --[no-]script.no-args Restrict script to accept arguments. --script.timeout-offset=0.5 Offset to subtract from timeout in seconds. --web.external-url= The URL under which Script Exporter is externally reachable (for example, if Script Exporter is served via a reverse proxy). Used for generating relative and absolute links back to Script Exporter itself. If the URL has a path portion, it will be used to prefix all HTTP endpoints served by Script Exporter. If omitted, relevant URL components will be derived automatically. --web.route-prefix= Prefix for the internal routes of web endpoints. Defaults to path of --web.external-url. --discovery.host="" Host for service discovery. --discovery.port="" Port for service discovery. --discovery.scheme="" Scheme for service discovery. --web.listen-address=:9469 ... Addresses on which to expose metrics and web interface. Repeatable for multiple addresses. Examples: `:9100` or `[::1]:9100` for http, `vsock://:9100` for vsock --web.config.file="" Path to configuration file that can enable TLS or authentication. See: https://github.com/prometheus/exporter-toolkit/blob/master/docs/web-configuration.md --log.level=info Only log messages with the given severity or above. One of: [debug, info, warn, error] --log.format=logfmt Output format of log messages. One of: [logfmt, json] --[no-]version Show application version. ``` ### Configuration File The scripts for the Script Exporter can be configured via multiple configuration files. The configuration files can be set via the `--config.files` command-line flag. To split the scripts accross multiple configuration files a glob pattern can be used, e.g. `--config.files=./scripts/*.yaml`. ```yaml scripts: - # The name of the script. To run the selected script within a probe the # "script" parameter must be set in the Prometheus scrape configuration. name: # The command which should be run. This could be the path to a shell script # or any other valid command which is available within your system. command: - # Additional arguments which should be passed to the command. The arguments # are passed to the command first, afterwards all additional arguments # specified by the "params" parameter from the Prometheus scrape config are # passed to the command: " [] []". args: - # All additional environment variables which should be passed to the script, # besides the globally defined environment variables on the system, where # Script Exporter is running. # # The parameters defined via the "params" query parameter are also passed to # script as environemnt variables. If an environemnt variable with the same # name as a parameter is already defined it will not be overwritten, unless # the "allow_env_overwrites" options is set to "true". env: : allow_env_overwrite: # If set to "true" the command will be executed with privileged (root) # permissions by executing the "command" with a pre-fixed "sudo": # "sudo [] []" # # Note that you still need to create the relevant sudoers entries, Script # Exporter will not do this for you. sudo: # By default the output of a script will be checked for valid Prometheus # metrics. These metrics will be exported in addition to the default script # metrics. output: # If set to "true" the output of a script will be ignored and only the # default metrics will be exported. ignore: # If set to "true" the output of a script will be ignored if the script # returned an error and only the default metrics will be exported. ignore_on_error: # Timeout configuration for the script. By default the timeout specified via # the "timeout" parameter or the "scrape_timeout" Prometheus configuration # will be used. # # We add the offset defined via the "--script.timeout-offset" command-line # flag to these timeouts. # # This information is made available to scripts through the environment # variables "$SCRIPT_TIMEOUT" and "$SCRIPT_DEADLINE". The first is the # timeout in seconds (including a fractional part) and the second is the # Unix timestamp when the deadline will expire (also including a fractional # part). timeout: # Set a max timeout in seconds. If the Prometheus specific timeout is # larger then the max timeout, the max timeout will be used. max_timeout: # If set to "true" the timeout will be enforced, otherwise the script will # continue with running, also when the timeout is passed. enforced: # Set a wait delay in seconds. # # If the script spawns a child process (e.g. "sleep") the timeout might # not be enforced, because Go waits for the timeout and the closing of all # I/O pipes. To enforce the timeout for such cases the "wait_delay" must # be set to a low value (e.g. "0.01") wait_delay: # By default the result of a script execution will not be cached. To reuse # the result from one scrape in a follow up scrape the "duration" must be # set. # # Note: The cache is not presisted, which means that the cache is deleted, # if the Script Exporter is restarted. cache: # Cache duration in seconds. If this is set, the result of a script # execution will be returned from the cache instead of running the script # again. duration: # If set to "true" also the result of a script execution which returned an # error will be cached. cache_on_error: # If set to "true" the result from the cache will be returned, when the # script returned an error, also when the cache entry is already expired. use_expired_cache_on_error: # Configuration for the Prometheus discovery. discovery: # A list of parameters which will be passed to the script and within the # "params" query parameter. params: : # The scrape interval and scrape timeout which should be used by # Prometheus for the discovered script. If not set the global scrape # interval and timeout will be used. scrape_interval: scrape_timeout: ``` ### TLS and Basic Authentication The Script Exporter supports TLS and basic authentication. This enables better control of the various HTTP endpoints. To use TLS and/or basic authentication, you need to pass a configuration file using the `--web.config.file` parameter. The format of the file is described [in the exporter-toolkit repository](https://github.com/prometheus/exporter-toolkit/blob/master/docs/web-configuration.md). Note that the TLS and basic authentication settings affect all HTTP endpoints: `/metrics` for scraping, `/probe` for probing, and the web UI. ### Prometheus Configuration An example configuration for Prometheus can be found in the [prometheus.yaml](./prometheus.yaml) file, which contains one job for each example script, a job to scrape the Script Exporter metrics and a job to use the Prometheus discovery feature of the script exporter. ```yaml scrape_configs: # Scrape configuration for all of the example scripts. - job_name: showtimeout metrics_path: /probe params: script: - showtimeout static_configs: - targets: - localhost:9469 # Configuration to get the metrics of the Script Exporter. - job_name: "script_exporter" metrics_path: /metrics static_configs: - targets: - localhost:9469 # Configuration for the Prometheus discovery feature of the Script Exporter. - job_name: scripts http_sd_configs: - url: http://localhost:9469/discovery ``` By default the Script Exporter will use the host, port and scheme used by Prometheus when creating the targets for the Prometheus discovery. If you want to overwrite the host, port and scheme the `--discovery.host`, `--discovery.port` and `--discovery.scheme` command-line flags can be set. script_exporter-3.0.1/charts/000077500000000000000000000000001500336103100162235ustar00rootroot00000000000000script_exporter-3.0.1/charts/script-exporter/000077500000000000000000000000001500336103100213755ustar00rootroot00000000000000script_exporter-3.0.1/charts/script-exporter/.helmignore000066400000000000000000000005351500336103100235320ustar00rootroot00000000000000# Patterns to ignore when building packages. # This supports shell glob matching, relative path matching, and # negation (prefixed with !). Only one pattern per line. .DS_Store # Common VCS dirs .git/ .gitignore .bzr/ .bzrignore .hg/ .hgignore .svn/ # Common backup files *.swp *.bak *.tmp *.orig *~ # Various IDEs .project .idea/ *.tmproj .vscode/ script_exporter-3.0.1/charts/script-exporter/Chart.yaml000066400000000000000000000003151500336103100233210ustar00rootroot00000000000000--- apiVersion: v2 name: script-exporter description: Prometheus exporter to execute scripts and collect metrics from the output or the exit status. type: application version: 3.0.1 appVersion: v3.0.1 script_exporter-3.0.1/charts/script-exporter/templates/000077500000000000000000000000001500336103100233735ustar00rootroot00000000000000script_exporter-3.0.1/charts/script-exporter/templates/NOTES.txt000066400000000000000000000001121500336103100250160ustar00rootroot00000000000000Visit https://github.com/ricoberger/script_exporter for more information. script_exporter-3.0.1/charts/script-exporter/templates/_helpers.tpl000066400000000000000000000052411500336103100257170ustar00rootroot00000000000000{{/* Expand the name of the chart. */}} {{- define "script-exporter.name" -}} {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} {{- end }} {{/* Create a default fully qualified app name. We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). If release name contains chart name it will be used as a full name. */}} {{- define "script-exporter.fullname" -}} {{- if .Values.fullnameOverride }} {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} {{- else }} {{- $name := default .Chart.Name .Values.nameOverride }} {{- if contains $name .Release.Name }} {{- .Release.Name | trunc 63 | trimSuffix "-" }} {{- else }} {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} {{- end }} {{- end }} {{- end }} {{/* Create chart name and version as used by the chart label. */}} {{- define "script-exporter.chart" -}} {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} {{- end }} {{/* Common labels */}} {{- define "script-exporter.labels" -}} helm.sh/chart: {{ include "script-exporter.chart" . }} {{ include "script-exporter.selectorLabels" . }} {{- if .Chart.AppVersion }} app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} {{- end }} app.kubernetes.io/managed-by: {{ .Release.Service }} {{- end }} {{/* Selector labels */}} {{- define "script-exporter.selectorLabels" -}} app.kubernetes.io/name: {{ include "script-exporter.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} {{- end }} {{/* Additional annotations for Pods */}} {{- define "script-exporter.podAnnotations" -}} {{- if .Values.podAnnotations }} {{- toYaml .Values.podAnnotations }} {{- end }} {{- end }} {{/* Additional labels for Pods */}} {{- define "script-exporter.podLabels" -}} {{- if .Values.podLabels }} {{- toYaml .Values.podLabels }} {{- end }} {{- end }} {{/* Additional annotations for the Service */}} {{- define "script-exporter.serviceAnnotations" -}} {{- if .Values.service.annotations }} {{- toYaml .Values.service.annotations }} {{- end }} {{- end }} {{/* Additional labels for the Service */}} {{- define "script-exporter.serviceLabels" -}} {{- if .Values.service.labels }} {{- toYaml .Values.service.labels }} {{- end }} {{- end }} {{/* Additional labels for the Service Monitor */}} {{- define "script-exporter.serviceMonitorLabels" -}} {{- if .Values.serviceMonitor.labels }} {{- toYaml .Values.serviceMonitor.labels }} {{- end }} {{- end }} {{/* Additional labels for the self Service Monitor */}} {{- define "script-exporter.selfServiceMonitorLabels" -}} {{- if .Values.selfServiceMonitor.labels }} {{- toYaml .Values.selfServiceMonitor.labels }} {{- end }} {{- end }} script_exporter-3.0.1/charts/script-exporter/templates/configmap.yaml000066400000000000000000000004421500336103100262220ustar00rootroot00000000000000apiVersion: v1 kind: ConfigMap metadata: name: {{ include "script-exporter.fullname" . }} labels: {{- include "script-exporter.labels" . | nindent 4 }} data: scripts.yaml: | {{ tpl .Values.config . | indent 4 }} {{- with .Values.scripts }} {{- toYaml . | nindent 2 }} {{- end }} script_exporter-3.0.1/charts/script-exporter/templates/deployment.yaml000066400000000000000000000052601500336103100264420ustar00rootroot00000000000000apiVersion: apps/v1 kind: Deployment metadata: name: {{ include "script-exporter.fullname" . }} labels: {{- include "script-exporter.labels" . | nindent 4 }} spec: replicas: {{ .Values.replicaCount }} selector: matchLabels: {{- include "script-exporter.selectorLabels" . | nindent 6 }} template: metadata: annotations: checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} {{- include "script-exporter.podAnnotations" . | nindent 8 }} labels: {{- include "script-exporter.selectorLabels" . | nindent 8 }} {{- include "script-exporter.podLabels" . | nindent 8 }} spec: {{- with .Values.imagePullSecrets }} imagePullSecrets: {{- toYaml . | nindent 8 }} {{- end }} {{- if .Values.serviceAccount.name }} serviceAccountName: "{{ .Values.serviceAccount.name }}" {{- end }} securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }} containers: - name: script-exporter securityContext: {{- toYaml .Values.securityContext | nindent 12 }} image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" imagePullPolicy: {{ .Values.image.pullPolicy }} {{- with .Values.env }} env: {{- toYaml . | nindent 12 }} {{- end }} {{- with .Values.args }} args: {{- toYaml . | nindent 12 }} {{- end }} ports: - name: http containerPort: 9469 protocol: TCP livenessProbe: httpGet: path: / port: http readinessProbe: httpGet: path: / port: http resources: {{- toYaml .Values.resources | nindent 12 }} volumeMounts: - name: config mountPath: /script_exporter/scripts readOnly: true {{- if .Values.volumeMounts }} {{- toYaml .Values.volumeMounts | nindent 12 }} {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} {{- end }} {{- with .Values.affinity }} affinity: {{- toYaml . | nindent 8 }} {{- end }} {{- with .Values.tolerations }} tolerations: {{- toYaml . | nindent 8 }} {{- end }} volumes: - name: config configMap: name: {{ include "script-exporter.fullname" . }} defaultMode: 0777 {{- if .Values.volumes }} {{- toYaml .Values.volumes | nindent 8 }} {{- end }} script_exporter-3.0.1/charts/script-exporter/templates/selfservicemonitor.yaml000066400000000000000000000022061500336103100302010ustar00rootroot00000000000000{{- if .Values.selfServiceMonitor.enabled }} --- apiVersion: monitoring.coreos.com/v1 kind: ServiceMonitor metadata: labels: {{- include "script-exporter.labels" . | nindent 4 }} {{- include "script-exporter.selfServiceMonitorLabels" . | nindent 4 }} name: {{ include "script-exporter.fullname" . }} namespace: {{ default .Release.Namespace .Values.selfServiceMonitor.namespace }} spec: endpoints: - path: /metrics port: http {{- with .Values.selfServiceMonitor.interval }} interval: {{ . }} {{- end }} {{- with .Values.selfServiceMonitor.scrapeTimeout }} scrapeTimeout: {{ . }} {{- end }} honorLabels: {{ .Values.selfServiceMonitor.honorLabels }} {{- with .Values.selfServiceMonitor.metricRelabelings }} metricRelabelings: {{ toYaml . | nindent 6 }} {{- end }} {{- with .Values.selfServiceMonitor.relabelings }} relabelings: {{ toYaml . | nindent 6 }} {{- end }} namespaceSelector: matchNames: - {{ .Release.Namespace }} selector: matchLabels: {{- include "script-exporter.selectorLabels" . | nindent 6 }} {{- end }} script_exporter-3.0.1/charts/script-exporter/templates/service.yaml000066400000000000000000000010011500336103100257070ustar00rootroot00000000000000apiVersion: v1 kind: Service metadata: name: {{ include "script-exporter.fullname" . }} labels: {{- include "script-exporter.labels" . | nindent 4 }} {{- include "script-exporter.serviceLabels" . | nindent 4 }} annotations: {{- include "script-exporter.serviceAnnotations" . | nindent 4 }} spec: type: {{ .Values.service.type }} ports: - port: 9469 targetPort: http protocol: TCP name: http selector: {{- include "script-exporter.selectorLabels" . | nindent 4 }} script_exporter-3.0.1/charts/script-exporter/templates/serviceaccount.yaml000066400000000000000000000005731500336103100273010ustar00rootroot00000000000000{{- if and .Values.serviceAccount.create .Values.serviceAccount.name }} apiVersion: v1 kind: ServiceAccount metadata: name: {{ .Values.serviceAccount.name }} labels: {{- include "script-exporter.labels" . | nindent 4 }} {{- if .Values.serviceAccount.annotations }} annotations: {{- toYaml .Values.serviceAccount.annotations | nindent 4 }} {{- end }} {{- end }} script_exporter-3.0.1/charts/script-exporter/templates/servicemonitor.yaml000066400000000000000000000062101500336103100273260ustar00rootroot00000000000000{{- if .Values.serviceMonitor.enabled }} {{- if .Values.serviceMonitor.autoCreate.enabled }} {{- range (fromYaml .Values.config).scripts }} --- apiVersion: monitoring.coreos.com/v1 kind: ServiceMonitor metadata: labels: {{- include "script-exporter.labels" $ | nindent 4 }} {{- include "script-exporter.serviceMonitorLabels" $ | nindent 4 }} name: {{ include "script-exporter.fullname" $ }}-{{ kebabcase .name }} namespace: {{ default $.Release.Namespace $.Values.serviceMonitor.namespace }} spec: endpoints: - path: /probe port: http {{- with $.Values.serviceMonitor.interval }} interval: {{ . }} {{- end }} {{- with $.Values.serviceMonitor.scrapeTimeout }} scrapeTimeout: {{ . }} {{- end }} params: script: - {{ .name }} {{- with $.Values.serviceMonitor.honorLabels }} honorLabels: {{ . }} {{- end }} metricRelabelings: - action: replace replacement: {{ .name }} targetLabel: script {{- with $.Values.serviceMonitor.metricRelabelings }} {{- toYaml . | nindent 8 }} {{- end }} relabelings: - action: replace replacement: {{ .name }} targetLabel: script {{- with $.Values.serviceMonitor.relabelings }} {{- toYaml . | nindent 8 }} {{- end }} namespaceSelector: matchNames: - {{ $.Release.Namespace }} selector: matchLabels: {{- include "script-exporter.selectorLabels" $ | nindent 6 }} {{- end }} {{- else }} {{- range .Values.serviceMonitor.targets }} --- apiVersion: monitoring.coreos.com/v1 kind: ServiceMonitor metadata: labels: {{- include "script-exporter.labels" $ | nindent 4 }} {{- include "script-exporter.serviceMonitorLabels" $ | nindent 4 }} {{- with .labels }} {{- toYaml . | nindent 4 }} {{- end }} name: {{ include "script-exporter.fullname" $ }}-{{ kebabcase .name }} namespace: {{ default $.Release.Namespace $.Values.serviceMonitor.namespace }} spec: endpoints: - path: /probe port: http {{- with .interval | default $.Values.serviceMonitor.interval }} interval: {{ . }} {{- end }} {{- with .scrapeTimeout | default $.Values.serviceMonitor.scrapeTimeout }} scrapeTimeout: {{ . }} {{- end }} params: script: - {{ .script }} {{- with .honorLabels | default $.Values.serviceMonitor.honorLabels }} honorLabels: {{ . }} {{- end }} metricRelabelings: - action: replace replacement: {{ .script }} targetLabel: script {{- with .additionalMetricsRelabels | default $.Values.serviceMonitor.metricRelabelings }} {{- toYaml . | nindent 8 }} {{- end }} relabelings: - action: replace replacement: {{ .script }} targetLabel: script {{- with .additionalRelabeling | default $.Values.serviceMonitor.relabelings }} {{- toYaml . | nindent 8 }} {{- end }} namespaceSelector: matchNames: - {{ $.Release.Namespace }} selector: matchLabels: {{- include "script-exporter.selectorLabels" $ | nindent 6 }} {{- end }} {{- end }} {{- end }} script_exporter-3.0.1/charts/script-exporter/values.yaml000066400000000000000000000216711500336103100235670ustar00rootroot00000000000000# Default values for Script Exporter. # This is a YAML-formatted file. # Declare variables to be passed into your templates. nameOverride: "" fullnameOverride: "" ## The number of Pods, which are created by the Deployment. ## See: https://kubernetes.io/docs/concepts/workloads/controllers/deployment/ ## replicaCount: 1 ## Specify a list of image pull secrets, to avoid the DockerHub rate limit or to pull the Script Exporter image from a ## private registry. ## See: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ ## imagePullSecrets: [] ## Set the image which should be used for Script Exporter. ## image: repository: ghcr.io/ricoberger/script_exporter pullPolicy: IfNotPresent # Overrides the image tag whose default is the chart appVersion. tag: "" ## Specify security settings for the created Pods. To set the security settings for the Script Exporter Container use ## the corresponding "securityContext" field. ## See: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/#set-the-security-context-for-a-pod ## podSecurityContext: {} # fsGroup: 2000 ## Specify security settings for the Script Exporter Container. They override settings made at the Pod level via the ## "podSecurityContext" when there is overlap. ## See: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/#set-the-security-context-for-a-container ## securityContext: {} # capabilities: # drop: # - ALL # readOnlyRootFilesystem: true # runAsNonRoot: true # runAsUser: 1000 ## We usually recommend not to specify default resources and to leave this as a conscious choice for the user. This ## also increases chances charts run on environments with little resources, such as Minikube. If you do want to ## specify resources, uncomment the following lines, adjust them as necessary, and remove the curly braces after ## 'resources:'. ## resources: {} # limits: # cpu: 100m # memory: 128Mi # requests: # cpu: 100m # memory: 128Mi ## Specify a map of key-value pairs, to assign the Pods to a specific set of nodes. ## See: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#nodeselector ## nodeSelector: {} ## Specify the tolerations for the Script Exporter Pods. ## See: https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/ ## tolerations: [] ## Specify a node affinity or inter-pod affinity / anti-affinity for an advanced scheduling of the Script Exporter Pods. ## See: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#affinity-and-anti-affinity ## affinity: {} ## Topology spread constraints rely on node labels to identify the topology domain(s) that each Node is in. ## See: https://kubernetes.io/docs/concepts/workloads/pods/pod-topology-spread-constraints/ ## topologySpreadConstraints: [] # - maxSkew: 1 # topologyKey: topology.kubernetes.io/zone # whenUnsatisfiable: DoNotSchedule # labelSelector: # matchLabels: # app.kubernetes.io/name=hub ## Specify additional volumes for the Script Exporter deployment. ## See: https://kubernetes.io/docs/concepts/storage/volumes/ ## volumes: [] # - name: scripts # configMap: # name: scripts ## Specify additional volumeMounts for the Script Exporter container. ## See: https://kubernetes.io/docs/concepts/storage/volumes/ ## volumeMounts: [] # - name: scripts # mountPath: /script_exporter/scripts # readOnly: true ## Specify additional arguments for the Script Exporter container. ## args: - --config.files=/script_exporter/scripts/scripts.yaml ## Specify additional environment variables for the Script Exporter container. ## env: [] # - name: MY_ENV_VAR # value: MY_ENV_VALUE ## Specify additional labels and annotations for the created Pods. ## podAnnotations: {} podLabels: {} ## Set the type for the created service: ClusterIP, NodePort, LoadBalancer. ## See: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types ## service: type: ClusterIP annotations: {} labels: {} serviceAccount: ## If true, a ServiceAccount is created. ## create: false ## The name of the ServiceAccount to use. This field is required if `create` is true. ## name: "" ## Specify annotations to add to the ServiceAccount. ## annotations: {} ## Create a Service Monitor for the Prometheus Operator. ## See: https://github.com/coreos/prometheus-operator ## serviceMonitor: ## If true, a ServiceMonitor CRD is created for a prometheus operator ## https://github.com/coreos/prometheus-operator for each target ## enabled: false ## Namespace for the ServiceMonitor. Fallback to the the release namespace. ## namespace: "" ## Interval at which metrics should be scraped. Fallback to the Prometheus default unless specified. ## interval: "" ## Timeout after which the scrape is ended. Fallback to the Prometheus default unless specified. ## scrapeTimeout: "" ## Additional labels that are used by the Prometheus installed in your cluster to select Service Monitors to work with ## See: https://github.com/coreos/prometheus-operator/blob/master/Documentation/api.md#prometheusspec ## labels: {} ## HonorLabels chooses the metric's labels on collisions with target labels. ## honorLabels: true ## MetricRelabelConfigs to apply to samples before ingestion. ## metricRelabelings: [] # - action: keep # regex: 'kube_(daemonset|deployment|pod|namespace|node|statefulset).+' # sourceLabels: [__name__] ## RelabelConfigs to apply to samples before scraping. Prometheus Operator automatically adds relabelings for a few ## standard Kubernetes fields and replaces original scrape job name with __tmp_prometheus_job_name. ## relabelings: [] # - sourceLabels: [__meta_kubernetes_pod_node_name] # separator: ; # regex: ^(.*)$ # targetLabel: nodename # replacement: $1 # action: replace ## Automatically create a serviceMonitor for each script defined in the 'config' section below ## This option is mutaly exclusive with the following 'targets' list ## autoCreate: enabled: true targets: [] # - name: example # Human readable URL that will appear in Prometheus / AlertManager # script: ping # Name of the script to target. # labels: {} # Map of labels for ServiceMonitor. Overrides value set in `defaults` # interval: 60s # Scraping interval. Overrides value set in `defaults` # scrapeTimeout: 60s # Scrape timeout. Overrides value set in `defaults` # additionalMetricsRelabels: [] # List of metric relabeling actions to run # additionalRelabeling: [] # List of relabeling actions to run ## Create a Service Monitor for the Prometheus Operator. ## See: https://github.com/coreos/prometheus-operator ## selfServiceMonitor: ## If true, a ServiceMonitor CRD is created for a prometheus operator ## https://github.com/coreos/prometheus-operator for each target ## enabled: false ## Namespace for the ServiceMonitor. Fallback to the the release namespace. ## namespace: "" ## Interval at which metrics should be scraped. Fallback to the Prometheus default unless specified. ## interval: "" ## Timeout after which the scrape is ended. Fallback to the Prometheus default unless specified. ## scrapeTimeout: "" ## Additional labels that are used by the Prometheus installed in your cluster to select Service Monitors to work with ## See: https://github.com/coreos/prometheus-operator/blob/master/Documentation/api.md#prometheusspec ## labels: {} ## HonorLabels chooses the metric's labels on collisions with target labels. ## honorLabels: true ## MetricRelabelConfigs to apply to samples before ingestion. ## metricRelabelings: [] # - action: keep # regex: 'kube_(daemonset|deployment|pod|namespace|node|statefulset).+' # sourceLabels: [__name__] ## RelabelConfigs to apply to samples before scraping. Prometheus Operator automatically adds relabelings for a few ## standard Kubernetes fields and replaces original scrape job name with __tmp_prometheus_job_name. ## relabelings: [] # - sourceLabels: [__meta_kubernetes_pod_node_name] # separator: ; # regex: ^(.*)$ # targetLabel: nodename # replacement: $1 # action: replace ## The configuration for the Script Exporter as shown in ## https://github.com/ricoberger/script_exporter/tree/main#usage-and-configuration ## config: | scripts: - name: example command: - /script_exporter/scripts/example.sh ## A list of scripts which should be added to the container. It is also possible to add some scripts to the container, ## via the "volumes" and "volumeMounts" values. ## scripts: example.sh: | #!/usr/bin/env bash echo '# HELP custom_metric A custom metric exported by the script' echo '# TYPE custom_metric gauge' echo 'custom_metric{script="example"} 1' script_exporter-3.0.1/cmd/000077500000000000000000000000001500336103100155025ustar00rootroot00000000000000script_exporter-3.0.1/cmd/main.go000066400000000000000000000217161500336103100167640ustar00rootroot00000000000000package main import ( "errors" "fmt" "net" "net/http" "net/url" "os" "os/signal" "path" "strings" "syscall" "time" "github.com/ricoberger/script_exporter/config" "github.com/ricoberger/script_exporter/discovery" "github.com/ricoberger/script_exporter/prober" "github.com/alecthomas/kingpin/v2" "github.com/goccy/go-yaml" "github.com/prometheus/client_golang/prometheus" versioncollector "github.com/prometheus/client_golang/prometheus/collectors/version" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/common/promslog" "github.com/prometheus/common/promslog/flag" "github.com/prometheus/common/version" "github.com/prometheus/exporter-toolkit/web" webflag "github.com/prometheus/exporter-toolkit/web/kingpinflag" ) var ( sc = config.NewSafeConfig(prometheus.DefaultRegisterer) configFiles = kingpin.Flag("config.files", "Configuration files. To specify multiple configuration files glob patterns can be used.").Default("scripts.yaml").String() configCheck = kingpin.Flag("config.check", "If true, validate the configuration files and then exit.").Default().Bool() logEnv = kingpin.Flag("log.env", "If true, environment variables passed to a script will be logged.").Default().Bool() scriptNoArgs = kingpin.Flag("script.no-args", "Restrict script to accept arguments.").Default().Bool() scriptTimeoutOffset = kingpin.Flag("script.timeout-offset", "Offset to subtract from timeout in seconds.").Default("0.5").Float64() externalURL = kingpin.Flag("web.external-url", "The URL under which Script Exporter is externally reachable (for example, if Script Exporter is served via a reverse proxy). Used for generating relative and absolute links back to Script Exporter itself. If the URL has a path portion, it will be used to prefix all HTTP endpoints served by Script Exporter. If omitted, relevant URL components will be derived automatically.").PlaceHolder("").String() routePrefix = kingpin.Flag("web.route-prefix", "Prefix for the internal routes of web endpoints. Defaults to path of --web.external-url.").PlaceHolder("").String() discoveryHost = kingpin.Flag("discovery.host", "Host for service discovery.").Default("").String() discoveryPort = kingpin.Flag("discovery.port", "Port for service discovery.").Default("").String() discoveryScheme = kingpin.Flag("discovery.scheme", "Scheme for service discovery.").Default("").String() toolkitFlags = webflag.AddFlags(kingpin.CommandLine, ":9469") ) func init() { prometheus.MustRegister(versioncollector.NewCollector("script_exporter")) } func run(stopCh chan bool) int { kingpin.CommandLine.UsageWriter(os.Stdout) promslogConfig := &promslog.Config{} flag.AddFlags(kingpin.CommandLine, promslogConfig) kingpin.Version(version.Print("script_exporter")) kingpin.HelpFlag.Short('h') kingpin.Parse() logger := promslog.New(promslogConfig) logger.Info("Starting script_exporter", "version", version.Info()) logger.Info(version.BuildContext()) if err := sc.ReloadConfig(*configFiles, logger); err != nil { logger.Error("Error loading config", "err", err) return 1 } if *configCheck { logger.Info("Config files are ok, exiting...") return 0 } logger.Info("Loaded config files") // Infer or set Script Exporter externalURL listenAddrs := toolkitFlags.WebListenAddresses if *externalURL == "" && *toolkitFlags.WebSystemdSocket { logger.Error("Cannot automatically infer external URL with systemd socket listener. Please provide --web.external-url") return 1 } else if *externalURL == "" && len(*listenAddrs) > 1 { logger.Info("Inferring external URL from first provided listen address") } beURL, err := computeExternalURL(*externalURL, (*listenAddrs)[0]) if err != nil { logger.Error("failed to determine external URL", "err", err) return 1 } logger.Debug(beURL.String()) // Default -web.route-prefix to path of -web.external-url. if *routePrefix == "" { *routePrefix = beURL.Path } // routePrefix must always be at least '/'. *routePrefix = "/" + strings.Trim(*routePrefix, "/") // routePrefix requires path to have trailing "/" in order for browsers to // interpret the path-relative path correctly, instead of stripping it. if *routePrefix != "/" { *routePrefix = *routePrefix + "/" } logger.Debug(*routePrefix) hup := make(chan os.Signal, 1) reloadCh := make(chan chan error) signal.Notify(hup, syscall.SIGHUP) go func() { for { select { case <-hup: if err := sc.ReloadConfig(*configFiles, logger); err != nil { logger.Error("Error reloading config", "err", err) continue } logger.Info("Reloaded config file") case rc := <-reloadCh: if err := sc.ReloadConfig(*configFiles, logger); err != nil { logger.Error("Error reloading config", "err", err) rc <- err } else { logger.Info("Reloaded config file") rc <- nil } } } }() // Match Prometheus behavior and redirect over externalURL for root path // only if routePrefix is different than "/". if *routePrefix != "/" { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } http.Redirect(w, r, beURL.String(), http.StatusFound) }) } http.HandleFunc(path.Join(*routePrefix, "/-/reload"), func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { w.WriteHeader(http.StatusMethodNotAllowed) fmt.Fprintf(w, "This endpoint requires a POST request.\n") return } rc := make(chan error) reloadCh <- rc if err := <-rc; err != nil { http.Error(w, fmt.Sprintf("failed to reload config: %s", err), http.StatusInternalServerError) } }) http.Handle(path.Join(*routePrefix, "/metrics"), promhttp.Handler()) http.HandleFunc(path.Join(*routePrefix, "/-/healthy"), func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte("Healthy")) }) http.HandleFunc(path.Join(*routePrefix, "/probe"), func(w http.ResponseWriter, r *http.Request) { sc.Lock() config := sc.C sc.Unlock() prober.Handler(w, r, config, logger, *logEnv, *scriptTimeoutOffset, *scriptNoArgs) }) http.HandleFunc(path.Join(*routePrefix, "/discovery"), func(w http.ResponseWriter, r *http.Request) { sc.Lock() config := sc.C sc.Unlock() discovery.Handler(w, r, config, logger, *discoveryHost, *discoveryPort, *discoveryScheme, *routePrefix) }) http.HandleFunc(*routePrefix, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") w.Write([]byte(` Script Exporter

Script Exporter

  • version: ` + version.Version + `
  • revision: ` + version.GetRevision() + `
  • branch: ` + version.Branch + `
  • buildUser: ` + version.BuildUser + `
  • buildDate: ` + version.BuildDate + `
  • goVersion: ` + version.GoVersion + `
  • platform: ` + version.GoOS + `/` + version.GoArch + `
  • tags: ` + version.GetTags() + `
`)) }) http.HandleFunc(path.Join(*routePrefix, "/config"), func(w http.ResponseWriter, r *http.Request) { sc.RLock() c, err := yaml.Marshal(sc.C) sc.RUnlock() if err != nil { logger.Warn("Error marshalling configuration", "err", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/plain") w.Write(c) }) srv := &http.Server{ ReadHeaderTimeout: 10 * time.Second, } srvc := make(chan struct{}) term := make(chan os.Signal, 1) signal.Notify(term, os.Interrupt, syscall.SIGTERM) go func() { if err := web.ListenAndServe(srv, toolkitFlags, logger); err != nil { logger.Error("Error starting HTTP server", "err", err) close(srvc) } }() for { select { case <-stopCh: logger.Info("Service received stop message...") return 0 case <-term: logger.Info("Received SIGTERM, exiting gracefully...") return 0 case <-srvc: return 1 } } } func startsOrEndsWithQuote(s string) bool { return strings.HasPrefix(s, "\"") || strings.HasPrefix(s, "'") || strings.HasSuffix(s, "\"") || strings.HasSuffix(s, "'") } // computeExternalURL computes a sanitized external URL from a raw input. It // infers unset URL parts from the OS and the given listen address. func computeExternalURL(u, listenAddr string) (*url.URL, error) { if u == "" { hostname, err := os.Hostname() if err != nil { return nil, err } _, port, err := net.SplitHostPort(listenAddr) if err != nil { return nil, err } u = fmt.Sprintf("http://%s:%s/", hostname, port) } if startsOrEndsWithQuote(u) { return nil, errors.New("URL must not begin or end with quotes") } eu, err := url.Parse(u) if err != nil { return nil, err } ppref := strings.TrimRight(eu.Path, "/") if ppref != "" && !strings.HasPrefix(ppref, "/") { ppref = "/" + ppref } eu.Path = ppref return eu, nil } script_exporter-3.0.1/cmd/main_test.go000066400000000000000000000015171500336103100200200ustar00rootroot00000000000000package main import ( "testing" "github.com/stretchr/testify/require" ) func TestComputeExternalURL(t *testing.T) { tests := []struct { input string valid bool }{ { input: "", valid: true, }, { input: "http://proxy.com/prometheus", valid: true, }, { input: "'https://url/prometheus'", valid: false, }, { input: "'relative/path/with/quotes'", valid: false, }, { input: "http://alertmanager.company.com", valid: true, }, { input: "https://double--dash.de", valid: true, }, { input: "'http://starts/with/quote", valid: false, }, { input: "ends/with/quote\"", valid: false, }, } for _, test := range tests { _, err := computeExternalURL(test.input, "0.0.0.0:9469") if test.valid { require.NoError(t, err) } else { require.Error(t, err) } } } script_exporter-3.0.1/cmd/main_unix.go000066400000000000000000000002241500336103100200160ustar00rootroot00000000000000//go:build darwin || linux // +build darwin linux package main import ( "os" ) func main() { stopCh := make(chan bool) os.Exit(run(stopCh)) } script_exporter-3.0.1/cmd/main_windows.go000066400000000000000000000023561500336103100205350ustar00rootroot00000000000000//go:build windows // +build windows package main import ( "log/slog" "os" "golang.org/x/sys/windows/svc" ) type windowsService struct { stopCh chan<- bool } func (ws *windowsService) Execute(args []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (ssec bool, errno uint32) { const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown changes <- svc.Status{State: svc.StartPending} changes <- svc.Status{State: svc.Running, Accepts: cmdsAccepted} loop: for { select { case c := <-r: switch c.Cmd { case svc.Interrogate: changes <- c.CurrentStatus case svc.Stop, svc.Shutdown: ws.stopCh <- true break loop default: slog.Warn("Unexpected control request", slog.Any("request", c)) } } } changes <- svc.Status{State: svc.StopPending} return } func main() { isService, err := svc.IsWindowsService() if err != nil { slog.Error("Failed to determine if Script Exporter is executed as Windows service", slog.Any("error", err)) os.Exit(1) } stopCh := make(chan bool) if isService { go func() { err = svc.Run("script_exporter", &windowsService{stopCh: stopCh}) if err != nil { slog.Error("Failed to run Windows service", slog.Any("error", err)) } }() } os.Exit(run(stopCh)) } script_exporter-3.0.1/config/000077500000000000000000000000001500336103100162045ustar00rootroot00000000000000script_exporter-3.0.1/config/config.go000066400000000000000000000062561500336103100200110ustar00rootroot00000000000000package config import ( "fmt" "log/slog" "os" "path/filepath" "sync" "github.com/goccy/go-yaml" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" ) type Config struct { Scripts []Script `yaml:"scripts"` } func (c *Config) GetScript(name string) *Script { for _, script := range c.Scripts { if name == script.Name { return &script } } return nil } type Script struct { Name string `yaml:"name"` Command []string `yaml:"command"` Args []string `yaml:"args"` Env map[string]string `yaml:"env"` AllowEnvOverwrite bool `yaml:"allow_env_overwrite"` Sudo bool `yaml:"sudo"` Output Output `yaml:"output"` Timeout Timeout `yaml:"timeout"` Cache Cache `yaml:"cache"` Discovery Discovery `yaml:"discovery"` } type Output struct { Ignore bool `yaml:"ignore"` IgnoreOnError bool `yaml:"ignore_on_error"` } type Timeout struct { MaxTimeout float64 `yaml:"max_timeout"` Enforced bool `yaml:"enforced"` WaitDelay float64 `yaml:"wait_delay"` } type Cache struct { Duration *float64 `yaml:"duration"` CacheOnError bool `yaml:"cache_on_error"` UseExpiredCacheOnError bool `yaml:"use_expired_cache_on_error"` } type Discovery struct { Params map[string]string `yaml:"params"` ScrapeInterval string `yaml:"scrape_interval"` ScrapeTimeout string `yaml:"scrape_timeout"` } type SafeConfig struct { sync.RWMutex C *Config configReloadSuccess prometheus.Gauge configReloadSeconds prometheus.Gauge } func NewSafeConfig(reg prometheus.Registerer) *SafeConfig { configReloadSuccess := promauto.With(reg).NewGauge(prometheus.GaugeOpts{ Namespace: "script_exporter", Name: "config_last_reload_successful", Help: "Script Exporter config loaded successfully.", }) configReloadSeconds := promauto.With(reg).NewGauge(prometheus.GaugeOpts{ Namespace: "script_exporter", Name: "config_last_reload_success_timestamp_seconds", Help: "Timestamp of the last successful configuration reload.", }) return &SafeConfig{C: &Config{}, configReloadSuccess: configReloadSuccess, configReloadSeconds: configReloadSeconds} } func (sc *SafeConfig) ReloadConfig(configFiles string, logger *slog.Logger) (err error) { var c = &Config{} defer func() { if err != nil { sc.configReloadSuccess.Set(0) } else { sc.configReloadSuccess.Set(1) sc.configReloadSeconds.SetToCurrentTime() } }() files, err := filepath.Glob(configFiles) if err != nil { return err } for _, file := range files { var fc = &Config{} yamlReader, err := os.Open(file) if err != nil { return fmt.Errorf("error reading config file: %s", err) } defer yamlReader.Close() decoder := yaml.NewDecoder(yamlReader, yaml.DisallowUnknownField()) if err = decoder.Decode(fc); err != nil { return fmt.Errorf("error parsing config file: %s", err) } c.Scripts = append(c.Scripts, fc.Scripts...) } sc.Lock() sc.C = c sc.Unlock() return nil } script_exporter-3.0.1/config/config_test.go000066400000000000000000000016361500336103100210450ustar00rootroot00000000000000package config import ( "testing" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" ) func TestNewSafeConfig(t *testing.T) { t.Run("should load configuration", func(t *testing.T) { sc := NewSafeConfig(prometheus.NewRegistry()) err := sc.ReloadConfig("./testdata/config-valid.yaml", nil) require.NoError(t, err) require.NotNil(t, sc.C) t.Run("should return script", func(t *testing.T) { script := sc.C.GetScript("output") require.NotNil(t, script) require.Equal(t, "output", script.Name) }) t.Run("should return nil if script is not found", func(t *testing.T) { script := sc.C.GetScript("invalid") require.Nil(t, script) }) }) t.Run("should return error for invalid configuration", func(t *testing.T) { sc := NewSafeConfig(prometheus.NewRegistry()) err := sc.ReloadConfig("./testdata/config-invalid.yaml", nil) require.Error(t, err) }) } script_exporter-3.0.1/config/testdata/000077500000000000000000000000001500336103100200155ustar00rootroot00000000000000script_exporter-3.0.1/config/testdata/config-invalid.yaml000066400000000000000000000001021500336103100235630ustar00rootroot00000000000000scripts: - name: output command: ./prober/scripts/output.sh script_exporter-3.0.1/config/testdata/config-valid.yaml000066400000000000000000000001121500336103100232350ustar00rootroot00000000000000scripts: - name: output command: - ./prober/scripts/output.sh script_exporter-3.0.1/discovery/000077500000000000000000000000001500336103100167465ustar00rootroot00000000000000script_exporter-3.0.1/discovery/handler.go000066400000000000000000000034531500336103100207170ustar00rootroot00000000000000package discovery import ( "encoding/json" "fmt" "log/slog" "net/http" "path" "strings" "github.com/ricoberger/script_exporter/config" ) type target struct { Targets []string `json:"targets"` Labels map[string]string `json:"labels"` } func Handler(w http.ResponseWriter, r *http.Request, c *config.Config, logger *slog.Logger, discoveryHost string, discoveryPort string, discoveryScheme string, routePrefix string) { w.Header().Set("Content-Type", "application/json") host := "" port := "" if strings.Contains(r.Host, ":") { host = strings.Split(r.Host, ":")[0] port = strings.Split(r.Host, ":")[1] } else { host = r.Host port = "9469" } scheme := "http" if r.TLS != nil { scheme = "https" } if discoveryHost != "" { host = discoveryHost } if discoveryPort != "" { port = discoveryPort } if discoveryScheme != "" { scheme = discoveryScheme } var targets []target for _, script := range c.Scripts { labels := map[string]string{ "__scheme__": scheme, "__metrics_path__": path.Join(routePrefix, "/probe"), "__param_script": script.Name, } if script.Discovery.ScrapeInterval != "" { labels["__scrape_interval__"] = script.Discovery.ScrapeInterval } if script.Discovery.ScrapeTimeout != "" { labels["__scrape_timeout__"] = script.Discovery.ScrapeTimeout } var params []string for key, value := range script.Discovery.Params { params = append(params, key) labels[fmt.Sprintf("__param_%s", key)] = value } labels["__param_params"] = strings.Join(params, ",") targets = append(targets, target{ Targets: []string{fmt.Sprintf("%s:%s", host, port)}, Labels: labels, }) } data, err := json.Marshal(targets) if err != nil { logger.Error("Failed to create discovery targets", slog.Any("error", err)) } w.Write(data) } script_exporter-3.0.1/discovery/handler_test.go000066400000000000000000000065311500336103100217560ustar00rootroot00000000000000package discovery import ( "context" "io" "net/http" "net/http/httptest" "testing" "github.com/ricoberger/script_exporter/config" "github.com/stretchr/testify/require" ) func TestHandler(t *testing.T) { t.Run("should return targets", func(t *testing.T) { var c = config.Config{ Scripts: []config.Script{{ Name: "test", Command: []string{"test"}, }}, } req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "http://localhost:9469/dicovery", nil) w := httptest.NewRecorder() Handler(w, req, &c, nil, "", "", "", "") res := w.Result() defer res.Body.Close() data, err := io.ReadAll(res.Body) require.NoError(t, err) require.Equal(t, res.StatusCode, http.StatusOK) require.Equal(t, "[{\"targets\":[\"localhost:9469\"],\"labels\":{\"__metrics_path__\":\"/probe\",\"__param_params\":\"\",\"__param_script\":\"test\",\"__scheme__\":\"http\"}}]", string(data)) }) t.Run("should use discovery configuration", func(t *testing.T) { var c = config.Config{ Scripts: []config.Script{{ Name: "test", Command: []string{"test"}, Discovery: config.Discovery{ Params: map[string]string{"seconds": "5"}, ScrapeInterval: "10s", ScrapeTimeout: "5s", }, }}, } req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "http://localhost:9469/dicovery", nil) w := httptest.NewRecorder() Handler(w, req, &c, nil, "", "", "", "") res := w.Result() defer res.Body.Close() data, err := io.ReadAll(res.Body) require.NoError(t, err) require.Equal(t, res.StatusCode, http.StatusOK) require.Equal(t, "[{\"targets\":[\"localhost:9469\"],\"labels\":{\"__metrics_path__\":\"/probe\",\"__param_params\":\"seconds\",\"__param_script\":\"test\",\"__param_seconds\":\"5\",\"__scheme__\":\"http\",\"__scrape_interval__\":\"10s\",\"__scrape_timeout__\":\"5s\"}}]", string(data)) }) t.Run("should use discovery flags", func(t *testing.T) { var c = config.Config{ Scripts: []config.Script{{ Name: "test", Command: []string{"test"}, }}, } req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "http://localhost:9469/dicovery", nil) w := httptest.NewRecorder() Handler(w, req, &c, nil, "script_exporter", "9999", "https", "/prefix") res := w.Result() defer res.Body.Close() data, err := io.ReadAll(res.Body) require.NoError(t, err) require.Equal(t, res.StatusCode, http.StatusOK) require.Equal(t, "[{\"targets\":[\"script_exporter:9999\"],\"labels\":{\"__metrics_path__\":\"/prefix/probe\",\"__param_params\":\"\",\"__param_script\":\"test\",\"__scheme__\":\"https\"}}]", string(data)) }) t.Run("should use default port", func(t *testing.T) { var c = config.Config{ Scripts: []config.Script{{ Name: "test", Command: []string{"test"}, }}, } req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "http://script_exporter/dicovery", nil) w := httptest.NewRecorder() Handler(w, req, &c, nil, "", "", "", "") res := w.Result() defer res.Body.Close() data, err := io.ReadAll(res.Body) require.NoError(t, err) require.Equal(t, res.StatusCode, http.StatusOK) require.Equal(t, "[{\"targets\":[\"script_exporter:9469\"],\"labels\":{\"__metrics_path__\":\"/probe\",\"__param_params\":\"\",\"__param_script\":\"test\",\"__scheme__\":\"http\"}}]", string(data)) }) } script_exporter-3.0.1/docker-compose.yaml000066400000000000000000000011121500336103100205300ustar00rootroot00000000000000services: script-exporter: container_name: script_exporter build: context: . restart: always ports: - 9469:9469 volumes: - ./scripts.yaml:/script_exporter/scripts.yaml - ./prober/scripts:/script_exporter/prober/scripts prometheus: container_name: prometheus image: quay.io/prometheus/prometheus:v3.2.1 restart: always command: - --config.file=/etc/prometheus/prometheus.yaml ports: - 9090:9090 volumes: - ./prometheus.yaml:/etc/prometheus/prometheus.yaml depends_on: - script-exporter script_exporter-3.0.1/go.mod000066400000000000000000000027521500336103100160530ustar00rootroot00000000000000module github.com/ricoberger/script_exporter go 1.24.0 require ( github.com/alecthomas/kingpin/v2 v2.4.0 github.com/goccy/go-yaml v1.17.1 github.com/prometheus/client_golang v1.22.0-rc.0 github.com/prometheus/common v0.63.0 github.com/prometheus/exporter-toolkit v0.14.0 github.com/stretchr/testify v1.10.0 golang.org/x/sys v0.31.0 ) require ( github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/jpillora/backoff v1.0.0 // indirect github.com/mdlayher/socket v0.4.1 // indirect github.com/mdlayher/vsock v1.2.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect golang.org/x/crypto v0.36.0 // indirect golang.org/x/net v0.38.0 // indirect golang.org/x/oauth2 v0.25.0 // indirect golang.org/x/sync v0.12.0 // indirect golang.org/x/text v0.23.0 // indirect google.golang.org/protobuf v1.36.5 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) script_exporter-3.0.1/go.sum000066400000000000000000000171371500336103100161030ustar00rootroot00000000000000github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY= github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0= github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= 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/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY= github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 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= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U= github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA= github.com/mdlayher/vsock v1.2.1 h1:pC1mTJTvjo1r9n9fbm7S1j04rCgCzhCOS5DY0zqHlnQ= github.com/mdlayher/vsock v1.2.1/go.mod h1:NRfCibel++DgeMD8z/hP+PPTjlNJsdPOmxcnENvE+SE= 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/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.22.0-rc.0 h1:meoqLyZIVEIiQxZmyVTOnzk/bA+n2pN2MXN8pSzX2ws= github.com/prometheus/client_golang v1.22.0-rc.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 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.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= github.com/prometheus/exporter-toolkit v0.14.0 h1:NMlswfibpcZZ+H0sZBiTjrA3/aBFHkNZqE+iCj5EmRg= github.com/prometheus/exporter-toolkit v0.14.0/go.mod h1:Gu5LnVvt7Nr/oqTBUC23WILZepW0nffNo10XdhQcwWA= 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.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 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= script_exporter-3.0.1/prober/000077500000000000000000000000001500336103100162305ustar00rootroot00000000000000script_exporter-3.0.1/prober/cache.go000066400000000000000000000023011500336103100176160ustar00rootroot00000000000000package prober import ( "fmt" "strings" "sync" "time" "github.com/ricoberger/script_exporter/config" ) var cache map[string]cacheEntry var cacheLock = sync.RWMutex{} type cacheEntry struct { cacheTime time.Time result scriptResult } func getCacheKey(script *config.Script, scriptParamValues []string) string { return fmt.Sprintf("%s--%s", script.Name, strings.Join(scriptParamValues, "-")) } func getCacheResult(script *config.Script, scriptParamValues []string, useExpiredCache bool) *scriptResult { cacheLock.RLock() defer cacheLock.RUnlock() if script.Cache.Duration == nil { return nil } if entry, ok := cache[getCacheKey(script, scriptParamValues)]; ok { if entry.cacheTime.Add(time.Duration(*script.Cache.Duration*float64(time.Second))).After(time.Now()) || useExpiredCache { return &entry.result } } return nil } func setCacheResult(script *config.Script, scriptParamValues []string, result scriptResult) { cacheLock.Lock() defer cacheLock.Unlock() if script.Cache.Duration == nil { return } if cache == nil { cache = make(map[string]cacheEntry) } cache[getCacheKey(script, scriptParamValues)] = cacheEntry{ cacheTime: time.Now(), result: result, } } script_exporter-3.0.1/prober/handler.go000066400000000000000000000304141500336103100201760ustar00rootroot00000000000000package prober import ( "bufio" "bytes" "context" "fmt" "log/slog" "net/http" "net/url" "os" "os/exec" "strconv" "strings" "time" "github.com/ricoberger/script_exporter/config" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/common/expfmt" ) type scriptResult struct { startTime time.Time success int exitCode int cached int output string } var ( metricScriptUnknownTotal = promauto.NewCounter(prometheus.CounterOpts{ Namespace: "script_exporter", Name: "script_unknown_total", Help: "Total number of unknown scripts requested by probes", }) metricReqInflight = promauto.NewGaugeVec(prometheus.GaugeOpts{ Namespace: "script_exporter", Name: "http_requests_inflight", Help: "Number of HTTP inflight requests, partitioned by script.", }, []string{"script"}) metricReqCount = promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: "script_exporter", Name: "http_requests_total", Help: "Number of HTTP requests processed, partitioned by script.", }, []string{"script"}) metricReqDurationSeconds = promauto.NewSummaryVec(prometheus.SummaryOpts{ Namespace: "script_exporter", Name: "http_request_duration_seconds", Help: "Latency of HTTP requests processed, partitioned by script.", Objectives: map[float64]float64{0.25: 0.05, 0.5: 0.05, 0.75: 0.02, 0.9: 0.01, 0.99: 0.001, 1.0: 0.001}, }, []string{"script"}) ) func Handler(w http.ResponseWriter, r *http.Request, c *config.Config, logger *slog.Logger, logEnv bool, scriptTimeoutOffset float64, scriptNoArgs bool) { w.Header().Set("Content-Type", "text/plain") prometheusTimeout := r.Header.Get("X-Prometheus-Scrape-Timeout-Seconds") params := r.URL.Query() scriptNames := params["script"] if len(scriptNames) == 0 { logger.Error("'script' parameter is missing") metricScriptUnknownTotal.Inc() http.Error(w, "'script' parameter is missing", http.StatusBadRequest) return } for _, scriptName := range scriptNames { script := c.GetScript(scriptName) if script == nil { logger.Error("Script not found", "script", r.URL.Query().Get("script")) metricScriptUnknownTotal.Inc() http.Error(w, "Script not found", http.StatusBadRequest) return } metricReqInflight.WithLabelValues(scriptName).Inc() defer metricReqInflight.WithLabelValues(scriptName).Dec() start := time.Now() output := handleScript(script, params, logger, logEnv, prometheusTimeout, scriptTimeoutOffset, scriptNoArgs) logger.Debug("Script was run", slog.Duration("duration", time.Since(start)), slog.String("output", output)) metricReqCount.WithLabelValues(scriptName).Inc() metricReqDurationSeconds.WithLabelValues(scriptName).Observe(time.Since(start).Seconds()) fmt.Fprint(w, output) } } func handleScript(script *config.Script, params url.Values, logger *slog.Logger, logEnv bool, prometheusTimeout string, scriptTimeoutOffset float64, scriptNoArgs bool) string { // Get parameters, if the scriptNoArgs flag is set to true, we do not add // arguments from the params query parameter to the script. var scriptParamValues []string if !scriptNoArgs { scriptParams := params.Get("params") if scriptParams != "" { scriptParamValues = strings.Split(scriptParams, ",") for i, p := range scriptParamValues { scriptParamValues[i] = params.Get(p) } } } result := scriptResult{ startTime: time.Now(), success: 1, exitCode: -1, cached: 0, output: "", } // Check if the result of the script is cached and not stale. If this is the // case the getCacheResult function will return a scriptResult which we can // directly return. if cachedResult := getCacheResult(script, scriptParamValues, false); cachedResult != nil { cachedResult.startTime = result.startTime cachedResult.cached = 1 logger.Debug("Using cached script result", "script", script.Name) return generateScriptMetrics(script, *cachedResult) } // Get the timeout from either Prometheus's HTTP header or a URL query // parameter, clamped to a maximum specified through the configuration file. timeout := getTimeout(params, prometheusTimeout, scriptTimeoutOffset, script.Timeout.MaxTimeout) // Append arguments passed via scrape query parameters to the arguments // defined in the script configuration. runArgs := []string{} if script.Sudo { runArgs = append(runArgs, "sudo") } runArgs = append(runArgs, script.Command...) runArgs = append(runArgs, script.Args...) runArgs = append(runArgs, scriptParamValues...) // Get environment variables which should be set for the script from the // script configuration and the query parameters. If the allow_env_overwrite // option is set to true we overwrite environment variables from the script // configuration with the values from the query parameters. runEnv := make(map[string]string) for key, val := range script.Env { runEnv[key] = val } for key, val := range params { if _, ok := runEnv[key]; !ok || script.AllowEnvOverwrite { runEnv[key] = strings.Join(val, ",") } } output, exitCode, err := runScript(script, logger, logEnv, timeout, runArgs, runEnv) result.exitCode = exitCode result.output = getFormattedOutput(script, logger, output, err) if err != nil { result.success = 0 if script.Cache.UseExpiredCacheOnError { if cachedResult := getCacheResult(script, scriptParamValues, true); cachedResult != nil { cachedResult.startTime = result.startTime cachedResult.cached = 1 logger.Debug("Using cached script result", "script", script.Name) return generateScriptMetrics(script, *cachedResult) } } if script.Cache.CacheOnError { setCacheResult(script, scriptParamValues, result) } return generateScriptMetrics(script, result) } setCacheResult(script, scriptParamValues, result) return generateScriptMetrics(script, result) } func generateScriptMetrics(script *config.Script, result scriptResult) string { return ` # HELP script_success Script exit status (0 = error, 1 = success). # TYPE script_success gauge script_success{script="` + script.Name + `"} ` + fmt.Sprintf("%d", result.success) + ` # HELP script_duration_seconds Script execution time, in seconds. # TYPE script_duration_seconds gauge script_duration_seconds{script="` + script.Name + `"} ` + fmt.Sprintf("%f", time.Since(result.startTime).Seconds()) + ` # HELP script_exit_code The exit code of the script. # TYPE script_exit_code gauge script_exit_code{script="` + script.Name + `"} ` + fmt.Sprintf("%d", result.exitCode) + ` # HELP script_cached Script result is returned from cache (0 = no, 1 = yes). # TYPE script_cached gauge script_cached{script="` + script.Name + `"} ` + fmt.Sprintf("%d", result.cached) + ` ` + result.output + ` ` } // getTimeout gets the Prometheus scrape timeout (in seconds) from the HTTP // request, either from a 'timeout' query parameter or from the special HTTP // header that Prometheus inserts on scrapes, and returns it. If there is a // timeout, it is modified down by the offset. // // If the there is an error or no timeout is specified, it returns the // maxTimeout configured for the script (the default value for this is 0, which // means no timeout) func getTimeout(params url.Values, prometheusTimeout string, scriptTimeoutOffset float64, scriptMaxTimeout float64) float64 { v := params.Get("timeout") if v == "" { v = prometheusTimeout } if v == "" { return scriptMaxTimeout } ts, err := strconv.ParseFloat(v, 64) adjusted := ts - scriptTimeoutOffset switch { case err != nil: return scriptMaxTimeout case scriptMaxTimeout < adjusted && scriptMaxTimeout > 0: return scriptMaxTimeout case adjusted <= 0: return 0 default: return adjusted } } func runScript(script *config.Script, logger *slog.Logger, logEnv bool, timeout float64, args []string, env map[string]string) (string, int, error) { // Tentatively, we do not inherit the context from the HTTP request. Doing // so would provide automatic termination should the client close the // connection, but it would mean that all scripts would be subject to abrupt // termination regardless of any 'enforced' settings. Right now, abrupt // termination requires opting in in the configuration file. var cancel context.CancelFunc ctx := context.Background() deadline := time.Now().Add(time.Duration(timeout * float64(time.Second))) if timeout > 0 && script.Timeout.Enforced { ctx, cancel = context.WithDeadline(ctx, deadline) defer cancel() } //nolint:gosec cmd := exec.CommandContext(ctx, args[0], args[1:]...) // When the executed script spawns it's own child processes (e.g. "sleep") // the child process will not be killed when the context deadline is // exceeded. Because "cmd.Wait()" waits for the command to exit and for // outputs being copied it will only return if the child process also // finishes. To enforce the timeout we can set "cmd.WaitDelay" to a non-zero // value to ensure that the executet script is killed even if the io pipes // are not closed. // // See: // - https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773 // - https://stackoverflow.com/q/71714228 if script.Timeout.WaitDelay > 0 { cmd.WaitDelay = time.Duration(script.Timeout.WaitDelay * float64(time.Second)) } // Set environments variables cmd.Env = os.Environ() for key, value := range env { cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", key, value)) } // If the timeout larger than zero, it is exported into the environment as // $SCRIPT_TIMEOUT (its raw value) and $SCRIPT_DEADLINE, which is the Unix // timestamp (including fractional parts) when the deadline will expire. If // enforced is true, the timeout will be enforced by script_exporter, by // killing the script if the timeout is reached, and // $SCRIPT_TIMEOUT_ENFORCED will be set to 1 in the environment to inform // the script of this. if timeout > 0 { // Three digits of fractional precision in the seconds and the deadline // are probably excessive, given that we're running external programs. // But better slightly excessive than not enough precision. cmd.Env = append(cmd.Env, fmt.Sprintf("SCRIPT_TIMEOUT=%0.3f", timeout)) cmd.Env = append(cmd.Env, fmt.Sprintf("SCRIPT_DEADLINE=%0.3f", float64(deadline.UnixNano())/float64(time.Second))) var envEnforced int if script.Timeout.Enforced { envEnforced = 1 } cmd.Env = append(cmd.Env, fmt.Sprintf("SCRIPT_TIMEOUT_ENFORCED=%d", envEnforced)) } logEnvValues := "" if logEnv { logEnvValues = strings.Join(cmd.Env, ",") } var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr err := cmd.Run() if err != nil { if exitError, ok := err.(*exec.ExitError); ok { logger.Error("Script execution failed", slog.String("script", script.Name), slog.String("args", strings.Join(args, ",")), slog.String("env", logEnvValues), slog.String("stdout", stdout.String()), slog.String("stderr", stderr.String()), slog.Int("exitCode", exitError.ExitCode()), slog.Any("error", err)) return stdout.String(), exitError.ExitCode(), err } logger.Error("Script execution failed", slog.String("script", script.Name), slog.String("args", strings.Join(args, ",")), slog.String("env", logEnvValues), slog.String("stdout", stdout.String()), slog.String("stderr", stderr.String()), slog.Int("exitCode", -1), slog.Any("error", err)) return stdout.String(), -1, err } logger.Debug("Script execution succeeded", slog.String("script", script.Name), slog.String("args", strings.Join(args, ",")), slog.String("env", logEnvValues), slog.String("stdout", stdout.String()), slog.String("stderr", stderr.String()), slog.Int("exitCode", 0)) return stdout.String(), 0, nil } func getFormattedOutput(script *config.Script, logger *slog.Logger, output string, err error) string { if script.Output.Ignore { return "" } if err != nil && script.Output.IgnoreOnError { return "" } var formattedOutput string var parser expfmt.TextParser scanner := bufio.NewScanner(strings.NewReader(output)) for scanner.Scan() { if len(scanner.Text()) == 0 { continue } _, err := parser.TextToMetricFamilies(strings.NewReader(fmt.Sprintf("%s\n", scanner.Text()))) if err != nil { logger.Debug("Error parsing metric families", slog.String("script", script.Name), slog.String("output", scanner.Text()), slog.Any("error", err)) } else { formattedOutput += fmt.Sprintf("%s\n", scanner.Text()) } } return formattedOutput } script_exporter-3.0.1/prober/handler_test.go000066400000000000000000000152211500336103100212340ustar00rootroot00000000000000package prober import ( "context" "io" "log/slog" "net/http" "net/http/httptest" "os" "testing" "time" "github.com/ricoberger/script_exporter/config" "github.com/stretchr/testify/require" ) var logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) func TestHandler(t *testing.T) { t.Run("should return metrics", func(t *testing.T) { var c = config.Config{ Scripts: []config.Script{{ Name: "test", Command: []string{"sleep"}, Args: []string{"1"}, }}, } req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "/probe?script=test", nil) w := httptest.NewRecorder() Handler(w, req, &c, logger, false, 0.5, false) res := w.Result() defer res.Body.Close() data, err := io.ReadAll(res.Body) require.NoError(t, err) require.Equal(t, http.StatusOK, res.StatusCode) require.Contains(t, string(data), `script_success{script="test"} 1`) require.Contains(t, string(data), `script_duration_seconds{script="test"}`) require.Contains(t, string(data), `script_exit_code{script="test"} 0`) require.Contains(t, string(data), `script_cached{script="test"} 0`) }) t.Run("should return error if script is not found", func(t *testing.T) { var c = config.Config{} req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "/probe?script=test", nil) w := httptest.NewRecorder() Handler(w, req, &c, logger, false, 0.5, false) res := w.Result() defer res.Body.Close() require.Equal(t, http.StatusBadRequest, res.StatusCode) }) t.Run("should use max timeout", func(t *testing.T) { var c = config.Config{ Scripts: []config.Script{{ Name: "test", Command: []string{"sleep"}, Args: []string{"5"}, Timeout: config.Timeout{ MaxTimeout: 1, Enforced: true, }, }}, } startTime := time.Now() req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "/probe?script=test", nil) w := httptest.NewRecorder() Handler(w, req, &c, logger, false, 0.5, false) res := w.Result() defer res.Body.Close() data, err := io.ReadAll(res.Body) require.NoError(t, err) require.Equal(t, http.StatusOK, res.StatusCode) require.Contains(t, string(data), `script_success{script="test"} 0`) require.Contains(t, string(data), `script_duration_seconds{script="test"}`) require.Contains(t, string(data), `script_exit_code{script="test"} -1`) require.Contains(t, string(data), `script_cached{script="test"} 0`) require.Less(t, time.Since(startTime).Seconds(), float64(2)) }) t.Run("should not enforce max timeout when wait delay is not set", func(t *testing.T) { var c = config.Config{ Scripts: []config.Script{{ Name: "test", Command: []string{"./scripts/sleep.sh"}, Args: []string{"5"}, Timeout: config.Timeout{ MaxTimeout: 1, Enforced: true, }, }}, } startTime := time.Now() req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "/probe?script=test", nil) w := httptest.NewRecorder() Handler(w, req, &c, logger, false, 0.5, false) res := w.Result() defer res.Body.Close() data, err := io.ReadAll(res.Body) require.NoError(t, err) require.Equal(t, http.StatusOK, res.StatusCode) require.Contains(t, string(data), `script_success{script="test"} 0`) require.Contains(t, string(data), `script_duration_seconds{script="test"}`) require.Contains(t, string(data), `script_exit_code{script="test"} -1`) require.Contains(t, string(data), `script_cached{script="test"} 0`) require.Greater(t, time.Since(startTime).Seconds(), float64(2)) }) t.Run("should enforce max timeout when wait delay is set", func(t *testing.T) { var c = config.Config{ Scripts: []config.Script{{ Name: "test", Command: []string{"./scripts/sleep.sh"}, Args: []string{"5"}, Timeout: config.Timeout{ MaxTimeout: 1, Enforced: true, WaitDelay: 0.01, }, }}, } startTime := time.Now() req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "/probe?script=test", nil) w := httptest.NewRecorder() Handler(w, req, &c, logger, false, 0.5, false) res := w.Result() defer res.Body.Close() data, err := io.ReadAll(res.Body) require.NoError(t, err) require.Equal(t, http.StatusOK, res.StatusCode) require.Contains(t, string(data), `script_success{script="test"} 0`) require.Contains(t, string(data), `script_duration_seconds{script="test"}`) require.Contains(t, string(data), `script_exit_code{script="test"} -1`) require.Contains(t, string(data), `script_cached{script="test"} 0`) require.Less(t, time.Since(startTime).Seconds(), float64(2)) }) t.Run("should use parameters as arguments and set environemnt variables", func(t *testing.T) { var c = config.Config{ Scripts: []config.Script{{ Name: "test", Command: []string{"sleep"}, Env: map[string]string{"HELOL": "WORLD"}, }}, } startTime := time.Now() req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "/probe?script=test¶ms=seconds&seconds=5", nil) w := httptest.NewRecorder() Handler(w, req, &c, logger, false, 0.5, false) res := w.Result() defer res.Body.Close() data, err := io.ReadAll(res.Body) require.NoError(t, err) require.Equal(t, http.StatusOK, res.StatusCode) require.Contains(t, string(data), `script_success{script="test"} 1`) require.Contains(t, string(data), `script_duration_seconds{script="test"}`) require.Contains(t, string(data), `script_exit_code{script="test"} 0`) require.Contains(t, string(data), `script_cached{script="test"} 0`) require.Greater(t, time.Since(startTime).Seconds(), float64(5)) }) t.Run("should cache result", func(t *testing.T) { cacheDuration := float64(10) var c = config.Config{ Scripts: []config.Script{{ Name: "test", Command: []string{"sleep"}, Args: []string{"5"}, Cache: config.Cache{ Duration: &cacheDuration, }, }}, } // Uncached startTime1 := time.Now() req1, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "/probe?script=test", nil) w1 := httptest.NewRecorder() Handler(w1, req1, &c, logger, false, 0.5, false) res1 := w1.Result() defer res1.Body.Close() require.Equal(t, http.StatusOK, res1.StatusCode) require.Greater(t, time.Since(startTime1).Seconds(), float64(5)) // Cached startTime2 := time.Now() req2, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "/probe?script=test", nil) w2 := httptest.NewRecorder() Handler(w2, req2, &c, logger, false, 0.5, false) res2 := w2.Result() defer res2.Body.Close() require.Equal(t, http.StatusOK, res2.StatusCode) require.Less(t, time.Since(startTime2).Seconds(), float64(2)) }) } script_exporter-3.0.1/prober/scripts/000077500000000000000000000000001500336103100177175ustar00rootroot00000000000000script_exporter-3.0.1/prober/scripts/docker.sh000077500000000000000000000003761500336103100215330ustar00rootroot00000000000000#!/usr/bin/env bash # Run the following command to start a docker container using the i386/busybox image. # docker run --name busybox -it --rm i386/busybox result="$(docker exec -i busybox ls -la | wc -l)" echo "$result" echo "number_of_files $result" script_exporter-3.0.1/prober/scripts/output.sh000077500000000000000000000007711500336103100216230ustar00rootroot00000000000000#!/usr/bin/env bash echo '# HELP test_first_test' echo '# TYPE test_first_test gauge' echo 'first_test{label1="test_1_label_1"} 1' echo '' echo '# HELP test_second_test' echo '# TYPE test_second_test gauge' echo 'second_test{label1="test_2_label_1",label2="test_2_label_2"} 2.71828182846' echo '' echo '# HELP test_third_test' echo '# TYPE test_third_test gauge' echo 'third_test{} 3,14159265359' echo '' echo 'Fourth test' echo 'fourth label1="test_1_label_1 4' echo '' echo 'Fifth test' echo 'fifth 5' script_exporter-3.0.1/prober/scripts/ping.sh000077500000000000000000000000671500336103100212160ustar00rootroot00000000000000#!/usr/bin/env bash set -e ping -c 3 $1 &> /dev/null script_exporter-3.0.1/prober/scripts/showtimeout.sh000077500000000000000000000014361500336103100226510ustar00rootroot00000000000000#!/usr/bin/env bash echo "# HELP script_has_timeout Whether this script is run with a timeout." echo "# TYPE script_has_timeout gauge" if [ -z "$SCRIPT_TIMEOUT" ]; then echo "script_has_timeout{} 0" exit 0 fi echo "script_has_timeout{} 1" echo "# HELP script_timeout_seconds Timeout of the script in seconds" echo "# TYPE script_timeout_seconds gauge" echo "script_timeout_seconds{} $SCRIPT_TIMEOUT" echo "# HELP script_deadline_seconds Unix timestamp when the timeout will expire" echo "# TYPE script_deadline_seconds gauge" echo "script_deadline_seconds{} $SCRIPT_DEADLINE" echo "# HELP script_timeout_enforced Whether or not script_exporter is enforcing a timeout on the script." echo "# TYPE script_timeout_enforced gauge" echo "script_timeout_enforced{} $SCRIPT_TIMEOUT_ENFORCED" exit 0 script_exporter-3.0.1/prober/scripts/sleep.sh000077500000000000000000000000771500336103100213720ustar00rootroot00000000000000#!/usr/bin/env bash sleep "$1" echo "sleep{seconds=\"$1\"} 1" script_exporter-3.0.1/prometheus.yaml000066400000000000000000000032461500336103100200230ustar00rootroot00000000000000global: scrape_interval: 15s scrape_timeout: 10s scrape_configs: # Scrape configuration for all of the example scripts. - job_name: output metrics_path: /probe params: script: - output static_configs: - targets: - script_exporter:9469 - job_name: ping metrics_path: /probe params: script: - ping params: - target static_configs: - targets: - example.com - example.org relabel_configs: - source_labels: [__address__] target_label: __param_target - target_label: __address__ replacement: script_exporter:9469 - source_labels: [__param_target] target_label: target - job_name: showtimeout metrics_path: /probe params: script: - showtimeout static_configs: - targets: - script_exporter:9469 - job_name: sleep metrics_path: /probe params: script: - sleep params: - seconds seconds: - "20" static_configs: - targets: - script_exporter:9469 - job_name: cache metrics_path: /probe params: script: - cache params: - seconds seconds: - "5" static_configs: - targets: - script_exporter:9469 # Configuration to get the metrics of the Script Exporter. - job_name: "script_exporter" metrics_path: /metrics static_configs: - targets: - script_exporter:9469 # Configuration for the Prometheus discovery feature of the Script Exporter. - job_name: scripts http_sd_configs: - url: http://script_exporter:9469/discovery script_exporter-3.0.1/scripts.yaml000066400000000000000000000012451500336103100173140ustar00rootroot00000000000000scripts: - name: output command: - ./prober/scripts/output.sh - name: ping command: - ./prober/scripts/ping.sh output: ignore: true - name: showtimeout command: - ./prober/scripts/showtimeout.sh timeout: max_timeout: 60 - name: docker command: - ./prober/scripts/docker.sh - name: sleep command: - ./prober/scripts/sleep.sh timeout: max_timeout: 10 enforced: true wait_delay: 0.01 discovery: params: seconds: "20" - name: cache command: - ./prober/scripts/sleep.sh cache: duration: 60 discovery: params: seconds: "5"