diff --git a/Dockerfile b/Dockerfile index ee023ee..8ec7f0f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:18-alpine +FROM --platform=linux/amd64 node:18-alpine RUN mkdir -p /usr/src/app && \ chown -R node:node /usr/src/app @@ -10,4 +10,4 @@ RUN npm install COPY . /usr/src/app -CMD ["node", "index.js"] +CMD ["node", "src/index.js"] diff --git a/README.md b/README.md index 5cd2ae5..1527da9 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,18 @@ The `build-release.sh` script does the following: Call the script without parameters for a description. +### MAC Troubleshooting + +When building the image using a MAC with M1 or M2, you may find that the execution of the image generates errors. +This is because the platform is incorrect when building the image. +To solve this issue, you need to export the following environment variable before performing the build: + +``` +export DOCKER_DEFAULT_PLATFORM=linux/amd64 +``` + +With this adjustment, the image should be built correctly. + ### Jenkinsfile We have also provided a sample `Jenkinsfile` which you can use to build the project **for a specific release, from source code** on your own Jenkins infrastructure. Please note: These scripts all download the code from **this** repository on GitHub; in case you want to build from a different fork, you will need to do a couple of adaptions. diff --git a/package-lock.json b/package-lock.json index d15b058..df2d04d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.2", "license": "Apache-2.0", "dependencies": { - "axios": "1.5.1", + "axios": "1.6.1", "prom-client": "15.0.0", "winston": "3.11.0" }, @@ -1238,9 +1238,9 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/axios": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.5.1.tgz", - "integrity": "sha512-Q28iYCWzNHjAm+yEAot5QaAMxhMghWLFVf7rRdwhUI+c2jix2DUXjAHXVi+s1ibs3mjPO/cCgbA++3BjD0vP/A==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.1.tgz", + "integrity": "sha512-vfBmhDpKafglh0EldBEbVuoe7DyAavGSLWhuSm5ZSEKQnHhBf0xAAwybbNH1IkrJNGnS/VG4I5yxig1pCEXE4g==", "dependencies": { "follow-redirects": "^1.15.0", "form-data": "^4.0.0", diff --git a/package.json b/package.json index b17a12c..74fbf13 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "test": "jest" }, "dependencies": { - "axios": "1.5.1", + "axios": "1.6.1", "prom-client": "15.0.0", "winston": "3.11.0" }, diff --git a/index.js b/src/functions.js similarity index 78% rename from index.js rename to src/functions.js index 845b4de..ecde970 100644 --- a/index.js +++ b/src/functions.js @@ -1,38 +1,28 @@ -'use strict' - const axios = require('axios') const logger = require('./logger') -let PUSHGATEWAY_URL = resolve('PUSHGATEWAY_URL', 'http://localhost:9091') -if (!PUSHGATEWAY_URL.endsWith('/')) - PUSHGATEWAY_URL += '/' -logger.info(`Pushgateway URL: ${PUSHGATEWAY_URL}`) - -const INTERVAL_SECONDS = resolve('PRUNE_INTERVAL', 60) -const PRUNE_THRESHOLD_SECONDS = resolve('PRUNE_THRESHOLD', 600) -logger.info(`Prune interval: ${INTERVAL_SECONDS} seconds.`) -logger.info(`Prune threshold: ${PRUNE_THRESHOLD_SECONDS} seconds.`) +const METRIC_NAME = 'push_time_seconds'; -async function pruneGroups() { +async function pruneGroups(pushgatewayUrl, pruneThresholdSeconds) { logger.info('Starting prune process...'); // Get metrics request from Prometheus push gateway let metrics = null; try { - metrics = await getMetrics(PUSHGATEWAY_URL); + metrics = await getMetrics(pushgatewayUrl); } catch (e) { - throw new Error(`GET /metrics from ${PUSHGATEWAY_URL} failed. Cause: ${e}`) + throw new Error(`GET /metrics from ${pushgatewayUrl} failed. Cause: ${e}`) } // Get 'push_time_seconds' groups and filter the ones that are above pruneThresholdSeconds const groupings = parseGroupings(metrics) - const filteredGroupings = filterOldGroupings(groupings) + const filteredGroupings = filterOldGroupings(groupings, pruneThresholdSeconds) logger.info(`Found ${groupings.length} grouping(s), of which ${filteredGroupings.length} will be pruned`) if (filteredGroupings.length > 0) { filteredGroupings.map((filteredGroup) => { try { - deleteGrouping(filteredGroup) + deleteGrouping(filteredGroup, pushgatewayUrl) } catch (e) { logger.error(`Pruning group ${filteredGroup} failed.`) } @@ -57,9 +47,9 @@ function resolve(envVar, defaultValue) { return defaultValue } -async function getMetrics() { +async function getMetrics(pushgatewayUrl) { logger.debug('getMetrics()') - const getMetricsResponse = await axios.get(PUSHGATEWAY_URL + 'metrics', { + const getMetricsResponse = await axios.get(pushgatewayUrl + 'metrics', { timeout: 2000 }); @@ -81,7 +71,7 @@ function parseGroupings(metrics) { const pushGroups = [] for (let i = 0; i < lines.length; ++i) { const line = lines[i] - if (line.startsWith("push_time_seconds")) { + if (line.startsWith(METRIC_NAME)) { const labels = parseLabels(line.substring(line.indexOf('{') + 1, line.indexOf('}'))) const timestamp = new Date(parseFloat(line.substring(line.indexOf('}') + 1).trim()) * 1000) pushGroups.push({ @@ -113,12 +103,12 @@ function parseLabels(labels) { return labelMap } -function filterOldGroupings(groupings) { +function filterOldGroupings(groupings, pruneThresholdSeconds) { logger.debug('filterOldGroupings()'); const filteredGroupings = [] const now = new Date() for (let i = 0; i < groupings.length; ++i) { - if ((now - groupings[i].timestamp) > PRUNE_THRESHOLD_SECONDS * 1000) { + if ((now - groupings[i].timestamp) > pruneThresholdSeconds * 1000) { filteredGroupings.push(groupings[i]) } } @@ -127,7 +117,7 @@ function filterOldGroupings(groupings) { return filteredGroupings } -async function deleteGrouping(grouping) { +async function deleteGrouping(grouping, pushgatewayUrl) { logger.debug('deleteGrouping()', grouping) const job = grouping.labels.job @@ -141,7 +131,7 @@ async function deleteGrouping(grouping) { return; } - const url = PUSHGATEWAY_URL + encodeURIComponent(`metrics/job/${job}/${labelName}/${labelValue}`) + const url = pushgatewayUrl + encodeURIComponent(`metrics/job/${job}/${labelName}/${labelValue}`) logger.debug(`Delete URL: ${url}`) const deleteResponse = await axios.delete(url, { timeout: 2000 @@ -159,7 +149,6 @@ async function deleteGrouping(grouping) { logger.debug(`DELETE ${url} succeeded, status code ${deleteResponse.status}`) logger.info('Deleted grouping', grouping.labels) - return; } function findLabelName(labels) { @@ -171,9 +160,8 @@ function findLabelName(labels) { return null } -const interval = setInterval(pruneGroups, INTERVAL_SECONDS * 1000) - module.exports = { + resolve, pruneGroups, - interval + parseLabels } diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..15c732d --- /dev/null +++ b/src/index.js @@ -0,0 +1,22 @@ +const { resolve, pruneGroups } = require('./functions') +const logger = require('./logger') + +let PUSHGATEWAY_URL = resolve('PUSHGATEWAY_URL', 'http://localhost:9091') +if (!PUSHGATEWAY_URL.endsWith('/')) + PUSHGATEWAY_URL += '/' +logger.info(`Pushgateway URL: ${PUSHGATEWAY_URL}`) + +const INTERVAL_SECONDS = resolve('PRUNE_INTERVAL', 60) +const PRUNE_THRESHOLD_SECONDS = resolve('PRUNE_THRESHOLD', 600) +logger.info(`Prune interval: ${INTERVAL_SECONDS} seconds.`) +logger.info(`Prune threshold: ${PRUNE_THRESHOLD_SECONDS} seconds.`) + +const interval = setInterval( + () => pruneGroups(PUSHGATEWAY_URL, PRUNE_THRESHOLD_SECONDS), + INTERVAL_SECONDS * 1000 +) + +module.exports = { + pruneGroups, + interval +} diff --git a/logger.js b/src/logger.js similarity index 87% rename from logger.js rename to src/logger.js index 4ecc100..080865b 100644 --- a/logger.js +++ b/src/logger.js @@ -1,6 +1,6 @@ const winston = require('winston') -let logLevel = process.env.DEBUG == 'true' ? 'debug' : 'info' +let logLevel = process.env.DEBUG === 'true' ? 'debug' : 'info' const logger = winston.createLogger({ transports: [ diff --git a/test/functions.test.js b/test/functions.test.js new file mode 100644 index 0000000..4b83f1c --- /dev/null +++ b/test/functions.test.js @@ -0,0 +1,159 @@ +const axios = require('axios'); +const { resolve, pruneGroups, parseLabels } = require('./../src/functions') + +jest.mock('axios'); + +const DEFAULT_METRICS_DATA = + 'go_gc_duration_seconds{quantile="0"} 2.56e-05\n' + + '# HELP push_time_seconds Last Unix time when changing this group in the Pushgateway succeeded.\n' + + '# TYPE push_time_seconds gauge\n' + + 'push_time_seconds{instance="instance_1",job="application_1"} 1.5806880000000000e+09\n' + + 'push_time_seconds{instance="instance_2",job="application_2"} 1.5831936000000000e+09\n' + + 'push_time_seconds{instance="instance_3",job="application_3"} 1.5858720000000000e+09\n' + + '# HELP pushgateway_http_requests_total Total HTTP requests processed by the Pushgateway, excluding scrapes.\n' + + '# TYPE pushgateway_http_requests_total counter\n' + + 'pushgateway_http_requests_total{code="200",handler="healthy",method="get"} 12550\n' + + 'pushgateway_http_requests_total{code="200",handler="push",method="post"} 8445\n' + + 'pushgateway_http_requests_total{code="200",handler="ready",method="get"} 12550' + +function mockProcessEnv(envVar, envValue) { + process.env[envVar] = envValue; +} + +function mockGetMetricsResponse(metricsData = DEFAULT_METRICS_DATA, statusCode = 200) { + axios.get.mockResolvedValue({ + status: statusCode, + data: metricsData + }); +} + +function mockDeleteMetricsResponse(statusCode = 200) { + axios.delete.mockResolvedValue({ + status: statusCode + }); +} + +describe('Functions test', () => { + describe('Prune groups', () => { + + beforeAll(() => { + jest.useFakeTimers('modern'); + jest.setSystemTime(Date.UTC(2020, 2, 3)); + + mockGetMetricsResponse(); + mockDeleteMetricsResponse(); + }); + + beforeEach(() => { + expect(axios.delete).toHaveBeenCalledTimes(0); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + test('Simple prune process with one metric to be prune', async () => { + await pruneGroups('PUSHGATEWAY_URL', 60); + + expect(axios.delete).toHaveBeenCalledTimes(1); + }); + + test('Metric with no instance should not be deleted', async () => { + const metricsData = + 'push_time_seconds{instance="",job="application_1"} 1.5806844000000000e+09'; + + mockGetMetricsResponse(metricsData); + + await pruneGroups('PUSHGATEWAY_URL', 60); + + expect(axios.delete).toHaveBeenCalledTimes(0); + }); + + test('When GET metric has an error raise an exception', async () => { + mockGetMetricsResponse('', 500); + + const request = pruneGroups('PUSHGATEWAY_URL', 60); + + await expect(request).rejects.toThrow(); + }); + }) + + describe('Resolve function test', () => { + const envVar = 'TEST_ENV_VAR'; + const defaultValue = 'default_value'; + const defaultProcessEnvValues = { ...process.env }; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...defaultProcessEnvValues }; + }); + + afterAll(() => { + process.env = defaultProcessEnvValues; // Restore old environment + }); + + test('Return process env value string when specified', () => { + const expectedValue = 'expected_value'; + mockProcessEnv(envVar, expectedValue); + + const responseValue = resolve(envVar, defaultValue); + + expect(responseValue).toBe(expectedValue); + }); + + test('Return process env value integer when specified', () => { + const expectedValue = 123; + mockProcessEnv(envVar, expectedValue); + + const responseValue = resolve(envVar, defaultValue); + + expect(responseValue).toBe(expectedValue); + }); + + test('Return default process env value when no specified', () => { + const responseValue = resolve(envVar, defaultValue); + + expect(responseValue).toBe(defaultValue); + }); + }); + + describe('Parse labels function test', () => { + test('Return the specific labels', () => { + const stringLabels = 'instance="instance_1",job="application_1"'; + const expectedValue = { + instance: 'instance_1', + job: 'application_1' + }; + + const responseValue = parseLabels(stringLabels); + + expect(responseValue).toEqual(expectedValue); + }); + + test('Return empty string in labels that does not have a value', () => { + const stringLabels = 'instance="",job="application_1"'; + const expectedValue = { + instance: '', + job: 'application_1' + }; + + const responseValue = parseLabels(stringLabels); + + expect(responseValue).toEqual(expectedValue); + }); + + test('Return empty if no labels', () => { + const stringLabels = ''; + const expectedValue = {}; + + const responseValue = parseLabels(stringLabels); + + expect(responseValue).toEqual(expectedValue); + }); + }); +}) + diff --git a/test/index.test.js b/test/index.test.js deleted file mode 100644 index 9f81308..0000000 --- a/test/index.test.js +++ /dev/null @@ -1,74 +0,0 @@ -const axios = require('axios') -const { pruneGroups, interval } = require('./../index') - -jest.mock('axios'); - -const DEFAULT_METRICS_DATA = - 'go_gc_duration_seconds{quantile="0"} 2.56e-05\n' + - '# HELP push_time_seconds Last Unix time when changing this group in the Pushgateway succeeded.\n' + - '# TYPE push_time_seconds gauge\n' + - 'push_time_seconds{instance="instance_1",job="application_1"} 1.5806844000000000e+09\n' + - 'push_time_seconds{instance="instance_2",job="application_2"} 1.5831900000000000e+09\n' + - 'push_time_seconds{instance="instance_3",job="application_3"} 1.5858648000000000e+09\n' + - '# HELP pushgateway_http_requests_total Total HTTP requests processed by the Pushgateway, excluding scrapes.\n' + - '# TYPE pushgateway_http_requests_total counter\n' + - 'pushgateway_http_requests_total{code="200",handler="healthy",method="get"} 12550\n' + - 'pushgateway_http_requests_total{code="200",handler="push",method="post"} 8445\n' + - 'pushgateway_http_requests_total{code="200",handler="ready",method="get"} 12550' - -function mockGetMetricsResponse(metricsData = DEFAULT_METRICS_DATA, statusCode = 200) { - axios.get.mockResolvedValue({ - status: statusCode, - data: metricsData - }); -} - -function mockDeleteMetricsResponse(statusCode = 200) { - axios.delete.mockResolvedValue({ - status: statusCode - }); -} - -describe('PushGateway Pruner', () => { - - beforeAll(() => { - jest.useFakeTimers('modern'); - jest.setSystemTime(new Date(2020, 2, 3)); - - mockGetMetricsResponse(); - mockDeleteMetricsResponse(); - }); - - afterEach(() => { - jest.useRealTimers(); - - clearInterval(interval); - - jest.clearAllMocks(); - }) - - test('Simple prune process with one metric to be prune', async () => { - await pruneGroups('PUSHGATEWAY_URL', 60); - - expect(axios.delete).toHaveBeenCalledTimes(1); - }); - - test('Metric with no instance should not be deleted', async () => { - const metricsData = - 'push_time_seconds{instance="",job="application_1"} 1.5806844000000000e+09'; - - mockGetMetricsResponse(metricsData); - - await pruneGroups('PUSHGATEWAY_URL', 60); - - expect(axios.delete).toHaveBeenCalledTimes(0); - }); - - test('When GET metric has an error raise an exception', async () => { - mockGetMetricsResponse('', 500); - - const request = pruneGroups('PUSHGATEWAY_URL', 60); - - await expect(request).rejects.toThrow(); - }); -})