Skip to content

Commit cd0f850

Browse files
authored
Merge branch 'main' into dependabot/github_actions/asdf-vm/actions-4.0.0
2 parents 52bf449 + 2083bb0 commit cd0f850

16 files changed

Lines changed: 894 additions & 460 deletions

.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" && \
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
name: Run psu notify load test
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
environment:
7+
description: 'Environment to run tests against. Allowed values are dev or ref'
8+
required: true
9+
default: 'ref'
10+
arrivalRate:
11+
description: 'How many requests to send per second during the main test'
12+
required: true
13+
default: '100'
14+
duration:
15+
description: 'The duration of the main test'
16+
required: true
17+
default: '900'
18+
rampUpDuration:
19+
description: 'The duration of ramp-up phase of the test'
20+
required: true
21+
default: '900'
22+
maxVusers:
23+
description: 'Maximum number of virtual users to create'
24+
required: true
25+
default: '100'
26+
artillery_key:
27+
description: 'Your Artillery cloud API key (optional – omit to run without recording)'
28+
required: false
29+
default: ''
30+
31+
jobs:
32+
run_artillery:
33+
name: Run Artillery
34+
runs-on: ubuntu-latest
35+
permissions:
36+
id-token: write
37+
contents: read
38+
steps:
39+
- name: Show input params
40+
shell: bash
41+
run: |
42+
echo "## environment : ${{ github.event.inputs.environment }}" >> "$GITHUB_STEP_SUMMARY"
43+
echo "## arrivalRate : ${{ github.event.inputs.arrivalRate }}" >> "$GITHUB_STEP_SUMMARY"
44+
echo "## duration : ${{ github.event.inputs.duration }}" >> "$GITHUB_STEP_SUMMARY"
45+
echo "## rampUpDuration : ${{ github.event.inputs.rampUpDuration }}" >> "$GITHUB_STEP_SUMMARY"
46+
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
52+
53+
- name: Checkout repo
54+
uses: actions/checkout@v4
55+
with:
56+
ref: ${{ env.BRANCH_NAME }}
57+
58+
- name: Install asdf
59+
uses: asdf-vm/actions/setup@05e0d2ed97b598bfce82fd30daf324ae0c4570e6
60+
with:
61+
asdf_branch: v0.14.0
62+
63+
- name: Cache asdf
64+
uses: actions/cache@v4
65+
with:
66+
path: |
67+
~/.asdf
68+
key: ${{ runner.os }}-asdf-${{ hashFiles('**/.tool-versions') }}
69+
restore-keys: |
70+
${{ runner.os }}-asdf-
71+
72+
- name: Install asdf dependencies in .tool-versions
73+
uses: asdf-vm/actions/install@05e0d2ed97b598bfce82fd30daf324ae0c4570e6
74+
with:
75+
asdf_branch: v0.14.0
76+
env:
77+
PYTHON_CONFIGURE_OPTS: --enable-shared
78+
79+
- name: Install Dependencies
80+
run: make install
81+
82+
- name: Assume dev artillery runner role
83+
if: github.event.inputs.environment == 'dev'
84+
uses: aws-actions/configure-aws-credentials@v4
85+
with:
86+
aws-region: eu-west-2
87+
role-to-assume: ${{ secrets.DEV_ARTILLERY_RUNNER_ROLE }}
88+
role-session-name: github-actions-artillery
89+
90+
- name: Assume ref artillery runner role
91+
if: github.event.inputs.environment == 'ref'
92+
uses: aws-actions/configure-aws-credentials@v4
93+
with:
94+
aws-region: eu-west-2
95+
role-to-assume: ${{ secrets.REF_ARTILLERY_RUNNER_ROLE }}
96+
role-session-name: github-actions-artillery
97+
98+
- name: Run load tests
99+
shell: bash
100+
env:
101+
environment: ${{ github.event.inputs.environment }}
102+
arrivalRate: ${{ github.event.inputs.arrivalRate }}
103+
duration: ${{ github.event.inputs.duration }}
104+
rampUpDuration: ${{ github.event.inputs.rampUpDuration }}
105+
maxVusers: ${{ github.event.inputs.maxVusers }}
106+
artillery_key: ${{ github.event.inputs.artillery_key }}
107+
run: |
108+
./scripts/run_notify_load_test.sh
109+
110+
- uses: actions/upload-artifact@v4
111+
if: always()
112+
name: Upload test_report
113+
with:
114+
name: test_report
115+
path: |
116+
notify_load_test.json
117+
notify_load_test.json.html
118+
119+
- name: Upload artifacts to S3, if we are using REF environment
120+
if: github.event.inputs.environment == 'ref'
121+
continue-on-error: true
122+
env:
123+
AWS_REGION: eu-west-2
124+
BUCKET_NAME: artilleryio-test-data-${{ secrets.AWS_ACCOUNT_ID }}
125+
RUN_ID: ${{ github.run_id }}
126+
run: |
127+
aws s3 cp notify_load_test.json s3://$BUCKET_NAME/reports/$RUN_ID/notify_load_test.json --acl public-read
128+
aws s3 cp notify_load_test.json.html s3://$BUCKET_NAME/reports/$RUN_ID/notify_load_test.json.html --acl public-read
129+
130+
- name: Output link to HTML report
131+
if: github.event.inputs.environment == 'ref'
132+
env:
133+
AWS_REGION: eu-west-2
134+
BUCKET_NAME: artilleryio-test-data-${{ secrets.AWS_ACCOUNT_ID }}
135+
RUN_ID: ${{ github.run_id }}
136+
run: |
137+
REPORT_URL="https://$BUCKET_NAME.s3.${AWS_REGION}.amazonaws.com/reports/$RUN_ID/notify_load_test.json.html"
138+
echo "Test report is hosted at: $REPORT_URL" >> "$GITHUB_STEP_SUMMARY"
139+
echo "::set-output name=report_url::$REPORT_URL"

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 }}"

0 commit comments

Comments
 (0)