Skip to content

Commit f7cefda

Browse files
authored
chore(service): add load testing with k6 (#3180)
1 parent 722101b commit f7cefda

File tree

7 files changed

+325
-0
lines changed

7 files changed

+325
-0
lines changed

load-tests/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
config.js

load-tests/README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Load testing with k6
2+
3+
## To run
4+
1. Download k6: https://k6.io/docs/get-started/installation/ - you can also get a binary from the github page
5+
2. Enter the credentials and deployment you wish to test in a file named `config.js`, you can
6+
use the `example.config.js` to start - just make a copy and rename it.
7+
3. Run the tests with `k6 run testFileName.js`
8+
9+
## Limitations
10+
11+
- This can log into Renku only and specifically in the cases where Renku has its own built-in gitlab that does
12+
not require a separate log in OR when the gitlab deployment is part of another renku deployment
13+
- The login flow cannot handle giving authorization when prompted in the oauth flow - do this
14+
for the first time manually then run the tests with the same account
15+
- The project used to test migrations has to be in a namespace that you control and can create
16+
other projects in. When forks are created they are always created in the same namespace as the
17+
original project with the name being unique.

load-tests/example.config.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
export const baseUrl = "https://dev.renku.ch"
2+
// oldGitlabProjectId has to point to a project that resides in a namespace that the user
3+
// has at least maintainer access to. This is because the load tests will fork this project
4+
// into the same namespace as where the original project resides and only generate a uuid-like
5+
// name for the project. So if you point to a project that resides in a namespace to which
6+
// the test runner has no permissions, the forking part of the tests will fail.
7+
export const oldGitlabProjectId = 5011
8+
// This project is used to test calling api/renku/project.show, the project is not forked
9+
// and it does not have the same strict requirements as the project mentioned above. Any
10+
// public project should work here (whether the user has write access to it or not).
11+
export const sampleGitProjectUrl = "https://dev.renku.ch/gitlab/tasko.olevski/test-project-2.git"
12+
13+
// Two sets of credentials are needed only if the Renku deployment
14+
// has a separate Gitlab that requires logging into another Renku
15+
// instance. So for dev.renku.ch you need one set of credentials
16+
// for CI deployments you need 2. First the credentials to the
17+
// CI deployment then the ones for dev.renku.ch.
18+
export const credentials = [
19+
{
20+
username: "[email protected]",
21+
password: "secret-password1"
22+
},
23+
{
24+
username: "[email protected]",
25+
password: "secret-password1"
26+
},
27+
]

load-tests/fileUpload.js

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Creator: k6 Browser Recorder 0.6.2
2+
3+
import { sleep, check, fail } from 'k6'
4+
import http from 'k6/http'
5+
import { uuidv4 } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';
6+
import crypto from 'k6/crypto';
7+
import { URL } from 'https://jslib.k6.io/url/1.0.0/index.js';
8+
9+
import { renkuLogin } from './oauth.js'
10+
import { credentials, baseUrl } from './config.js'
11+
12+
export const options = {
13+
scenarios: {
14+
testUploads: {
15+
executor: 'per-vu-iterations',
16+
vus: 3,
17+
iterations: 1,
18+
},
19+
}
20+
}
21+
22+
function uploadRandomFile(baseUrl, uuid, fileName, numChunks, chunkSizeBytes) {
23+
const responses = []
24+
for (let i = 0; i < numChunks; i++) {
25+
const url = new URL(`ui-server/api/renku/cache.files_upload`, baseUrl);
26+
url.searchParams.append('dzuuid', uuid);
27+
url.searchParams.append('dzchunkindex', i);
28+
url.searchParams.append('dztotalfilesize', numChunks * chunkSizeBytes);
29+
url.searchParams.append('dzchunksize', chunkSizeBytes);
30+
url.searchParams.append('dztotalchunkcount', numChunks);
31+
url.searchParams.append('dzchunkbyteoffset', i * chunkSizeBytes);
32+
url.searchParams.append('chunked_content_type', "application/octet-stream");
33+
const res = http.post(url.toString(), {
34+
file: http.file(crypto.randomBytes(chunkSizeBytes), fileName, "application/octet-stream")
35+
})
36+
responses.push(res)
37+
}
38+
if (!check(responses, {
39+
"file uploads all have 200 repsonses": (responses) => responses.every(res => res.status === 200),
40+
"file uploads all completed without errors": (responses) => responses.every(res => res.json().error === undefined),
41+
})) {
42+
const errResponses = responses.filter(res => res.json().error !== undefined).map(res => res.json())
43+
const failedResponsesBody = responses.filter(res => res.status != 200).map(res => res.body)
44+
const failedResponsesCodes = responses.filter(res => res.status != 200).map(res => res.status)
45+
fail(
46+
`some responses failed with errors ${JSON.stringify(errResponses)}\nsome respones ` +
47+
`failed with non-200 status codes codes: ${JSON.stringify(failedResponsesCodes)} bodies: ${JSON.stringify(failedResponsesBody)}`
48+
)
49+
};
50+
return responses[numChunks - 1]
51+
}
52+
53+
export default function fileUpload() {
54+
renkuLogin(baseUrl, credentials)
55+
const baseUrlResponse = http.get(baseUrl)
56+
const projects = http.get(`${baseUrl}/ui-server/api/projects?query=last_activity_at&per_page=100&starred=true&page=1`)
57+
check(baseUrlResponse, {
58+
"baseUrl responds with status 200": (r) => r.status === 200,
59+
});
60+
check(projects, {
61+
'projects list endpoint responds with status 200': (r) => r.status === 200,
62+
'project list exists': (r) => r.json().length >= 0,
63+
});
64+
sleep(1)
65+
const uploads = http.get(`${baseUrl}/ui-server/api/renku/cache.files_list`)
66+
check(uploads, {
67+
'uploads list response does not have errors': (r) => r.json().error === undefined,
68+
'uploads list response contains a list of uploads': (r) => r.json().result.files.length >= 0,
69+
});
70+
sleep(1)
71+
const uuid = uuidv4()
72+
const fileName = `${uuid}.bin`
73+
const fileUploadResponse = uploadRandomFile(baseUrl, uuid, fileName, 100, 1e6)
74+
let uploadedFiles = fileUploadResponse.json().result.files
75+
if (uploadedFiles === undefined) {
76+
uploadedFiles = []
77+
}
78+
uploadedFiles = uploadedFiles.map(i => i.file_name)
79+
if (!check(uploadedFiles, {
80+
'file name found in last upload response': (r) => r.includes(fileName),
81+
})) {
82+
fail(`could not find file in last upload response, body: ${fileUploadResponse.body}, status code: ${fileUploadResponse.status}`)
83+
}
84+
}

load-tests/oauth.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import http from 'k6/http';
2+
import { parseHTML } from 'k6/html';
3+
4+
function handleRenkuLoginForm(httpResponse, username, password) {
5+
const doc = parseHTML(httpResponse.body);
6+
const actionUrl = doc.find('#kc-form-login').attr('action');
7+
if (!actionUrl) {
8+
throw new Error(`Could not locate login form in http response ${httpResponse.body}`)
9+
}
10+
const loginData = {
11+
username,
12+
password,
13+
credentialId: '',
14+
};
15+
return http.post(actionUrl, loginData)
16+
}
17+
18+
function followRedirectLinkFromHtml(httpResponse) {
19+
const doc = parseHTML(httpResponse.body);
20+
let url = doc.find('a').attr("href")
21+
if (!url) {
22+
throw new Error(`Could not find <a> element with href attribute in ${httpResponse.body}`)
23+
}
24+
if (url.endsWith("/")) {
25+
// leaving trailing slashes here results in 404
26+
url = url.slice(0,-1)
27+
}
28+
return http.get(url)
29+
}
30+
31+
export function renkuLogin(baseUrl, credentials) {
32+
// double slashes when composing url causes trouble and 404s
33+
if (baseUrl.endsWith("/")) {
34+
baseUrl = baseUrl.slice(0,-1)
35+
}
36+
// the trailing slash is needed here keycloak accepts only such and longer callbacks
37+
const redirectUrl = `${baseUrl}/`
38+
let finalResponse = null
39+
const res1 = http.get(`${baseUrl}/ui-server/auth/login?redirect_url=${redirectUrl}"`)
40+
const res2 = handleRenkuLoginForm(res1, credentials[0].username, credentials[0].password)
41+
const res3 = followRedirectLinkFromHtml(res2)
42+
if (res3.body.match(".*redirect.*|.*Redirect.*") && parseHTML(res3.body).find("a").toArray().length > 0) {
43+
// no more login forms just follow a single last redirect
44+
finalResponse = followRedirectLinkFromHtml(res3)
45+
}
46+
else if (parseHTML(res3.body).find('#kc-form-login').toArray().length > 0) {
47+
// one more login required, usually happens for ci and similar deployments that do not have their own gitlab
48+
const res4 = handleRenkuLoginForm(res3, credentials[1].username, credentials[1].password)
49+
finalResponse = followRedirectLinkFromHtml(res4)
50+
}
51+
if (finalResponse.status != 200) {
52+
throw new Error(`Could not successfully login, expected status code 200 but got ${finalResponse.status}`)
53+
}
54+
}

load-tests/parallelRequests.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import http from 'k6/http';
2+
import { check, fail, sleep } from 'k6';
3+
4+
import { renkuLogin } from './oauth.js'
5+
import { credentials, baseUrl, sampleGitProjectUrl } from './config.js'
6+
7+
export const options = {
8+
scenarios: {
9+
testUploads: {
10+
executor: 'per-vu-iterations',
11+
vus: 3,
12+
iterations: 1,
13+
},
14+
}
15+
}
16+
17+
function getTemplates(baseUrl, templatesRef) {
18+
const res = http.get(`${baseUrl}/ui-server/api/renku/templates.read_manifest?url=https%3A%2F%2Fgithub.com%2FSwissDataScienceCenter%2Frenku-project-template&ref=${templatesRef}`)
19+
if (!check(res, {
20+
"reading templates succeeded with 2XX": (res) => res.status >= 200 && res.status < 300,
21+
"reading templates response has no error": (res) => res.json().error === undefined,
22+
"reading templates response more than zero templates": (res) => res.json().result.templates.length > 0,
23+
})) {
24+
fail(`reading templates failed with status code ${res.status} and response ${res.body}`)
25+
}
26+
return res
27+
}
28+
29+
function showProjectInfo(baseUrl, gitUrl) {
30+
const payload = {
31+
git_url: gitUrl,
32+
is_delayed: false,
33+
migrate_project: false
34+
}
35+
const res = http.post(
36+
`${baseUrl}/ui-server/api/renku/project.show`,
37+
JSON.stringify(payload),
38+
{ headers: { "Content-Type": "application/json" } },
39+
)
40+
if (!check(res, {
41+
"getting project info succeeded with 2XX": (res) => res.status >= 200 && res.status < 300,
42+
"getting project info response has no error": (res) => res.json().error === undefined,
43+
})) {
44+
fail(`getting project info failed with status ${res.status} and body ${res.body}`)
45+
}
46+
return res
47+
}
48+
49+
export default function test() {
50+
renkuLogin(baseUrl, credentials)
51+
const templatesRef = "0.3.4"
52+
getTemplates(baseUrl, templatesRef)
53+
sleep(2)
54+
showProjectInfo(baseUrl, sampleGitProjectUrl)
55+
}

load-tests/projectMigration.js

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import http from 'k6/http';
2+
import { check, fail, sleep } from 'k6';
3+
import { uuidv4 } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';
4+
5+
import { renkuLogin } from './oauth.js'
6+
import { credentials, baseUrl, oldGitlabProjectId } from './config.js'
7+
8+
export const options = {
9+
scenarios: {
10+
testUploads: {
11+
executor: 'per-vu-iterations',
12+
vus: 3,
13+
iterations: 1,
14+
},
15+
}
16+
}
17+
18+
function forkProject(baseUrl, gitlabProjectId) {
19+
const uuid = uuidv4()
20+
let projectData = http.get(`${baseUrl}/ui-server/api/projects/${gitlabProjectId}`)
21+
check( projectData, {
22+
"response code for getting project info was 2XX": (res) => res.status >= 200 && res.status < 300,
23+
})
24+
projectData = projectData.json()
25+
let forkName = `test-forked-project-${uuid}`
26+
const payload = {
27+
id: gitlabProjectId,
28+
name: forkName,
29+
namespace_id: projectData.namespace.id,
30+
path: forkName,
31+
visibility: projectData.visibility,
32+
}
33+
let forkRes = http.post(`${baseUrl}/ui-server/api/projects/${gitlabProjectId}/fork`, payload)
34+
if (!check( forkRes, {
35+
"response code for forking was 2XX": (res) => res.status >= 200 && res.status < 300,
36+
})) {
37+
fail(`forking failed with code ${forkRes.status} and body ${forkRes.body}`)
38+
}
39+
sleep(5)
40+
return forkRes
41+
}
42+
43+
function migrateProject(baseUrl, gitlabProjectId) {
44+
let projectData = http.get(`${baseUrl}/ui-server/api/projects/${gitlabProjectId}`)
45+
check( projectData, {
46+
"response code for getting project info is 2XX": (res) => res.status >= 200 && res.status < 300,
47+
})
48+
projectData = projectData.json()
49+
const migratePayload = {
50+
branch: projectData.default_branch,
51+
force_template_update: true,
52+
git_url: projectData.http_url_to_repo,
53+
is_delayed: false,
54+
skip_docker_update: false,
55+
skip_migrations: false,
56+
skip_template_update: false,
57+
}
58+
const migrateRes = http.post(
59+
`${baseUrl}/ui-server/api/renku/cache.migrate`,
60+
JSON.stringify(migratePayload), {headers: {"Content-Type": "application/json"}}
61+
)
62+
if (!check( migrateRes, {
63+
"response code for migrating is 2XX": (res) => res.status >= 200 && res.status < 300,
64+
})) {
65+
fail(`migration completed with code ${migrateRes.status}`)
66+
}
67+
if (!check( migrateRes, {
68+
"migration request has no errors": (res) => res.json().error === undefined,
69+
})) {
70+
fail(`migration completed with errors: ${JSON.stringify(migrateRes.json().error)}`)
71+
}
72+
check( migrateRes, {
73+
"was_migrated is true in migration response": (res) => res.json().result.was_migrated,
74+
})
75+
return migrateRes
76+
}
77+
78+
export default function test() {
79+
renkuLogin(baseUrl, credentials)
80+
const forkedProjectResponse = forkProject(baseUrl, oldGitlabProjectId)
81+
const forkProjectId = forkedProjectResponse.json().id
82+
migrateProject(baseUrl, forkProjectId)
83+
const res = http.del(`${baseUrl}/ui-server/api/projects/${forkProjectId}`)
84+
check( res, {
85+
"deletion of fork succeeded with 2XX": (res) => res.status >= 200 && res.status < 300,
86+
})
87+
}

0 commit comments

Comments
 (0)