diff --git a/Dockerfile b/Dockerfile index 9574a27..a6c5ceb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,12 @@ FROM python:3.7.4-alpine3.10 ADD exporter exporter/ -add requirements.txt exporter/requirements.txt +ADD requirements.txt exporter/requirements.txt WORKDIR exporter RUN pip install -r requirements.txt +EXPOSE 8000 + CMD ["python", "app.py"] \ No newline at end of file diff --git a/README.md b/README.md index 6c88c2a..e156458 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,54 @@ [![Build Status](https://craigg.visualstudio.com/Pipelines/_apis/build/status/status-cake-exporter?branchName=master)](https://craigg.visualstudio.com/Pipelines/_build/latest?definitionId=19&branchName=master) - - # Status Cake Exporter -Status Cake Exporter is a Prometheus expoter for [StatusCake](https://www.statuscake.com/). +Status Cake Exporter is a Prometheus exporter for [StatusCake](https://www.statuscake.com/). -Metrics are exposed on port 8080. E.g. +Metrics are exposed on port 8000 when using the provided examples/manifest.yml](examples/manifest.yml) in Kubernetes, e.g. +```sh +http://status-cake-exporter.default.svc:8000 ``` -http://status-cake-exporter.default.svc:8080 -``` + +## Requirements + +* Python 3.7 (not tested with anything below this) +* Python dependencies from `requirements.txt` +* Docker +* Kubernetes (optional) +* Helm 3 (optional) ## Usage | Setting | Required | Default | |----------|----------|---------| -| USERNAME | Yes | Null | +| USERNAME | Yes | Null | | API_KEY | Yes | Null | | TAGS | No | Null | -| LOG_LEVEL| No | info | +| LOG_LEVEL| No | info | +| PORT | No | 8000 | ### Docker -``` +The following will expose the exporter at `localhost:8000`: + +```sh export USERNAME=statuscakeuser export API_KEY=xxxxxxxx -docker run --env USERNAME --env API_KEY chelnak/status-cake-exporter:latest +docker run -d -p 8000:8000 --env USERNAME --env API_KEY chelnak/status-cake-exporter:latest ``` ### Kubernetes -To get up and running quickly, use [examples/manifest.yml](examples/manifest.yml) as an example. +To get up and running quickly, use [examples/manifest.yml](examples/manifest.yml) as an example. You will need to create a secret named `status-cake-api-token` containing your `USERNAME` and `API_KEY` first. + +Otherwise, you can use the Helm Chart provided in [chart/status-cake-exporter](chart/status-cake-exporter/README.md). ### Terminal -``` +```sh usage: app.py [-h] [--username USERNAME] [--api-key API_KEY] - [--tests.tags TAGS] [--logging.level {debug,info,warn,error}] + [--tests.tags TAGS] [--logging.level {debug,info,warn,error}] [--port PORT] If an arg is specified in more than one place, then commandline values override environment variables which override defaults. @@ -48,7 +59,8 @@ optional arguments: --api-key API_KEY API key for the account [env var: API_KEY] --tests.tags TAGS A comma separated list of tags used to filter tests returned from the api [env var: TAGS] --logging.level {debug,info,warn,error} Set a log level for the application [env var: LOG_LEVEL] -``` + --port The TCP port to start the web server on [env var: PORT] +``` ## Metrics @@ -75,6 +87,7 @@ scrape_configs: ``` ## Grafana + Data collected by Prometheus can be easily surfaced in Grafana. Using the [Statusmap panel](https://grafana.com/grafana/plugins/flant-statusmap-panel) by [flant](https://github.com/flant/grafana-statusmap) you can create a basic status visualization based on uptime percentage: @@ -82,6 +95,22 @@ Using the [Statusmap panel](https://grafana.com/grafana/plugins/flant-statusmap- ![](examples/grafana.png) ### PromQL + ```PromQL status_cake_test_info * on(test_id) group_right(test_name) status_cake_test_uptime_percent ``` + +## Development + +This repository uses [Tilt](https://tilt.dev) for rapid development on Kubernetes. + +To use this, run: + +```sh +cd chart/status-cake-exporter +tilt up +``` + +Tilt will reload your environment when it detects changes to your code. + +Note: You will need to provide valid credentials for StatusCake in your `Tiltfile` for this to work. diff --git a/chart/status-cake-exporter/.gitignore b/chart/status-cake-exporter/.gitignore new file mode 100644 index 0000000..85de9cf --- /dev/null +++ b/chart/status-cake-exporter/.gitignore @@ -0,0 +1 @@ +src diff --git a/chart/status-cake-exporter/.helmignore b/chart/status-cake-exporter/.helmignore new file mode 100644 index 0000000..21d0a61 --- /dev/null +++ b/chart/status-cake-exporter/.helmignore @@ -0,0 +1,2 @@ +Tiltfile_* +.git diff --git a/chart/status-cake-exporter/Chart.yaml b/chart/status-cake-exporter/Chart.yaml new file mode 100644 index 0000000..1dcc0dd --- /dev/null +++ b/chart/status-cake-exporter/Chart.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +appVersion: latest +description: Status Cake Prometheus Exporter +keywords: +- prometheus +- statuscake +maintainers: [] +name: status-cake-exporter +version: 1.0.0 diff --git a/chart/status-cake-exporter/README.md b/chart/status-cake-exporter/README.md new file mode 100644 index 0000000..f8c1486 --- /dev/null +++ b/chart/status-cake-exporter/README.md @@ -0,0 +1,39 @@ +# Statuscake Prometheus Exporter Helm Chart + +This Helm chart deploys the StatusCake Prometheus exporter from [chelnak/status-cake-exporter](https://github.com/chelnak/status-cake-exporter). + +## Requirements + +* Statuscake `username` and `apiKey` defined in [values.yaml](values.yaml). + +## Usage + +Create your own `values.yaml` file and run: + +```bash +helm install status-cake-exporter . --namespace default --values values.yaml +``` + +## Testing + +```bash +helm test ${releaseName} +``` + +## Development + +This repository uses [Tilt](https://tilt.dev) for rapid development on Kubernetes. + +To use this, run: + +```sh +tilt up +``` + +Tilt will reload your environment when it detects changes to your code (see [Tiltfile](Tiltfile) for the list of paths watched). + +Note: You will need to provide valid credentials for StatusCake in your `Tiltfile` for this to work. To do so, you can copy the file to e.g. `Tiltfile_secret`, update it and then start tilt with: + +```sh +tilt up -f Tiltfile_secret +``` diff --git a/chart/status-cake-exporter/Tiltfile b/chart/status-cake-exporter/Tiltfile new file mode 100644 index 0000000..48d4294 --- /dev/null +++ b/chart/status-cake-exporter/Tiltfile @@ -0,0 +1,7 @@ +docker_build('status-cake-exporter:dev', '../../') +# If not using a standard local dev name, specify your k8s context here +#allow_k8s_contexts('microk8s') +k8s_yaml(helm('.', values='values.yaml', set=['statuscake.logLevel=debug', 'image.repository=status-cake-exporter', 'image.tag=dev', 'statuscake.username=', 'statuscake.apiKey=', 'statuscake.tags=firstTag,secondTag'])) +watch_file('.') +watch_file('../../Dockerfile') +watch_file('../../exporter') diff --git a/chart/status-cake-exporter/templates/_helpers.tpl b/chart/status-cake-exporter/templates/_helpers.tpl new file mode 100644 index 0000000..2946a81 --- /dev/null +++ b/chart/status-cake-exporter/templates/_helpers.tpl @@ -0,0 +1,27 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 24 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Create a default fully qualified app name. +We truncate at 24 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +*/}} +{{- define "fullname" -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- printf "%s-%s" .Release.Name $name | trunc 24 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Return the appropriate apiVersion for deployment. +*/}} +{{- define "deployment.apiVersion" -}} +{{- if semverCompare "<1.9-0" .Capabilities.KubeVersion.GitVersion -}} +{{- print "extensions/v1beta1" -}} +{{- else if semverCompare "^1.9-0" .Capabilities.KubeVersion.GitVersion -}} +{{- print "apps/v1" -}} +{{- end -}} +{{- end -}} diff --git a/chart/status-cake-exporter/templates/deployment.yaml b/chart/status-cake-exporter/templates/deployment.yaml new file mode 100644 index 0000000..7065086 --- /dev/null +++ b/chart/status-cake-exporter/templates/deployment.yaml @@ -0,0 +1,52 @@ +--- +apiVersion: {{ template "deployment.apiVersion" . }} +kind: Deployment +metadata: + name: "{{ .Release.Name }}" + labels: + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + heritage: {{ .Release.Service | quote }} + release: {{ .Release.Name | quote }} + app: "{{ .Release.Name }}" +spec: + replicas: 1 + selector: + matchLabels: + app: "{{ .Release.Name }}" + template: + metadata: + labels: + app: "{{ .Release.Name }}" + spec: + containers: + - name: status-cake-exporter + image: {{ .Values.image.repository }}:{{ .Values.image.tag }} + ports: + - containerPort: {{ .Values.service.port }} + env: + - name: USERNAME + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-api-token + key: USERNAME + - name: API_KEY + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-api-token + key: API_KEY +{{- if .Values.statuscake.tags }} + - name: TAGS + value: {{ .Values.statuscake.tags }} +{{- end }} +{{- if .Values.statuscake.logLevel }} + - name: LOG_LEVEL + value: {{ .Values.statuscake.logLevel }} +{{- end }} + resources: +{{ toYaml .Values.resources | indent 10 }} +{{- if .Values.image.pullSecrets }} + imagePullSecrets: +{{- range .Values.image.pullSecrets }} + - name: {{ . }} +{{- end }} +{{- end }} diff --git a/chart/status-cake-exporter/templates/secrets.yml b/chart/status-cake-exporter/templates/secrets.yml new file mode 100644 index 0000000..20c18c7 --- /dev/null +++ b/chart/status-cake-exporter/templates/secrets.yml @@ -0,0 +1,14 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: "{{ .Release.Name }}-api-token" + labels: + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + heritage: {{ .Release.Service | quote }} + release: {{ .Release.Name | quote }} + app: "{{ .Release.Name }}" +type: generic +data: + USERNAME: {{ .Values.statuscake.username | b64enc }} + API_KEY: {{ .Values.statuscake.apiKey | b64enc }} diff --git a/chart/status-cake-exporter/templates/svc.yaml b/chart/status-cake-exporter/templates/svc.yaml new file mode 100644 index 0000000..02a798f --- /dev/null +++ b/chart/status-cake-exporter/templates/svc.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: "{{ .Release.Name }}" + labels: + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + heritage: {{ .Release.Service | quote }} + release: {{ .Release.Name | quote }} + app: "{{ .Release.Name }}" +spec: + ports: + - port: {{ .Values.service.port }} + protocol: TCP + selector: + app: "{{ .Release.Name }}" diff --git a/chart/status-cake-exporter/templates/tests/status-cake-exporter-test.yaml b/chart/status-cake-exporter/templates/tests/status-cake-exporter-test.yaml new file mode 100644 index 0000000..9c63518 --- /dev/null +++ b/chart/status-cake-exporter/templates/tests/status-cake-exporter-test.yaml @@ -0,0 +1,43 @@ +apiVersion: v1 +kind: Pod +metadata: + name: "{{ .Release.Name }}-test-{{ randAlphaNum 5 | lower }}" + annotations: + "helm.sh/hook": test-success +spec: + containers: + - name: {{ .Release.Name }}-test + image: {{ .Values.image.repository }}:{{ .Values.image.tag }} + command: + - "sh" + - "-c" + - | + set -x + # run tests + sh /tests/run.sh + volumeMounts: + - mountPath: /tests + name: tests + readOnly: true + - mountPath: /tools + name: tools + resources: + requests: + cpu: "50m" + memory: "128Mi" + limits: + cpu: "250m" + memory: "256Mi" + volumes: + - name: tests + configMap: + name: {{ .Release.Name }}-tests + - name: tools + emptyDir: {} + restartPolicy: Never +{{- if .Values.image.pullSecrets }} + imagePullSecrets: +{{- range .Values.image.pullSecrets }} + - name: {{ . }} +{{- end }} +{{- end }} \ No newline at end of file diff --git a/chart/status-cake-exporter/templates/tests/test-config.yaml b/chart/status-cake-exporter/templates/tests/test-config.yaml new file mode 100644 index 0000000..86e2e2c --- /dev/null +++ b/chart/status-cake-exporter/templates/tests/test-config.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Release.Name }}-tests +data: + run.sh: |- + for i in $(seq 1 48) ; do + wget -T 10 http://{{ .Release.Name }}:{{ .Values.service.port }}/metrics && exit 0 + sleep 5 + done + + exit 1 diff --git a/chart/status-cake-exporter/values.yaml b/chart/status-cake-exporter/values.yaml new file mode 100644 index 0000000..8735fa1 --- /dev/null +++ b/chart/status-cake-exporter/values.yaml @@ -0,0 +1,26 @@ +image: + repository: chelnak/status-cake-exporter + tag: latest + # A list of ImagePullSecrets to use for the containers in this Chart + pullSecrets: [] + +statuscake: + # REQUIRED: username to use when connecting to statuscake + username: "" + # REQUIRED: apikey to use when connecting to statuscake + apiKey: "" + # optional: a comma separated list of tags to filter for + # tags: + # optional: debug, info, warn, error + # logLevel: + +service: + port: 8000 + +resources: + requests: + cpu: "250m" + memory: "256Mi" + limits: + cpu: "250m" + memory: "256Mi" diff --git a/examples/manifest.yml b/examples/manifest.yml index c951c31..9a0c96f 100644 --- a/examples/manifest.yml +++ b/examples/manifest.yml @@ -53,4 +53,17 @@ spec: valueFrom: secretKeyRef: name: status-cake-api-token - key: API_KEY \ No newline at end of file + key: API_KEY +--- +apiVersion: v1 +kind: Service +metadata: + name: status-cake-exporter + labels: + app: status-cake-exporter +spec: + ports: + - port: 8000 + protocol: TCP + selector: + app: status-cake-exporter diff --git a/exporter/app.py b/exporter/app.py index f4ecac5..f3e500a 100644 --- a/exporter/app.py +++ b/exporter/app.py @@ -9,7 +9,6 @@ from collectors import test_collector from utilities import logs, arguments - if __name__ == "__main__": try: @@ -19,10 +18,10 @@ logs.configure_logging(args.log_level) logger = logging.getLogger(__name__) - logger.info("Starting web server") - start_http_server(8000) + logger.info(f"Starting web server on port: {args.port}") + start_http_server(args.port) - logger.info("Registering collectors") + logger.info("Registering collectors.") REGISTRY.register(test_collector.TestCollector( args.username, args.api_key, args.tags)) diff --git a/exporter/collectors/test_collector.py b/exporter/collectors/test_collector.py index 8a8edb2..89afdd7 100644 --- a/exporter/collectors/test_collector.py +++ b/exporter/collectors/test_collector.py @@ -8,10 +8,14 @@ logger = logging.getLogger("test_collector") - def parse_test_response(r, m): t = [] - tests = r.json()['data'] + try: + tests = r.json() + except Exception as e: + logger.error(f"Could not parse test data, exception: {e}") + logger.error(f"Test data was:\n{r}") + sys.exit(1) for i in tests: t.append( { @@ -54,17 +58,29 @@ def __init__(self, username, api_key, tags): def collect(self): - logger.info("Collector started") + logger.info("Collector started.") try: maintenance = m.get_maintenance(self.api_key, self.username) - #Grab the test_ids from the response - m_test_id_list = [i['all_tests'] for i in maintenance.json()['data']] - #Flatten the test_ids into a list + try: + maintenance_data = maintenance.json()['data'] + except Exception as e: + logger.error(f"Could not parse maintenace data, exception: {e}") + logger.error(f"Data was:\n{maintenance}") + sys.exit(1) + logger.debug(f"Maintenance response:\n{maintenance_data}") + + # Grab the test_ids from the response + m_test_id_list = [i['all_tests'] for i in maintenance_data] + + # Flatten the test_ids into a list m_test_id_flat_list = [item for sublist in m_test_id_list for item in sublist] + logger.info(f"Found {len(m_test_id_flat_list)} tests that are in maintenance.") + tests = t.get_tests(self.api_key, self.username, self.tags) parsed_tests = parse_test_response(tests, m_test_id_flat_list) + logger.info(f"Publishing {len(parsed_tests)} tests.") # status_cake_test_info - gauge label_names = parsed_tests[0].keys() @@ -98,4 +114,4 @@ def collect(self): logger.error(e) sys.exit(1) - logger.info("Collector finished") + logger.info("Collector finished.") diff --git a/exporter/status_cake_client/base.py b/exporter/status_cake_client/base.py index d22ffad..958cfd8 100644 --- a/exporter/status_cake_client/base.py +++ b/exporter/status_cake_client/base.py @@ -10,13 +10,9 @@ def get(apikey, username, endpoint, params={}): - request_url = "{base}{endpoint}".format( - base=STATUS_CAKE_BASE_URL, endpoint=endpoint) + request_url = f"{STATUS_CAKE_BASE_URL}{endpoint}" - logger.debug("Starting request: {request_url} {endpoint} {params}".format( - request_url=request_url, - endpoint=endpoint, - params=params)) + logger.debug(f"Starting request: {request_url} {endpoint} {params}") headers = { "API": apikey, @@ -25,5 +21,6 @@ def get(apikey, username, endpoint, params={}): response = requests.get(url=request_url, params=params, headers=headers) response.raise_for_status() + logger.debug(f"Request response:\n{response.content}") return response diff --git a/exporter/status_cake_client/maintenance.py b/exporter/status_cake_client/maintenance.py index c12f733..3e2e64e 100644 --- a/exporter/status_cake_client/maintenance.py +++ b/exporter/status_cake_client/maintenance.py @@ -19,10 +19,11 @@ def get_maintenance(apikey, username, state="ACT"): response = get(apikey, username, endpoint, params) except requests.exceptions.HTTPError as e: if e.response.status_code == 404: - logger.info("Currently no active maintenance") + logger.info("Currently no active maintenance.") response = e.response else: logger.error(e) sys.exit(1) + logger.debug(f"Request response:\n{response.content}") return response diff --git a/exporter/status_cake_client/tests.py b/exporter/status_cake_client/tests.py index 09fe32e..e8c3582 100644 --- a/exporter/status_cake_client/tests.py +++ b/exporter/status_cake_client/tests.py @@ -11,8 +11,8 @@ def get_tests(apikey, username, tags=""): params = { "tags": tags } - response = get(apikey, username, endpoint, params) + logger.debug(f"Request response:\n{response.content}") return response @@ -24,5 +24,6 @@ def get_test_details(apikey, username, test_id): } response = get(apikey, username, endpoint, params) + logger.debug(f"Request response:\n{response.content}") return response diff --git a/exporter/utilities/arguments.py b/exporter/utilities/arguments.py index 1600faa..1e0033c 100644 --- a/exporter/utilities/arguments.py +++ b/exporter/utilities/arguments.py @@ -30,6 +30,12 @@ def get_args(): choices={'debug', 'info', 'warn', 'error'}, help="Set a log level for the application") + parser.add("--port", + dest="port", + env_var="PORT", + default=8000, + help="The TCP port to start the web server on") + args = parser.parse_args() if args.username is None: