Skip to content

Commit 1ec9825

Browse files
authored
New: [AEA-5411] - Create load test for PSU Notify (#460)
## Summary - ✨ New Feature ### Details Defines a github action to run the load testing against the PSU, in a way that should help evaluate the notify integration
1 parent 97cfc21 commit 1ec9825

File tree

13 files changed

+385
-23
lines changed

13 files changed

+385
-23
lines changed

.devcontainer/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ RUN apt-get update \
99
jq apt-transport-https ca-certificates gnupg-agent \
1010
software-properties-common bash-completion python3-pip make libbz2-dev \
1111
libreadline-dev libsqlite3-dev wget llvm libncurses5-dev libncursesw5-dev \
12-
xz-utils tk-dev liblzma-dev netcat libyaml-dev
12+
xz-utils tk-dev liblzma-dev netcat-traditional libyaml-dev
1313

1414
# install aws stuff
1515
RUN wget -O /tmp/awscliv2.zip "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" && \

.github/workflows/run_notify_load.yml

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,25 @@ on:
88
required: true
99
default: 'ref'
1010
arrivalRate:
11-
description: 'The number of new users to add every second'
11+
description: 'How many requests to send per second during the main test'
1212
required: true
13-
default: '275'
13+
default: '100'
1414
duration:
1515
description: 'The duration of the main test'
1616
required: true
1717
default: '900'
1818
rampUpDuration:
19-
description: 'The duration to ramp up to the arrival rate'
19+
description: 'The duration of ramp-up phase of the test'
2020
required: true
2121
default: '900'
2222
maxVusers:
23-
description: 'Maximum number of vusers to create'
23+
description: 'Maximum number of virtual users to create'
2424
required: true
25-
default: '10000'
25+
default: '100'
26+
artillery_key:
27+
description: 'Your Artillery cloud API key (optional – omit to run without recording)'
28+
required: false
29+
default: ''
2630

2731
jobs:
2832
run_artillery:
@@ -40,6 +44,11 @@ jobs:
4044
echo "## duration : ${{ github.event.inputs.duration }}" >> "$GITHUB_STEP_SUMMARY"
4145
echo "## rampUpDuration : ${{ github.event.inputs.rampUpDuration }}" >> "$GITHUB_STEP_SUMMARY"
4246
echo "## maxVusers : ${{ github.event.inputs.maxVusers }}" >> "$GITHUB_STEP_SUMMARY"
47+
if [ -n "${{ github.event.inputs.artillery_key }}" ]; then
48+
echo "## artillery_key : (provided)" >> "$GITHUB_STEP_SUMMARY"
49+
else
50+
echo "## artillery_key : (not provided)" >> "$GITHUB_STEP_SUMMARY"
51+
fi
4352
4453
- name: Checkout repo
4554
uses: actions/checkout@v4
@@ -65,7 +74,7 @@ jobs:
6574
with:
6675
asdf_branch: v0.14.0
6776
env:
68-
PYTHON_CONFIGURE_OPTS: --enable-shared
77+
PYTHON_CONFIGURE_OPTS: --enable-shared
6978

7079
- name: Install Dependencies
7180
run: make install
@@ -94,6 +103,7 @@ jobs:
94103
duration: ${{ github.event.inputs.duration }}
95104
rampUpDuration: ${{ github.event.inputs.rampUpDuration }}
96105
maxVusers: ${{ github.event.inputs.maxVusers }}
106+
artillery_key: ${{ github.event.inputs.artillery_key }}
97107
run: |
98108
./scripts/run_notify_load_test.sh
99109

Makefile

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ compile-node:
1010

1111
compile: compile-node
1212

13+
aws-configure:
14+
aws configure sso --region eu-west-2
15+
1316
aws-login:
1417
aws sso login --sso-session sso-session
1518

@@ -36,4 +39,7 @@ local-cpsu:
3639
./scripts/test_cpsu_load_test.sh
3740

3841
local-psu:
39-
./scripts/test_psu_load_test.sh
42+
./scripts/test_psu_load_test.sh
43+
44+
local-notify:
45+
./scripts/test_notify_load_test.sh

artillery/helper/odscodes.mjs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// These ODS codes should match the values in AWS parameter store. They're assumed to be enabled in the whitelist
2+
export const allowedOdsCodes = [
3+
"FA565"
4+
]
5+
6+
export const blockedOdsCodes = [
7+
"B3J1Z"
8+
]

artillery/helper/psu.mjs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,19 @@ const logger = pino()
66
let oauthToken
77
let tokenExpiryTime
88

9-
export function getBody(isValid = true) {
9+
export function getBody(
10+
isValid = true,
11+
status = "in-progress",
12+
odsCode = "C9Z10",
13+
nhsNumber = "9449304130",
14+
businessStatus = "With Pharmacy",
15+
) {
1016
// If this is intended to be a failed request, mangle the prescription ID.
1117
const prescriptionID = isValid ? shortPrescId() : invalidShortPrescId();
1218

1319
const task_identifier = uuidv4()
1420
const prescriptionOrderItemNumber = uuidv4()
15-
const nhsNumber = "9449304130"
1621
const currentTimestamp = new Date().toISOString()
17-
const odsCode = "C9Z1O"
18-
const status = "in-progress"
19-
const businessStatus = "With Pharmacy"
2022
const body = {
2123
resourceType: "Bundle",
2224
type: "transaction",
@@ -112,6 +114,8 @@ export async function getSharedAuthToken(vuContext) {
112114
const api_key = process.env.psu_api_key
113115
const kid = process.env.psu_kid
114116

117+
logger.info("Secrets:", {privateKey, api_key, kid})
118+
115119
// And use them to fetch the access token
116120
const response = await getAccessToken(logger, vuContext.vars.target, privateKey, api_key, kid)
117121

@@ -130,7 +134,6 @@ export async function getPSUParams(requestParams, vuContext) {
130134
const isValid = vuContext.scenario.tags.isValid
131135
const body = getBody(isValid)
132136

133-
requestParams.json = body
134137
// This sets the body of the request and some variables so headers are unique
135138
requestParams.json = body
136139
vuContext.vars.x_request_id = uuidv4()

artillery/notify_entrypoint.mjs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import {v4 as uuidv4} from "uuid"
2+
import pino from "pino"
3+
import {getSharedAuthToken, getBody} from "./helper/psu.mjs"
4+
import {allowedOdsCodes, blockedOdsCodes} from "./helper/odscodes.mjs"
5+
6+
export { getSharedAuthToken }
7+
8+
const logger = pino()
9+
10+
function computeCheckDigit(nhsNumber) {
11+
const factors = [10,9,8,7,6,5,4,3,2]
12+
let total = 0
13+
14+
for (let i = 0; i < 9; i++) {
15+
total += parseInt(nhsNumber.charAt(i),10) * factors[i]
16+
}
17+
18+
const rem = total % 11
19+
let d = 11 - rem
20+
if (d === 11) d = 0
21+
22+
return d
23+
}
24+
25+
function generateValidNhsNumber() {
26+
while (true) {
27+
const partial = Array.from({length:9},() => Math.floor(Math.random()*10)).join("")
28+
const cd = computeCheckDigit(partial)
29+
if (cd < 10) return partial + cd
30+
}
31+
}
32+
33+
// Apparently Math.sampleNormal isn't a function? Do a quick Box-Muller transform instead
34+
function sampleNormal(mean = 0, sd = 1) {
35+
let u = 0, v = 0;
36+
// avoid zeros because of log(0)
37+
while (u === 0) u = Math.random();
38+
while (v === 0) v = Math.random();
39+
const z = Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
40+
return z * sd + mean;
41+
}
42+
43+
function initUserContextVars(context) {
44+
context.vars.nhsNumber = generateValidNhsNumber()
45+
46+
let prescriptionCount = Math.round(sampleNormal(3,1))
47+
if (prescriptionCount < 1) prescriptionCount = 1 // just truncate at 1.
48+
context.vars.prescriptionCount = prescriptionCount
49+
context.vars.loopcount = 0
50+
}
51+
52+
export function initUserAllowed(context, events, done) {
53+
logger.info("Initializing user context variables for allowed ODS codes")
54+
initUserContextVars(context)
55+
56+
// Generate data for a patient with an allowed ODS code
57+
context.vars.odsCode = allowedOdsCodes[Math.floor(Math.random() * allowedOdsCodes.length)]
58+
59+
logger.info(`[ALLOWED] Patient ${context.vars.nhsNumber}, ODS ${context.vars.odsCode} has ${context.vars.prescriptionCount} prescriptions`)
60+
done()
61+
}
62+
63+
export function initUserBlocked(context, events, done) {
64+
logger.info("Initializing user context variables for allowed ODS codes")
65+
initUserContextVars(context)
66+
67+
// Generate data for a patient with a blocked ODS code
68+
context.vars.odsCode = blockedOdsCodes[Math.floor(Math.random() * blockedOdsCodes.length)]
69+
70+
logger.info(`[BLOCKED] Patient ${context.vars.nhsNumber}, ODS ${context.vars.odsCode} has ${context.vars.prescriptionCount} prescriptions`)
71+
done()
72+
}
73+
74+
export function generatePrescData(requestParams, context, ee, next) {
75+
const isAllowed = allowedOdsCodes.includes(context.vars.odsCode);
76+
const logPrefix = isAllowed ? "[ALLOWED]" : "[BLOCKED]";
77+
78+
logger.debug(`${logPrefix} Generating a prescription for patient ${context.vars.nhsNumber}`)
79+
const body = getBody(
80+
true, /* isValid */
81+
"completed", /* status */
82+
context.vars.odsCode, /* odsCode */
83+
context.vars.nhsNumber, /* nhsNumber */
84+
"ready to collect" /* Item status */
85+
)
86+
// The body is fine - it works when I put it in postman
87+
88+
requestParams.json = body
89+
context.vars.x_request_id = uuidv4()
90+
context.vars.x_correlation_id = uuidv4()
91+
92+
context.vars.loopcount += 1
93+
94+
// Wait this long between requests
95+
let meanDelay = 10 // seconds
96+
let stdDevDelay = 10 // seconds
97+
let delay = 0
98+
if (context.vars.loopcount < context.vars.prescriptionCount) {
99+
delay = sampleNormal(meanDelay, stdDevDelay)
100+
while (delay < 0) delay = sampleNormal(meanDelay, stdDevDelay)
101+
}
102+
103+
context.vars.nextDelay = delay
104+
logger.debug(`${logPrefix} Patient ${context.vars.nhsNumber} (on prescription update ${context.vars.loopcount}/${context.vars.prescriptionCount}) will think for ${context.vars.nextDelay} seconds`)
105+
next()
106+
}

artillery/notify_load_test.yml

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
config:
2+
processor: "./notify_entrypoint.mjs"
3+
plugins:
4+
expect: {}
5+
apdex: {}
6+
phases:
7+
- name: ramp up phase
8+
duration: "{{ $env.rampUpDuration }}"
9+
arrivalRate: 1
10+
rampTo: "{{ $env.arrivalRate }}"
11+
maxVusers: "{{ $env.maxVusers }}"
12+
- name: run phase
13+
duration: "{{ $env.duration }}"
14+
arrivalRate: "{{ $env.arrivalRate }}"
15+
maxVusers: "{{ $env.maxVusers }}"
16+
environments:
17+
dev:
18+
target: https://internal-dev.api.service.nhs.uk/
19+
ref:
20+
target: https://ref.api.service.nhs.uk/
21+
int:
22+
target: https://int.api.service.nhs.uk/
23+
pr:
24+
target: https://psu-pr-{{ prNumber }}.dev.eps.national.nhs.uk/
25+
26+
before:
27+
flow:
28+
- function: getSharedAuthToken
29+
30+
scenarios:
31+
- name: Allowed ODS code scenario
32+
weight: 1
33+
flow:
34+
- function: initUserAllowed
35+
- loop:
36+
- function: getSharedAuthToken
37+
- post:
38+
url: "/prescription-status-update/"
39+
beforeRequest: "generatePrescData"
40+
headers:
41+
Authorization: "Bearer {{ authToken }}"
42+
x-request-id: "{{ x_request_id }}"
43+
x-correlation-id: "{{ x_correlation_id }}"
44+
expect:
45+
- statusCode: 201
46+
- think: "{{ nextDelay }}seconds"
47+
count: "{{ prescriptionCount }}"
48+
49+
- name: Blocked ODS code scenario
50+
weight: 0
51+
flow:
52+
- function: initUserBlocked
53+
- loop:
54+
- function: getSharedAuthToken
55+
- post:
56+
url: "/prescription-status-update/"
57+
beforeRequest: "generatePrescData"
58+
headers:
59+
Authorization: "Bearer {{ authToken }}"
60+
x-request-id: "{{ x_request_id }}"
61+
x-correlation-id: "{{ x_correlation_id }}"
62+
expect:
63+
- statusCode: 201
64+
- think: "{{ nextDelay }}seconds"
65+
count: "{{ prescriptionCount }}"

scripts/run_cpsu_load_test.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ npx artillery run-fargate \
5757
--security-group-ids "${security_group}" \
5858
--subnet-ids "${vpc_subnets}" \
5959
--task-role-name "${artillery_worker_role_name}" \
60-
--dotenv runtimeenv.env \
60+
--env-file runtimeenv.env \
6161
--output cpsu_load_test.json \
6262
artillery/cpsu_load_test.yml
6363

scripts/run_notify_load_test.sh

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
#!/usr/bin/env bash
2+
3+
4+
if [ -z "${environment}" ]; then
5+
echo "environment is unset or set to the empty string"
6+
exit 1
7+
fi
8+
9+
if [ -z "${maxVusers}" ]; then
10+
echo "maxVusers is unset or set to the empty string"
11+
exit 1
12+
fi
13+
14+
if [ -z "${duration}" ]; then
15+
echo "duration is unset or set to the empty string"
16+
exit 1
17+
fi
18+
19+
if [ -z "${rampUpDuration}" ]; then
20+
echo "rampUpDuration is unset or set to the empty string"
21+
exit 1
22+
fi
23+
24+
if [ -z "${arrivalRate}" ]; then
25+
echo "arrivalRate is unset or set to the empty string"
26+
exit 1
27+
fi
28+
29+
if ! [[ "${environment}" =~ ^(dev|ref)$ ]]; then
30+
echo "environment must be dev or ref"
31+
exit 1
32+
fi
33+
34+
security_group=$(aws cloudformation list-exports --output json | jq -r '.Exports[] | select(.Name == "artillery-resources:ArtillerySecurityGroupId") | .Value' | grep -o '[^:]*$')
35+
export security_group
36+
vpc_subnets=$(aws cloudformation list-exports --output json | jq -r '.Exports[] | select(.Name == "vpc-resources:PrivateSubnets") | .Value' | grep -o '[^:]*$')
37+
export vpc_subnets
38+
39+
artillery_worker_role_name=$(aws cloudformation list-exports --output json | jq -r '.Exports[] | select(.Name == "artillery-resources:ArtilleryWorkerRoleName") | .Value' | grep -o '[^:]*$')
40+
export artillery_worker_role_name
41+
42+
if [ -z "${artillery_key}" ]; then
43+
echo "artillery_key is unset. Running without --record to Artillery Cloud."
44+
RECORD_ARGS=""
45+
else
46+
RECORD_ARGS="--record --key ${artillery_key}"
47+
fi
48+
49+
cat <<EOF > runtimeenv.env
50+
maxVusers=${maxVusers}
51+
duration=${duration}
52+
arrivalRate=${arrivalRate}
53+
rampUpDuration=${rampUpDuration}
54+
EOF
55+
56+
echo ${launch_config}
57+
58+
# shellcheck disable=SC2090,SC2086
59+
npx artillery run-fargate \
60+
--environment "${environment}" \
61+
--secret psu_api_key psu_private_key psu_kid \
62+
--region eu-west-2 \
63+
--cluster artilleryio-cluster \
64+
--security-group-ids "${security_group}" \
65+
--subnet-ids "${vpc_subnets}" \
66+
--task-role-name "${artillery_worker_role_name}" \
67+
--env-file runtimeenv.env \
68+
--output notify_load_test.json \
69+
$RECORD_ARGS \
70+
artillery/notify_load_test.yml
71+
72+
npx artillery report notify_load_test.json

0 commit comments

Comments
 (0)