diff --git a/devenv/loadtest/README.md b/devenv/loadtest/README.md index 15605d87..6aa1ea00 100644 --- a/devenv/loadtest/README.md +++ b/devenv/loadtest/README.md @@ -20,6 +20,11 @@ Run load test for custom duration: $ ./run.sh -d 10s ``` +Run only 1 iteration of the load test (useful for testing): + +```bash +$ ./run.sh -i 1 + Run load test for custom target url: ```bash diff --git a/devenv/docker/ha/grafana/provisioning/dashboards/general/graph_panel.json b/devenv/loadtest/fixtures/graph_panel.json similarity index 100% rename from devenv/docker/ha/grafana/provisioning/dashboards/general/graph_panel.json rename to devenv/loadtest/fixtures/graph_panel.json diff --git a/devenv/loadtest/modules/client.js b/devenv/loadtest/modules/client.js index 67733129..123d0a3d 100644 --- a/devenv/loadtest/modules/client.js +++ b/devenv/loadtest/modules/client.js @@ -1,5 +1,66 @@ import http from "k6/http"; +export const DatasourcesEndpoint = class DatasourcesEndpoint { + constructor(httpClient) { + this.httpClient = httpClient; + } + + getAll() { + return this.httpClient.get('/datasources'); + } + + getById(id) { + return this.httpClient.get(`/datasources/${id}`); + } + + getByName(name) { + return this.httpClient.get(`/datasources/name/${name}`); + } + + create(payload) { + return this.httpClient.post(`/datasources`, JSON.stringify(payload)); + } + + update(id, payload) { + return this.httpClient.put(`/datasources/${id}`, JSON.stringify(payload)); + } +}; + +export const DashboardsEndpoint = class DashboardsEndpoint { + constructor(httpClient) { + this.httpClient = httpClient; + } + + getAll() { + return this.httpClient.get('/dashboards'); + } + + upsert(payload) { + return this.httpClient.post(`/dashboards/db`, JSON.stringify(payload)); + } +}; + +export const OrganizationsEndpoint = class OrganizationsEndpoint { + constructor(httpClient) { + this.httpClient = httpClient; + } + + getById(id) { + return this.httpClient.get(`/orgs/${id}`); + } + + getByName(name) { + return this.httpClient.get(`/orgs/name/${name}`); + } + + create(name) { + let payload = { + name: name, + }; + return this.httpClient.post(`/orgs`, JSON.stringify(payload)); + } +}; + export const UIEndpoint = class UIEndpoint { constructor(httpClient) { this.httpClient = httpClient; @@ -10,18 +71,46 @@ export const UIEndpoint = class UIEndpoint { return this.httpClient.formPost('/login', payload); } - render() { - return this.httpClient.get('/render/d-solo/_CPokraWz/graph-panel?orgId=1&panelId=1&width=1000&height=500&tz=Europe%2FStockholm') + renderPanel(orgId, dashboardUid, panelId) { + return this.httpClient.get( + `/render/d-solo/${dashboardUid}/graph-panel`, + { + orgId, + panelId, + width: 1000, + height: 500, + tz: 'Europe/Stockholm', + } + ); } } export const GrafanaClient = class GrafanaClient { constructor(httpClient) { - httpClient.onBeforeRequest = this.onBeforeRequest; + httpClient.onBeforeRequest = (params) => { + if (this.orgId && this.orgId > 0) { + params.headers = params.headers || {}; + params.headers["X-Grafana-Org-Id"] = this.orgId; + } + } + this.raw = httpClient; + this.dashboards = new DashboardsEndpoint(httpClient.withUrl('/api')); + this.datasources = new DatasourcesEndpoint(httpClient.withUrl('/api')); + this.orgs = new OrganizationsEndpoint(httpClient.withUrl('/api')); this.ui = new UIEndpoint(httpClient); } + loadCookies(cookies) { + for (let [name, value] of Object.entries(cookies)) { + http.cookieJar().set(this.raw.url, name, value); + } + } + + saveCookies() { + return http.cookieJar().cookiesForURL(this.raw.url + '/'); + } + batch(requests) { return this.raw.batch(requests); } @@ -29,13 +118,6 @@ export const GrafanaClient = class GrafanaClient { withOrgId(orgId) { this.orgId = orgId; } - - onBeforeRequest(params) { - if (this.orgId && this.orgId > 0) { - params = params.headers || {}; - params.headers["X-Grafana-Org-Id"] = this.orgId; - } - } } export const BaseClient = class BaseClient { @@ -62,10 +144,16 @@ export const BaseClient = class BaseClient { } - get(url, params) { + get(url, queryParams, params) { params = params || {}; - this.beforeRequest(params); this.onBeforeRequest(params); + + if (queryParams) { + url += '?' + Array.from(Object.entries(queryParams)).map(([key, value]) => + `${key}=${encodeURIComponent(value)}` + ).join('&'); + } + return http.get(this.url + url, params); } @@ -86,6 +174,15 @@ export const BaseClient = class BaseClient { return http.post(this.url + url, body, params); } + put(url, body, params) { + params = params || {}; + params.headers = params.headers || {}; + params.headers['Content-Type'] = 'application/json'; + + this.onBeforeRequest(params); + return http.put(this.url + url, body, params); + } + delete(url, params) { params = params || {}; this.beforeRequest(params); diff --git a/devenv/loadtest/modules/util.js b/devenv/loadtest/modules/util.js new file mode 100644 index 00000000..78db8a4a --- /dev/null +++ b/devenv/loadtest/modules/util.js @@ -0,0 +1,64 @@ +export const createTestOrgIfNotExists = (client) => { + let orgId = 0; + + let res = client.orgs.getByName('k6-image-renderer'); + if (res.status === 404) { + res = client.orgs.create('k6-image-renderer'); + if (res.status !== 200) { + throw new Error('Expected 200 response status when creating org'); + } + return res.json().orgId; + } + + // This can happen e.g. in Hosted Grafana instances, where even admins + // cannot see organisations + if (res.status !== 200) { + console.info(`unable to get orgs from instance, continuing with default orgId ${orgId}`); + return orgId; + } + + return res.json().id; +}; + +export const upsertTestdataDatasource = (client, name) => { + const payload = { + access: 'proxy', + isDefault: false, + name, + type: 'testdata', + }; + + let res = client.datasources.getByName(payload.name); + let id; + if (res.status === 404) { + res = client.datasources.create(payload); + + if (res.status == 200) { + id = res.json().id; + } + } else if (res.status == 200) { + id = res.json().id; + res = client.datasources.update(res.json().id, payload); + } + + if (res.status !== 200) { + throw new Error(`expected 200 response status when creating datasource, got ${res.status}`); + } + + return id; +}; + +export const upsertDashboard = (client, dashboard) => { + const payload = { + dashboard, + overwrite: true, + }; + + let res = client.dashboards.upsert(payload); + + if (res.status !== 200) { + throw new Error(`expected 200 response status when creating dashboards, got ${res.status}`); + } + + return res.json().id; +}; diff --git a/devenv/loadtest/render_test.js b/devenv/loadtest/render_test.js index 95adfcc0..c690c8b3 100644 --- a/devenv/loadtest/render_test.js +++ b/devenv/loadtest/render_test.js @@ -1,33 +1,56 @@ import { check, group } from 'k6'; import { createClient } from './modules/client.js'; +import { + createTestOrgIfNotExists, + upsertTestdataDatasource, + upsertDashboard, +} from './modules/util.js'; export let options = { - noCookiesReset: true + noCookiesReset: true, + thresholds: { checks: [ { threshold: 'rate=1', abortOnFail: true } ] }, }; let endpoint = __ENV.URL || 'http://localhost:3000'; const client = createClient(endpoint); +const dashboard = JSON.parse(open('fixtures/graph_panel.json')); -export default () => { - group("render test", () => { - if (__ITER === 0) { - group("user authenticates thru ui with username and password", () => { - let res = client.ui.login('admin', 'admin'); +export const setup = () => { + group("user authenticates thru ui with username and password", () => { + let res = client.ui.login('admin', 'admin'); - check(res, { - 'response status is 200': (r) => r.status === 200, - }); - }); - } - - if (__ITER !== 0) { - group("render graph panel", () => { - const response = client.ui.render(); - check(response, { - 'response status is 200': (r) => r.status === 200, - }); + check(res, { + 'response status is 200': (r) => r.status === 200, + }); + }); + + const orgId = createTestOrgIfNotExists(client); + client.withOrgId(orgId); + upsertTestdataDatasource(client, dashboard.panels[0].datasource); + upsertDashboard(client, dashboard); + + return { + orgId, + cookies: client.saveCookies(), + }; +}; + +export default (data) => { + client.loadCookies(data.cookies); + client.withOrgId(data.orgId); + + group("render test", () => { + group("render graph panel", () => { + const response = client.ui.renderPanel( + data.orgId, + dashboard.uid, + dashboard.panels[0].id, + ); + check(response, { + 'response status is 200': (r) => r.status === 200, + 'response is a PNG': (r) => r.headers['Content-Type'] == 'image/png', }); - } + }); }); } diff --git a/devenv/loadtest/run.sh b/devenv/loadtest/run.sh index 700a0357..892f170b 100755 --- a/devenv/loadtest/run.sh +++ b/devenv/loadtest/run.sh @@ -1,17 +1,21 @@ #/bin/bash -PWD=$(pwd) +cd "$(dirname $0)" run() { - duration='15m' - url='http://localhost:3000' - vus='2' + local duration='15m' + local url='http://localhost:3000' + local vus='2' + local iterationsOption='' - while getopts ":d:u:v:" o; do + while getopts ":d:i:u:v:" o; do case "${o}" in d) duration=${OPTARG} ;; + i) + iterationsOption="--iterations ${OPTARG}" + ;; u) url=${OPTARG} ;; @@ -22,7 +26,17 @@ run() { done shift $((OPTIND-1)) - docker run -t --network=host -v $PWD:/src -e URL=$url --rm -i loadimpact/k6:master run --vus $vus --duration $duration src/render_test.js + docker run \ + -it \ + --network=host \ + --mount type=bind,source=$PWD,destination=/src \ + -e URL=$url \ + --rm \ + loadimpact/k6:master run \ + --vus $vus \ + --duration $duration \ + $iterationsOption \ + //src/render_test.js } run "$@"