From c5f3af2def201713e8426b9103d7d38ba3cd2409 Mon Sep 17 00:00:00 2001 From: Jim Wild Date: Tue, 24 Jun 2025 13:42:09 +0000 Subject: [PATCH 01/10] Squash all changes to overwrite mistakenly committed secret value --- .devcontainer/Dockerfile | 2 +- .github/workflows/run_notify_load.yml | 7 +- Makefile | 5 +- artillery/helper/odscodes.mjs | 8 ++ artillery/helper/psu.mjs | 13 +-- artillery/notify_entrypoint.mjs | 111 ++++++++++++++++++++++++++ artillery/notify_load_test.yml | 44 ++++++++++ scripts/run_cpsu_load_test.sh | 2 +- scripts/run_notify_load_test.sh | 64 +++++++++++++++ scripts/run_psu_load_test.sh | 2 +- scripts/test_cpsu_load_test.sh | 4 +- scripts/test_notify_load_test.sh | 72 +++++++++++++++++ scripts/test_psu_load_test.sh | 4 +- 13 files changed, 318 insertions(+), 20 deletions(-) create mode 100644 artillery/helper/odscodes.mjs create mode 100644 artillery/notify_entrypoint.mjs create mode 100644 artillery/notify_load_test.yml create mode 100755 scripts/run_notify_load_test.sh create mode 100755 scripts/test_notify_load_test.sh diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index f11ba757..7274b289 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -9,7 +9,7 @@ RUN apt-get update \ jq apt-transport-https ca-certificates gnupg-agent \ software-properties-common bash-completion python3-pip make libbz2-dev \ libreadline-dev libsqlite3-dev wget llvm libncurses5-dev libncursesw5-dev \ - xz-utils tk-dev liblzma-dev netcat libyaml-dev + xz-utils tk-dev liblzma-dev netcat-traditional libyaml-dev # install aws stuff RUN wget -O /tmp/awscliv2.zip "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" && \ diff --git a/.github/workflows/run_notify_load.yml b/.github/workflows/run_notify_load.yml index d6ea151e..8ffb5440 100644 --- a/.github/workflows/run_notify_load.yml +++ b/.github/workflows/run_notify_load.yml @@ -15,14 +15,10 @@ on: description: 'The duration of the main test' required: true default: '900' - rampUpDuration: - description: 'The duration to ramp up to the arrival rate' - required: true - default: '900' maxVusers: description: 'Maximum number of vusers to create' required: true - default: '10000' + default: '1000' jobs: run_artillery: @@ -38,7 +34,6 @@ jobs: echo "## environment : ${{ github.event.inputs.environment }}" >> "$GITHUB_STEP_SUMMARY" echo "## arrivalRate : ${{ github.event.inputs.arrivalRate }}" >> "$GITHUB_STEP_SUMMARY" echo "## duration : ${{ github.event.inputs.duration }}" >> "$GITHUB_STEP_SUMMARY" - echo "## rampUpDuration : ${{ github.event.inputs.rampUpDuration }}" >> "$GITHUB_STEP_SUMMARY" echo "## maxVusers : ${{ github.event.inputs.maxVusers }}" >> "$GITHUB_STEP_SUMMARY" - name: Checkout repo diff --git a/Makefile b/Makefile index 88a4bcbd..ac6a846a 100644 --- a/Makefile +++ b/Makefile @@ -36,4 +36,7 @@ local-cpsu: ./scripts/test_cpsu_load_test.sh local-psu: - ./scripts/test_psu_load_test.sh \ No newline at end of file + ./scripts/test_psu_load_test.sh + +local-notify: + ./scripts/test_notify_load_test.sh \ No newline at end of file diff --git a/artillery/helper/odscodes.mjs b/artillery/helper/odscodes.mjs new file mode 100644 index 00000000..42869b24 --- /dev/null +++ b/artillery/helper/odscodes.mjs @@ -0,0 +1,8 @@ +// These ODS codes should match the values in AWS parameter store. They're assumed to be enabled in the whitelist +export const allowedOdsCodes = [ + "FA565" +] + +export const blockedOdsCodes = [ + // "B3J1Z" +] \ No newline at end of file diff --git a/artillery/helper/psu.mjs b/artillery/helper/psu.mjs index fbaae91f..025fe7bf 100644 --- a/artillery/helper/psu.mjs +++ b/artillery/helper/psu.mjs @@ -6,17 +6,19 @@ const logger = pino() let oauthToken let tokenExpiryTime -export function getBody(isValid = true) { +export function getBody( + isValid = true, + status = "in-progress", + odsCode = "C9Z10", + nhsNumber = "9449304130", + businessStatus = "With Pharmacy", +) { // If this is intended to be a failed request, mangle the prescription ID. const prescriptionID = isValid ? shortPrescId() : invalidShortPrescId(); const task_identifier = uuidv4() const prescriptionOrderItemNumber = uuidv4() - const nhsNumber = "9449304130" const currentTimestamp = new Date().toISOString() - const odsCode = "C9Z1O" - const status = "in-progress" - const businessStatus = "With Pharmacy" const body = { resourceType: "Bundle", type: "transaction", @@ -130,7 +132,6 @@ export async function getPSUParams(requestParams, vuContext) { const isValid = vuContext.scenario.tags.isValid const body = getBody(isValid) - requestParams.json = body // This sets the body of the request and some variables so headers are unique requestParams.json = body vuContext.vars.x_request_id = uuidv4() diff --git a/artillery/notify_entrypoint.mjs b/artillery/notify_entrypoint.mjs new file mode 100644 index 00000000..ffd671d6 --- /dev/null +++ b/artillery/notify_entrypoint.mjs @@ -0,0 +1,111 @@ +import {v4 as uuidv4} from "uuid" +import pino from "pino" +import {getSharedAuthToken, getBody} from "./helper/psu.mjs" +import {allowedOdsCodes, blockedOdsCodes} from "./helper/odscodes.mjs" + +export { getSharedAuthToken } + +const logger = pino() + +const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; +const DIGITS = "0123456789"; + +const randomChar = (chars) => chars[Math.floor(Math.random() * chars.length)]; + +/** Generate one two-letter, three-digit ODS code, e.g. "AB123" */ +const generateOdsCode = () => + `${randomChar(LETTERS)}${randomChar(LETTERS)}${randomChar(DIGITS)}${randomChar(DIGITS)}${randomChar(DIGITS)}`; + +function buildFullOdsCodes(targetCount, seedCodes) { + const codes = new Set(seedCodes); + + while (codes.size < targetCount) { + codes.add(generateOdsCode()); + } + + return Array.from(codes); +} + +// The complete list of ODS codes +const fullOdsCodes = allowedOdsCodes.concat(blockedOdsCodes) + +function computeCheckDigit(nhsNumber) { + const factors = [10,9,8,7,6,5,4,3,2] + let total = 0 + + for (let i = 0; i < 9; i++) { + total += parseInt(nhsNumber.charAt(i),10) * factors[i] + } + + const rem = total % 11 + let d = 11 - rem + if (d === 11) d = 0 + + return d +} + +function generateValidNhsNumber() { + while (true) { + const partial = Array.from({length:9},() => Math.floor(Math.random()*10)).join("") + const cd = computeCheckDigit(partial) + if (cd < 10) return partial + cd + } +} + +// Apparently Math.sampleNormal isn't a function? Do a quick Box-Muller transform instead +function sampleNormal(mean = 0, sd = 1) { + let u = 0, v = 0; + // avoid zeros because of log(0) + while (u === 0) u = Math.random(); + while (v === 0) v = Math.random(); + const z = Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v); + return z * sd + mean; +} + +export function initUser(context, events, done) { + // Generate data for a patient + context.vars.odsCode = fullOdsCodes[Math.floor(Math.random()*fullOdsCodes.length)] + context.vars.nhsNumber = generateValidNhsNumber() + + let prescriptionCount = Math.round(sampleNormal(3,1)) + if (prescriptionCount < 1) prescriptionCount = 1 // just truncate at 1. + context.vars.prescriptionCount = prescriptionCount + context.vars.loopcount = 0 + + logger.info(`Patient ${context.vars.nhsNumber}, ODS ${context.vars.odsCode} has ${context.vars.prescriptionCount} prescriptions`) + + done() +} + +export function generatePrescData(requestParams, context, ee, next) { + logger.debug(`Generating a prescription for patient ${context.vars.nhsNumber}`) + const body = getBody( + true, /* isValid */ + "completed", /* status */ + context.vars.odsCode, /* odsCode */ + context.vars.nhsNumber, /* nhsNumber */ + "ready to collect" /* Item status */ + ) + // The body is fine - it works when I put it in postman + + requestParams.json = body + context.vars.x_request_id = uuidv4() + context.vars.x_correlation_id = uuidv4() + + context.vars.loopcount += 1 + + // Wait this long between requests + let meanDelay = 10 // seconds + let stdDevDelay = 10 // seconds + let delay = 0 + if (context.vars.loopcount < context.vars.prescriptionCount) { + delay = sampleNormal(meanDelay, stdDevDelay) + while (delay < 0) delay = sampleNormal(meanDelay, stdDevDelay) + } + + context.vars.nextDelay = delay + logger.debug(`Patient ${context.vars.nhsNumber} (on prescription update ${context.vars.loopcount}/${context.vars.prescriptionCount}) will think for ${context.vars.nextDelay} seconds`) + + next() +} + diff --git a/artillery/notify_load_test.yml b/artillery/notify_load_test.yml new file mode 100644 index 00000000..4173eb1e --- /dev/null +++ b/artillery/notify_load_test.yml @@ -0,0 +1,44 @@ +config: + processor: "./notify_entrypoint.mjs" + plugins: + expect: {} + apdex: {} + phases: + - name: run phase + duration: "{{ $env.duration }}" + arrivalRate: "{{ $env.arrivalRate }}" + maxVusers: "{{ $env.maxVusers }}" + environments: + dev: + target: https://internal-dev.api.service.nhs.uk/ + ref: + target: https://ref.api.service.nhs.uk/ + int: + target: https://int.api.service.nhs.uk/ + pr: + target: https://psu-pr-{{ prNumber }}.dev.eps.national.nhs.uk/ + +before: + flow: + - function: getSharedAuthToken + +scenarios: + - name: dynamic PSU calls for each patient + + flow: + - function: initUser + + # for each prescription (number of prescriptions is variable) + - loop: + - function: getSharedAuthToken + - post: + url: "/prescription-status-update/" + beforeRequest: "generatePrescData" + headers: + Authorization: "Bearer {{ authToken }}" + x-request-id: "{{ x_request_id }}" + x-correlation-id: "{{ x_correlation_id }}" + expect: + - statusCode: 201 + - think: "{{ nextDelay }}seconds" + count: "{{ prescriptionCount }}" diff --git a/scripts/run_cpsu_load_test.sh b/scripts/run_cpsu_load_test.sh index 321c9cd3..448950e3 100755 --- a/scripts/run_cpsu_load_test.sh +++ b/scripts/run_cpsu_load_test.sh @@ -57,7 +57,7 @@ npx artillery run-fargate \ --security-group-ids "${security_group}" \ --subnet-ids "${vpc_subnets}" \ --task-role-name "${artillery_worker_role_name}" \ - --dotenv runtimeenv.env \ + --env-file runtimeenv.env \ --output cpsu_load_test.json \ artillery/cpsu_load_test.yml diff --git a/scripts/run_notify_load_test.sh b/scripts/run_notify_load_test.sh new file mode 100755 index 00000000..2657438d --- /dev/null +++ b/scripts/run_notify_load_test.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash + + +if [ -z "${artillery_key}" ]; then + echo "artillery_key is unset or set to the empty string" + exit 1 +fi + +if [ -z "${environment}" ]; then + echo "environment is unset or set to the empty string" + exit 1 +fi + +if [ -z "${maxVusers}" ]; then + echo "maxVusers is unset or set to the empty string" + exit 1 +fi + +if [ -z "${duration}" ]; then + echo "duration is unset or set to the empty string" + exit 1 +fi + +if [ -z "${arrivalRate}" ]; then + echo "arrivalRate is unset or set to the empty string" + exit 1 +fi + +if ! [[ "${environment}" =~ ^(dev|ref)$ ]] +then + echo "environment must be dev or ref" + exit 1 +fi + +security_group=$(aws cloudformation list-exports --output json | jq -r '.Exports[] | select(.Name == "artillery-resources:ArtillerySecurityGroupId") | .Value' | grep -o '[^:]*$') +export security_group +vpc_subnets=$(aws cloudformation list-exports --output json | jq -r '.Exports[] | select(.Name == "vpc-resources:PrivateSubnets") | .Value' | grep -o '[^:]*$') +export vpc_subnets + +artillery_worker_role_name=$(aws cloudformation list-exports --output json | jq -r '.Exports[] | select(.Name == "artillery-resources:ArtilleryWorkerRoleName") | .Value' | grep -o '[^:]*$') +export artillery_worker_role_name + +cat < runtimeenv.env +maxVusers=$maxVusers +duration=$duration +arrivalRate=$arrivalRate +EOF + +echo ${launch_config} + +# shellcheck disable=SC2090,SC2086 +npx artillery run-fargate \ + --environment "${environment}" \ + --secret psu_api_key psu_private_key psu_kid \ + --region eu-west-2 \ + --cluster artilleryio-cluster \ + --security-group-ids "${security_group}" \ + --subnet-ids "${vpc_subnets}" \ + --task-role-name "${artillery_worker_role_name}" \ + --env-file runtimeenv.env \ + --output notify_load_test.json \ + artillery/notify_load_test.yml + +npx artillery report notify_load_test.json diff --git a/scripts/run_psu_load_test.sh b/scripts/run_psu_load_test.sh index 4e1e563a..2bc409ad 100755 --- a/scripts/run_psu_load_test.sh +++ b/scripts/run_psu_load_test.sh @@ -59,7 +59,7 @@ npx artillery run-fargate \ --security-group-ids "${security_group}" \ --subnet-ids "${vpc_subnets}" \ --task-role-name "${artillery_worker_role_name}" \ - --dotenv runtimeenv.env \ + --env-file runtimeenv.env \ --output psu_load_test.json \ artillery/psu_load_test.yml diff --git a/scripts/test_cpsu_load_test.sh b/scripts/test_cpsu_load_test.sh index e85d0972..91fb7eb4 100755 --- a/scripts/test_cpsu_load_test.sh +++ b/scripts/test_cpsu_load_test.sh @@ -54,9 +54,9 @@ set -e # Run the Artillery test locally npx artillery run \ -e "${environment}" \ - --dotenv /workspaces/eps-load-test/runtimeenv.env \ + --env-file /workspaces/eps-load-test/runtimeenv.env \ --output /workspaces/eps-load-test/cpsu_load_test.json \ /workspaces/eps-load-test/artillery/cpsu_load_test.yml # Generate a report from the test results -npx /workspaces/eps-load-test/artillery report cpsu_load_test.json +npx artillery report cpsu_load_test.json diff --git a/scripts/test_notify_load_test.sh b/scripts/test_notify_load_test.sh new file mode 100755 index 00000000..8cae9e4c --- /dev/null +++ b/scripts/test_notify_load_test.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash + + +if [ -z "${artillery_key}" ]; then + echo "artillery_key is unset or set to the empty string" + exit 1 +fi + +if [ -z "${environment}" ]; then + echo "environment is unset or set to the empty string" + exit 1 +fi + +if [ -z "${maxVusers}" ]; then + echo "maxVusers is unset or set to the empty string" + exit 1 +fi + +if [ -z "${duration}" ]; then + echo "duration is unset or set to the empty string" + exit 1 +fi + +if [ -z "${arrivalRate}" ]; then + echo "arrivalRate is unset or set to the empty string" + exit 1 +fi + +if [ -z "${psu_api_key}" ]; then + echo "psu_api_key is unset or set to the empty string" + exit 1 +fi + +if [ -z "${psu_private_key}" ]; then + echo "psu_private_key is unset or set to the empty string" + exit 1 +fi + +if [ -z "${psu_kid}" ]; then + echo "psu_kid is unset or set to the empty string" + exit 1 +fi + + +# Create a dotenv file with the variables for Artillery +cat < runtimeenv.env +maxVusers=${maxVusers} +duration=${duration} +arrivalRate=${arrivalRate} +psu_api_key="${psu_api_key}" +psu_private_key="${psu_private_key}" +psu_kid="${psu_kid}" +artillery_key="${artillery_key}" +EOF + +echo "Running Artillery test locally..." +echo "" +echo "Environment: ${environment}" +echo "Max Virtual Users: ${maxVusers}" +echo "Run Phase Duration: ${duration}" +echo "Arrival Rate: ${arrivalRate}" +echo "" + +set -e + +# Run the Artillery test locally +npx artillery run \ + -e "${environment}" \ + --env-file /workspaces/eps-load-test/runtimeenv.env \ + --output /workspaces/eps-load-test/notify_load_test.json \ + --record --key ${artillery_key} \ + /workspaces/eps-load-test/artillery/notify_load_test.yml diff --git a/scripts/test_psu_load_test.sh b/scripts/test_psu_load_test.sh index 50a5f1c6..dd6902ca 100755 --- a/scripts/test_psu_load_test.sh +++ b/scripts/test_psu_load_test.sh @@ -66,9 +66,9 @@ set -e # Run the Artillery test locally npx artillery run \ -e "${environment}" \ - --dotenv /workspaces/eps-load-test/runtimeenv.env \ + --env-file /workspaces/eps-load-test/runtimeenv.env \ --output /workspaces/eps-load-test/psu_load_test.json \ /workspaces/eps-load-test/artillery/psu_load_test.yml # Generate a report from the test results -npx /workspaces/eps-load-test/artillery report psu_load_test.json +npx artillery report psu_load_test.json From af30d378d74be9f40a07e979f15de0b1c8fbf871 Mon Sep 17 00:00:00 2001 From: Jim Wild Date: Tue, 24 Jun 2025 13:44:56 +0000 Subject: [PATCH 02/10] Remove artillery key --- scripts/run_notify_load_test.sh | 5 ----- 1 file changed, 5 deletions(-) diff --git a/scripts/run_notify_load_test.sh b/scripts/run_notify_load_test.sh index 2657438d..31efbc85 100755 --- a/scripts/run_notify_load_test.sh +++ b/scripts/run_notify_load_test.sh @@ -1,11 +1,6 @@ #!/usr/bin/env bash -if [ -z "${artillery_key}" ]; then - echo "artillery_key is unset or set to the empty string" - exit 1 -fi - if [ -z "${environment}" ]; then echo "environment is unset or set to the empty string" exit 1 From 837b8fa619e6273f20acfddc840f53d84311d1f5 Mon Sep 17 00:00:00 2001 From: Jim Wild Date: Tue, 24 Jun 2025 13:46:24 +0000 Subject: [PATCH 03/10] Put a link in to where the artillery report is uploaded --- scripts/test_notify_load_test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test_notify_load_test.sh b/scripts/test_notify_load_test.sh index 8cae9e4c..83fdfed6 100755 --- a/scripts/test_notify_load_test.sh +++ b/scripts/test_notify_load_test.sh @@ -2,7 +2,7 @@ if [ -z "${artillery_key}" ]; then - echo "artillery_key is unset or set to the empty string" + echo "artillery_key is unset or set to the empty string. Get one from here: https://app.artillery.io/" exit 1 fi From 2b891cd1ecb892c5013908aa8e938700f4bd2faf Mon Sep 17 00:00:00 2001 From: Jim Wild Date: Tue, 24 Jun 2025 13:50:05 +0000 Subject: [PATCH 04/10] Lower the default values for load test --- .github/workflows/run_notify_load.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/run_notify_load.yml b/.github/workflows/run_notify_load.yml index 8ffb5440..1c7dbb2f 100644 --- a/.github/workflows/run_notify_load.yml +++ b/.github/workflows/run_notify_load.yml @@ -8,17 +8,17 @@ on: required: true default: 'ref' arrivalRate: - description: 'The number of new users to add every second' + description: 'How many requests to send per second' required: true - default: '275' + default: '100' duration: - description: 'The duration of the main test' + description: 'The duration of the test' required: true default: '900' maxVusers: - description: 'Maximum number of vusers to create' + description: 'Maximum number of virtual users to create' required: true - default: '1000' + default: '100' jobs: run_artillery: From e83dec8891a75762ff4bedf6b7b2449ee43db546 Mon Sep 17 00:00:00 2001 From: Jim Wild Date: Tue, 24 Jun 2025 14:35:51 +0000 Subject: [PATCH 05/10] add aws-configure to makefile. Also log secrets for debugging --- Makefile | 3 +++ artillery/helper/psu.mjs | 2 ++ 2 files changed, 5 insertions(+) diff --git a/Makefile b/Makefile index ac6a846a..15294c34 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,9 @@ compile-node: compile: compile-node +aws-configure: + aws configure sso --region eu-west-2 + aws-login: aws sso login --sso-session sso-session diff --git a/artillery/helper/psu.mjs b/artillery/helper/psu.mjs index 025fe7bf..00e61b8a 100644 --- a/artillery/helper/psu.mjs +++ b/artillery/helper/psu.mjs @@ -114,6 +114,8 @@ export async function getSharedAuthToken(vuContext) { const api_key = process.env.psu_api_key const kid = process.env.psu_kid + logger.info("Secrets:", {privateKey, api_key, kid}) + // And use them to fetch the access token const response = await getAccessToken(logger, vuContext.vars.target, privateKey, api_key, kid) From 809e0060f2a5872c9c1e80bea3a64f7290f81326 Mon Sep 17 00:00:00 2001 From: Jim Wild Date: Wed, 25 Jun 2025 15:14:07 +0000 Subject: [PATCH 06/10] Update scripts to take artillery api keys as an option. --- .github/workflows/run_notify_load.yml | 9 ++++++-- artillery/notify_load_test.yml | 5 +++++ scripts/run_notify_load_test.sh | 6 +++++ scripts/test_notify_load_test.sh | 32 ++++++++++++++++++--------- scripts/test_psu_load_test.sh | 18 +++++++++++---- 5 files changed, 53 insertions(+), 17 deletions(-) diff --git a/.github/workflows/run_notify_load.yml b/.github/workflows/run_notify_load.yml index 1c7dbb2f..bb2091c1 100644 --- a/.github/workflows/run_notify_load.yml +++ b/.github/workflows/run_notify_load.yml @@ -8,11 +8,15 @@ on: required: true default: 'ref' arrivalRate: - description: 'How many requests to send per second' + description: 'How many requests to send per second during the main test' required: true default: '100' duration: - description: 'The duration of the test' + description: 'The duration of the main test' + required: true + default: '900' + rampUpDuration: + description: 'The duration of ramp-up phase of the test' required: true default: '900' maxVusers: @@ -34,6 +38,7 @@ jobs: echo "## environment : ${{ github.event.inputs.environment }}" >> "$GITHUB_STEP_SUMMARY" echo "## arrivalRate : ${{ github.event.inputs.arrivalRate }}" >> "$GITHUB_STEP_SUMMARY" echo "## duration : ${{ github.event.inputs.duration }}" >> "$GITHUB_STEP_SUMMARY" + echo "## rampUpDuration : ${{ github.event.inputs.rampUpDuration }}" >> "$GITHUB_STEP_SUMMARY" echo "## maxVusers : ${{ github.event.inputs.maxVusers }}" >> "$GITHUB_STEP_SUMMARY" - name: Checkout repo diff --git a/artillery/notify_load_test.yml b/artillery/notify_load_test.yml index 4173eb1e..5d8be5bf 100644 --- a/artillery/notify_load_test.yml +++ b/artillery/notify_load_test.yml @@ -4,6 +4,11 @@ config: expect: {} apdex: {} phases: + - name: ramp up phase + duration: "{{ $env.rampUpDuration }}" + arrivalRate: 1 + rampTo: "{{ $env.arrivalRate }}" + maxVusers: "{{ $env.maxVusers }}" - name: run phase duration: "{{ $env.duration }}" arrivalRate: "{{ $env.arrivalRate }}" diff --git a/scripts/run_notify_load_test.sh b/scripts/run_notify_load_test.sh index 31efbc85..731b8f0b 100755 --- a/scripts/run_notify_load_test.sh +++ b/scripts/run_notify_load_test.sh @@ -16,6 +16,11 @@ if [ -z "${duration}" ]; then exit 1 fi +if [ -z "${rampUpDuration}" ]; then + echo "rampUpDuration is unset or set to the empty string" + exit 1 +fi + if [ -z "${arrivalRate}" ]; then echo "arrivalRate is unset or set to the empty string" exit 1 @@ -39,6 +44,7 @@ cat < runtimeenv.env maxVusers=$maxVusers duration=$duration arrivalRate=$arrivalRate +rampUpDuration=$rampUpDuration EOF echo ${launch_config} diff --git a/scripts/test_notify_load_test.sh b/scripts/test_notify_load_test.sh index 83fdfed6..21874c96 100755 --- a/scripts/test_notify_load_test.sh +++ b/scripts/test_notify_load_test.sh @@ -1,11 +1,6 @@ #!/usr/bin/env bash - -if [ -z "${artillery_key}" ]; then - echo "artillery_key is unset or set to the empty string. Get one from here: https://app.artillery.io/" - exit 1 -fi - +# Required checks if [ -z "${environment}" ]; then echo "environment is unset or set to the empty string" exit 1 @@ -21,6 +16,11 @@ if [ -z "${duration}" ]; then exit 1 fi +if [ -z "${rampUpDuration}" ]; then + echo "rampUpDuration is unset or set to the empty string" + exit 1 +fi + if [ -z "${arrivalRate}" ]; then echo "arrivalRate is unset or set to the empty string" exit 1 @@ -41,12 +41,20 @@ if [ -z "${psu_kid}" ]; then exit 1 fi +# Check artillery_key but don't fail — just omit recording if absent +if [ -z "${artillery_key}" ]; then + echo "Warning: artillery_key is unset; running test without recording to Artillery Cloud." + RECORD_ARGS="" +else + RECORD_ARGS="--record --key ${artillery_key}" +fi # Create a dotenv file with the variables for Artillery cat < runtimeenv.env maxVusers=${maxVusers} duration=${duration} arrivalRate=${arrivalRate} +rampUpDuration=${rampUpDuration} psu_api_key="${psu_api_key}" psu_private_key="${psu_private_key}" psu_kid="${psu_kid}" @@ -58,15 +66,17 @@ echo "" echo "Environment: ${environment}" echo "Max Virtual Users: ${maxVusers}" echo "Run Phase Duration: ${duration}" +echo "Ramp-up Duration: ${rampUpDuration}" echo "Arrival Rate: ${arrivalRate}" +[ -n "${artillery_key}" ] && echo "Recording to Artillery Cloud with key: ${artillery_key}" echo "" set -e -# Run the Artillery test locally +# Run the Artillery test locally, conditionally including recording flags npx artillery run \ -e "${environment}" \ - --env-file /workspaces/eps-load-test/runtimeenv.env \ - --output /workspaces/eps-load-test/notify_load_test.json \ - --record --key ${artillery_key} \ - /workspaces/eps-load-test/artillery/notify_load_test.yml + --env-file "$(pwd)/runtimeenv.env" \ + --output "$(pwd)/notify_load_test.json" \ + $RECORD_ARGS \ + "$(pwd)/artillery/notify_load_test.yml" diff --git a/scripts/test_psu_load_test.sh b/scripts/test_psu_load_test.sh index dd6902ca..b206c1f6 100755 --- a/scripts/test_psu_load_test.sh +++ b/scripts/test_psu_load_test.sh @@ -41,6 +41,13 @@ if [ -z "${psu_kid}" ]; then exit 1 fi +# Check for Artillery Cloud key (optional) +if [ -z "${artillery_key}" ]; then + echo "Warning: artillery_key is unset; running test without recording to Artillery Cloud." + RECORD_ARGS="" +else + RECORD_ARGS="--record --key ${artillery_key}" +fi # Create a dotenv file with the variables for Artillery cat < runtimeenv.env @@ -55,10 +62,12 @@ EOF echo "Running Artillery test locally..." echo "" +echo "Environment: ${environment}" echo "Max Virtual Users: ${maxVusers}" echo "Phase Duration: ${duration}" echo "Arrival Rate: ${arrivalRate}" -echo "Ramp Up Duration: ${rampUpDuration}" +echo "Ramp-up Duration: ${rampUpDuration}" +[ -n "${artillery_key}" ] && echo "Recording to Artillery Cloud with key: ${artillery_key}" echo "" set -e @@ -66,9 +75,10 @@ set -e # Run the Artillery test locally npx artillery run \ -e "${environment}" \ - --env-file /workspaces/eps-load-test/runtimeenv.env \ - --output /workspaces/eps-load-test/psu_load_test.json \ - /workspaces/eps-load-test/artillery/psu_load_test.yml + --env-file "$(pwd)/runtimeenv.env" \ + --output "$(pwd)/psu_load_test.json" \ + $RECORD_ARGS \ + "$(pwd)/artillery/psu_load_test.yml" # Generate a report from the test results npx artillery report psu_load_test.json From 392af23a5846be498cc5bb34e84f14ee6be2e0e9 Mon Sep 17 00:00:00 2001 From: Jim Wild Date: Wed, 25 Jun 2025 15:18:20 +0000 Subject: [PATCH 07/10] Add artillery key to workflow args --- .github/workflows/run_notify_load.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/run_notify_load.yml b/.github/workflows/run_notify_load.yml index bb2091c1..4859a2d0 100644 --- a/.github/workflows/run_notify_load.yml +++ b/.github/workflows/run_notify_load.yml @@ -23,6 +23,10 @@ on: description: 'Maximum number of virtual users to create' required: true default: '100' + artillery_key: + description: 'Your Artillery cloud API key (optional – omit to run without recording)' + required: false + default: '' jobs: run_artillery: @@ -40,6 +44,11 @@ jobs: echo "## duration : ${{ github.event.inputs.duration }}" >> "$GITHUB_STEP_SUMMARY" echo "## rampUpDuration : ${{ github.event.inputs.rampUpDuration }}" >> "$GITHUB_STEP_SUMMARY" echo "## maxVusers : ${{ github.event.inputs.maxVusers }}" >> "$GITHUB_STEP_SUMMARY" + if [ -n "${{ github.event.inputs.artillery_key }}" ]; then + echo "## artillery_key : (provided)" >> "$GITHUB_STEP_SUMMARY" + else + echo "## artillery_key : (not provided)" >> "$GITHUB_STEP_SUMMARY" + fi - name: Checkout repo uses: actions/checkout@v4 @@ -65,7 +74,7 @@ jobs: with: asdf_branch: v0.14.0 env: - PYTHON_CONFIGURE_OPTS: --enable-shared + PYTHON_CONFIGURE_OPTS: --enable-shared - name: Install Dependencies run: make install @@ -94,6 +103,7 @@ jobs: duration: ${{ github.event.inputs.duration }} rampUpDuration: ${{ github.event.inputs.rampUpDuration }} maxVusers: ${{ github.event.inputs.maxVusers }} + artillery_key: ${{ github.event.inputs.artillery_key }} run: | ./scripts/run_notify_load_test.sh From 8750451d0cec83e5e868bbc92e2dbc6f6382525b Mon Sep 17 00:00:00 2001 From: Jim Wild Date: Wed, 25 Jun 2025 15:24:51 +0000 Subject: [PATCH 08/10] Update notify workflow to optionally take artillery key --- scripts/run_notify_load_test.sh | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/scripts/run_notify_load_test.sh b/scripts/run_notify_load_test.sh index 731b8f0b..a6eb7ea3 100755 --- a/scripts/run_notify_load_test.sh +++ b/scripts/run_notify_load_test.sh @@ -26,8 +26,7 @@ if [ -z "${arrivalRate}" ]; then exit 1 fi -if ! [[ "${environment}" =~ ^(dev|ref)$ ]] -then +if ! [[ "${environment}" =~ ^(dev|ref)$ ]]; then echo "environment must be dev or ref" exit 1 fi @@ -40,11 +39,18 @@ export vpc_subnets artillery_worker_role_name=$(aws cloudformation list-exports --output json | jq -r '.Exports[] | select(.Name == "artillery-resources:ArtilleryWorkerRoleName") | .Value' | grep -o '[^:]*$') export artillery_worker_role_name +if [ -z "${artillery_key}" ]; then + echo "artillery_key is unset. Running without --record to Artillery Cloud." + RECORD_ARGS="" +else + RECORD_ARGS="--record --key ${artillery_key}" +fi + cat < runtimeenv.env -maxVusers=$maxVusers -duration=$duration -arrivalRate=$arrivalRate -rampUpDuration=$rampUpDuration +maxVusers=${maxVusers} +duration=${duration} +arrivalRate=${arrivalRate} +rampUpDuration=${rampUpDuration} EOF echo ${launch_config} @@ -60,6 +66,7 @@ npx artillery run-fargate \ --task-role-name "${artillery_worker_role_name}" \ --env-file runtimeenv.env \ --output notify_load_test.json \ + $RECORD_ARGS \ artillery/notify_load_test.yml -npx artillery report notify_load_test.json +npx artillery report notify_load_test.json From 8aa8cce66df89a994b8ae3d9d55c028a85103a8b Mon Sep 17 00:00:00 2001 From: Jim Wild Date: Fri, 27 Jun 2025 15:34:24 +0000 Subject: [PATCH 09/10] Have two scenarios, one for allowed ODS codes, one for blocked ones --- artillery/helper/odscodes.mjs | 4 +- artillery/notify_entrypoint.mjs | 75 +++++++++++++++------------------ artillery/notify_load_test.yml | 24 +++++++++-- 3 files changed, 57 insertions(+), 46 deletions(-) diff --git a/artillery/helper/odscodes.mjs b/artillery/helper/odscodes.mjs index 42869b24..83313cc0 100644 --- a/artillery/helper/odscodes.mjs +++ b/artillery/helper/odscodes.mjs @@ -4,5 +4,5 @@ export const allowedOdsCodes = [ ] export const blockedOdsCodes = [ - // "B3J1Z" -] \ No newline at end of file + "B3J1Z" +] diff --git a/artillery/notify_entrypoint.mjs b/artillery/notify_entrypoint.mjs index ffd671d6..571d093c 100644 --- a/artillery/notify_entrypoint.mjs +++ b/artillery/notify_entrypoint.mjs @@ -7,28 +7,6 @@ export { getSharedAuthToken } const logger = pino() -const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; -const DIGITS = "0123456789"; - -const randomChar = (chars) => chars[Math.floor(Math.random() * chars.length)]; - -/** Generate one two-letter, three-digit ODS code, e.g. "AB123" */ -const generateOdsCode = () => - `${randomChar(LETTERS)}${randomChar(LETTERS)}${randomChar(DIGITS)}${randomChar(DIGITS)}${randomChar(DIGITS)}`; - -function buildFullOdsCodes(targetCount, seedCodes) { - const codes = new Set(seedCodes); - - while (codes.size < targetCount) { - codes.add(generateOdsCode()); - } - - return Array.from(codes); -} - -// The complete list of ODS codes -const fullOdsCodes = allowedOdsCodes.concat(blockedOdsCodes) - function computeCheckDigit(nhsNumber) { const factors = [10,9,8,7,6,5,4,3,2] let total = 0 @@ -62,23 +40,42 @@ function sampleNormal(mean = 0, sd = 1) { return z * sd + mean; } -export function initUser(context, events, done) { - // Generate data for a patient - context.vars.odsCode = fullOdsCodes[Math.floor(Math.random()*fullOdsCodes.length)] - context.vars.nhsNumber = generateValidNhsNumber() +function initUserContextVars(context) { + context.vars.nhsNumber = generateValidNhsNumber() - let prescriptionCount = Math.round(sampleNormal(3,1)) - if (prescriptionCount < 1) prescriptionCount = 1 // just truncate at 1. - context.vars.prescriptionCount = prescriptionCount - context.vars.loopcount = 0 - - logger.info(`Patient ${context.vars.nhsNumber}, ODS ${context.vars.odsCode} has ${context.vars.prescriptionCount} prescriptions`) - - done() + let prescriptionCount = Math.round(sampleNormal(3,1)) + if (prescriptionCount < 1) prescriptionCount = 1 // just truncate at 1. + context.vars.prescriptionCount = prescriptionCount + context.vars.loopcount = 0 +} + +export function initUserAllowed(context, events, done) { + logger.info("Initializing user context variables for allowed ODS codes") + initUserContextVars(context) + + // Generate data for a patient with an allowed ODS code + context.vars.odsCode = allowedOdsCodes[Math.floor(Math.random() * allowedOdsCodes.length)] + + logger.info(`[ALLOWED] Patient ${context.vars.nhsNumber}, ODS ${context.vars.odsCode} has ${context.vars.prescriptionCount} prescriptions`) + done() +} + +export function initUserBlocked(context, events, done) { + logger.info("Initializing user context variables for allowed ODS codes") + initUserContextVars(context) + + // Generate data for a patient with a blocked ODS code + context.vars.odsCode = blockedOdsCodes[Math.floor(Math.random() * blockedOdsCodes.length)] + + logger.info(`[BLOCKED] Patient ${context.vars.nhsNumber}, ODS ${context.vars.odsCode} has ${context.vars.prescriptionCount} prescriptions`) + done() } export function generatePrescData(requestParams, context, ee, next) { - logger.debug(`Generating a prescription for patient ${context.vars.nhsNumber}`) + const isAllowed = allowedOdsCodes.includes(context.vars.odsCode); + const logPrefix = isAllowed ? "[ALLOWED]" : "[BLOCKED]"; + + logger.debug(`${logPrefix} Generating a prescription for patient ${context.vars.nhsNumber}`) const body = getBody( true, /* isValid */ "completed", /* status */ @@ -87,11 +84,11 @@ export function generatePrescData(requestParams, context, ee, next) { "ready to collect" /* Item status */ ) // The body is fine - it works when I put it in postman - + requestParams.json = body context.vars.x_request_id = uuidv4() context.vars.x_correlation_id = uuidv4() - + context.vars.loopcount += 1 // Wait this long between requests @@ -104,8 +101,6 @@ export function generatePrescData(requestParams, context, ee, next) { } context.vars.nextDelay = delay - logger.debug(`Patient ${context.vars.nhsNumber} (on prescription update ${context.vars.loopcount}/${context.vars.prescriptionCount}) will think for ${context.vars.nextDelay} seconds`) - + logger.debug(`${logPrefix} Patient ${context.vars.nhsNumber} (on prescription update ${context.vars.loopcount}/${context.vars.prescriptionCount}) will think for ${context.vars.nextDelay} seconds`) next() } - diff --git a/artillery/notify_load_test.yml b/artillery/notify_load_test.yml index 5d8be5bf..d1a542e1 100644 --- a/artillery/notify_load_test.yml +++ b/artillery/notify_load_test.yml @@ -28,12 +28,28 @@ before: - function: getSharedAuthToken scenarios: - - name: dynamic PSU calls for each patient - + - name: Allowed ODS code scenario + weight: 1 flow: - - function: initUser + - function: initUserAllowed + - loop: + - function: getSharedAuthToken + - post: + url: "/prescription-status-update/" + beforeRequest: "generatePrescData" + headers: + Authorization: "Bearer {{ authToken }}" + x-request-id: "{{ x_request_id }}" + x-correlation-id: "{{ x_correlation_id }}" + expect: + - statusCode: 201 + - think: "{{ nextDelay }}seconds" + count: "{{ prescriptionCount }}" - # for each prescription (number of prescriptions is variable) + - name: Blocked ODS code scenario + weight: 3 + flow: + - function: initUserBlocked - loop: - function: getSharedAuthToken - post: From a5c4a165f3b1c36c996fd96ce6db5626f80b15b6 Mon Sep 17 00:00:00 2001 From: Jim Wild Date: Mon, 30 Jun 2025 10:27:10 +0000 Subject: [PATCH 10/10] Disable blocked requests (they don't usually need to be tested --- artillery/notify_load_test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/artillery/notify_load_test.yml b/artillery/notify_load_test.yml index d1a542e1..b6eb1fbb 100644 --- a/artillery/notify_load_test.yml +++ b/artillery/notify_load_test.yml @@ -47,7 +47,7 @@ scenarios: count: "{{ prescriptionCount }}" - name: Blocked ODS code scenario - weight: 3 + weight: 0 flow: - function: initUserBlocked - loop: