This is an automated email from the ASF dual-hosted git repository. lahirujayathilake pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/airavata-custos.git
commit b764f7926768c81eda33acd61eadd8ea65551117 Author: lahiruj <[email protected]> AuthorDate: Wed Apr 1 09:15:04 2026 -0400 AMIE traffic simulation with mock server to have both success and failure scenarios --- allocations/access-ci-service/loadtest/README.md | 43 +++ .../access-ci-service/loadtest/amie-traffic.js | 118 ++++++++ .../access-ci-service/loadtest/mock-amie-server.py | 336 +++++++++++++++++++++ 3 files changed, 497 insertions(+) diff --git a/allocations/access-ci-service/loadtest/README.md b/allocations/access-ci-service/loadtest/README.md new file mode 100644 index 000000000..ae87d4aaf --- /dev/null +++ b/allocations/access-ci-service/loadtest/README.md @@ -0,0 +1,43 @@ +# AMIE Traffic Simulation + +k6 load test that generates AMIE test scenarios at varying rates to simulate real-world traffic patterns. + +## Prerequisites + +```bash +# macOS +brew install k6 +``` + +## Usage + +```bash +# Basic run (resets test server automatically) +AMIE_API_KEY=<your-key> k6 run amie-traffic.js + +# With Prometheus metrics export +AMIE_API_KEY=<key> k6 run --out experimental-prometheus-rw=http://localhost:9090/api/v1/write amie-traffic.js + +# Override AMIE endpoint +AMIE_BASE_URL=https://custom-endpoint AMIE_SITE=MySite AMIE_API_KEY=<key> k6 run amie-traffic.js +``` + +## Traffic Profile (~20 minutes) + +``` +VUs + 8 | * + 5 | *************** * * + 2 | ** ***** ************ ** + 1 | ****** *** **** + |──────────────────────────────────────────────────────── time + warm ramp peak cool steady quiet spike recovery +``` + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `AMIE_API_KEY` | (required) | AMIE API authentication key | +| `AMIE_BASE_URL` | `https://a3mdev.xsede.org/amie-api-test` | AMIE test API base URL | +| `AMIE_SITE` | `GaTech` | Site code for AMIE | diff --git a/allocations/access-ci-service/loadtest/amie-traffic.js b/allocations/access-ci-service/loadtest/amie-traffic.js new file mode 100644 index 000000000..68226d283 --- /dev/null +++ b/allocations/access-ci-service/loadtest/amie-traffic.js @@ -0,0 +1,118 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import http from 'k6/http'; +import {check, sleep} from 'k6'; +import {Counter} from 'k6/metrics'; +import {randomIntBetween} from 'https://jslib.k6.io/k6-utils/1.4.0/index.js'; + +const BASE_URL = __ENV.AMIE_BASE_URL || 'https://a3mdev.xsede.org/amie-api-test'; +const SITE = __ENV.AMIE_SITE || 'GaTech'; +const API_KEY = __ENV.AMIE_API_KEY; + +if (!API_KEY) { + throw new Error('AMIE_API_KEY environment variable is required'); +} + +const scenariosCreated = new Counter('amie_scenarios_created'); + +const HEADERS = { + 'XA-SITE': SITE, + 'XA-API-KEY': API_KEY, +}; + +// Mock server uses mixed type to get both success and failure packets. +// Standard test scenarios with the real AMIE test server. +const USE_MOCK = (BASE_URL.includes('localhost') || BASE_URL.includes('127.0.0.1')); + +// Weighted scenario types for real AMIE (cumulative thresholds) +const REAL_SCENARIO_TYPES = [ + {type: 'request_project_reactivate', weight: 0.30}, + {type: 'request_account_reactivate', weight: 0.60}, + {type: 'request_person_merge', weight: 0.80}, + {type: 'request_user_modify', weight: 1.00}, +]; + +const MOCK_SCENARIO_TYPES = [ + {type: 'mixed', weight: 0.50}, + {type: 'success_only', weight: 0.70}, + {type: 'failures_only', weight: 0.85}, + {type: 'heavy', weight: 1.00}, +]; + +const SCENARIO_TYPES = USE_MOCK ? MOCK_SCENARIO_TYPES : REAL_SCENARIO_TYPES; + +function pickScenarioType() { + const r = Math.random(); + for (const s of SCENARIO_TYPES) { + if (r <= s.weight) return s.type; + } + return SCENARIO_TYPES[SCENARIO_TYPES.length - 1].type; +} + +// Traffic stages: warm-up → ramp → peak → cool down → steady → quiet → spike → recovery +export const options = { + stages: [ + {duration: '1m', target: 1}, // warm-up + {duration: '2m', target: 5}, // ramp up + {duration: '5m', target: 5}, // peak + {duration: '2m', target: 2}, // cool down + {duration: '5m', target: 2}, // steady + {duration: '3m', target: 1}, // quiet + {duration: '30s', target: 8}, // spike + {duration: '2m', target: 1}, // recovery + ], + thresholds: { + http_req_failed: ['rate<0.05'], + http_req_duration: ['p(95)<5000'], + }, +}; + +export function setup() { + // Reset AMIE test server before the run + const res = http.post(`${BASE_URL}/test/${SITE}/reset`, null, {headers: HEADERS}); + check(res, {'reset succeeded': (r) => r.status === 200}); + console.log(`AMIE test server reset for site ${SITE}`); + sleep(2); +} + +export default function () { + const scenarioType = pickScenarioType(); + const url = `${BASE_URL}/test/${SITE}/scenarios?type=${scenarioType}`; + + const res = http.post(url, null, { + headers: HEADERS, + tags: {scenario_type: scenarioType}, + }); + + check(res, { + 'scenario created (200)': (r) => r.status === 200, + }); + + if (res.status === 200) { + scenariosCreated.add(1, {type: scenarioType}); + } + + // Randomized pause between scenario creation (5-15s) + sleep(randomIntBetween(5, 15)); +} + +export function teardown() { + console.log('Load test complete. Check Grafana for processing metrics.'); +} diff --git a/allocations/access-ci-service/loadtest/mock-amie-server.py b/allocations/access-ci-service/loadtest/mock-amie-server.py new file mode 100644 index 000000000..0bd32f7c7 --- /dev/null +++ b/allocations/access-ci-service/loadtest/mock-amie-server.py @@ -0,0 +1,336 @@ +#!/usr/bin/env python3 +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +# Mock AMIE server for testing the ACCESS CI service with both +# success and failure scenarios. Simulates the AMIE API endpoints +# that the service polls. +# +# Usage: +# python3 mock-amie-server.py +# +# Then point the service at: access.amie.base-url=http://localhost:8180 + +import json +import random +import time +import uuid +from flask import Flask, jsonify, request +from pathlib import Path + +app = Flask(__name__) + +pending_packets = [] +replied_packets = {} +packet_counter = 900000 # starting ID +stats = {"created": 0, "fetched": 0, "replied": 0} + +SCENARIOS_DIR = Path(__file__).parent / "scenarios" + + +def next_id(): + global packet_counter + packet_counter += 1 + return packet_counter + + +def make_packet(packet_type, body, transaction_id=None): + rec_id = next_id() + return { + "header": { + "packet_rec_id": rec_id, + "transaction_id": transaction_id or f"TXN-{uuid.uuid4().hex[:8]}", + "packet_type": packet_type, + "type_id": 1, + "local_site_name": "MockSite", + "remote_site_name": "TGCDB", + "packet_state": "in_progress", + "packet_timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + }, + "type": packet_type, + "body": body, + } + + +# Scenario generators + +def gen_valid_project_create(): + gid = str(random.randint(100000, 999999)) + grant = f"TST{random.randint(100000, 999999)}" + return make_packet("request_project_create", { + "GrantNumber": grant, + "PfosNumber": f"PFOS-{grant}", + "ProjectTitle": f"Mock project {grant}", + "PiGlobalID": gid, + "PiFirstName": random.choice(["Alice", "Bob", "Carol", "Dave", "Eve"]), + "PiLastName": random.choice(["Smith", "Johnson", "Williams", "Brown", "Jones"]), + "PiEmail": f"user{gid}@example.edu", + "PiOrganization": "Mock University", + "PiOrgCode": "MOCK", + "NsfStatusCode": "AC", + "PiDnList": [f"/C=US/O=Mock University/CN=User {gid}"], + "ServiceUnitsAllocated": str(random.randint(1000, 50000)), + "StartDate": "2026-01-01", + "EndDate": "2026-12-31", + "ResourceList": "mock-cluster.example.edu", + }) + + +def gen_valid_account_create(): + gid = str(random.randint(100000, 999999)) + return make_packet("request_account_create", { + "ProjectID": f"PRJ-MOCK{random.randint(1000, 9999)}", + "GrantNumber": f"TST{random.randint(100000, 999999)}", + "UserGlobalID": gid, + "UserFirstName": random.choice(["Frank", "Grace", "Heidi", "Ivan", "Judy"]), + "UserLastName": random.choice(["Davis", "Garcia", "Rodriguez", "Wilson", "Martinez"]), + "UserEmail": f"user{gid}@example.edu", + "UserOrganization": "Mock Institute", + "UserOrgCode": "MI", + "NsfStatusCode": "GR", + "UserDnList": [f"/C=US/O=Mock Institute/CN=User {gid}"], + "ResourceList": "mock-cluster.example.edu", + }) + + +def gen_valid_user_modify(): + gid = str(random.randint(100000, 999999)) + return make_packet("request_user_modify", { + "PersonID": f"person-mock-{uuid.uuid4().hex[:8]}", + "ActionType": "replace", + "UserFirstName": "Updated", + "UserLastName": "Name", + "UserEmail": f"updated{gid}@example.edu", + "UserOrganization": "Updated University", + }) + + +def gen_inform_transaction_complete(): + return make_packet("inform_transaction_complete", { + "StatusCode": "Success", + "StatusMessage": "OK", + "DetailCode": "1", + }) + + +# Failure scenarios + +def gen_missing_global_id(): + """request_account_create with no UserGlobalID — will cause IllegalArgumentException.""" + return make_packet("request_account_create", { + "ProjectID": f"PRJ-FAIL{random.randint(1000, 9999)}", + "GrantNumber": f"FAIL{random.randint(100000, 999999)}", + # UserGlobalID MISSING — required field + "UserFirstName": "NoGlobalID", + "UserLastName": "User", + "UserEmail": "[email protected]", + "ResourceList": "mock-cluster.example.edu", + }) + + +def gen_missing_grant_number(): + """request_project_create with no GrantNumber — will cause IllegalArgumentException.""" + return make_packet("request_project_create", { + # GrantNumber MISSING — required field + "PiGlobalID": str(random.randint(100000, 999999)), + "PiFirstName": "NoGrant", + "PiLastName": "User", + "PiEmail": "[email protected]", + "NsfStatusCode": "AC", + "ResourceList": "mock-cluster.example.edu", + }) + + +def gen_missing_email(): + """request_project_create with no PiEmail — known optional field.""" + gid = str(random.randint(100000, 999999)) + return make_packet("request_project_create", { + "GrantNumber": f"NOEMAIL{random.randint(100000, 999999)}", + "PiGlobalID": gid, + "PiFirstName": "NoEmail", + "PiLastName": "Person", + # PiEmail MISSING — optional per protocol, but we need it + "PiOrganization": "No Email University", + "NsfStatusCode": "AC", + "PiDnList": [], + "ResourceList": "mock-cluster.example.edu", + }) + + +def gen_missing_pi_name(): + """request_project_create with no PiFirstName — will cause IllegalArgumentException.""" + return make_packet("request_project_create", { + "GrantNumber": f"NONAME{random.randint(100000, 999999)}", + "PiGlobalID": str(random.randint(100000, 999999)), + # PiFirstName MISSING — asserted as required + "PiLastName": "OnlyLast", + "PiEmail": "[email protected]", + "NsfStatusCode": "AC", + "ResourceList": "mock-cluster.example.edu", + }) + + +def gen_invalid_person_modify(): + """request_user_modify for a person that doesn't exist in the DB.""" + return make_packet("request_user_modify", { + "PersonID": "nonexistent-person-id-12345", + "ActionType": "replace", + "UserFirstName": "Ghost", + "UserLastName": "User", + "UserEmail": "[email protected]", + }) + + +def gen_unknown_packet_type(): + """Packet with an unrecognized type — should hit NoOpHandler.""" + return make_packet("request_something_unknown", { + "SomeField": "SomeValue", + }) + + +def gen_empty_body(): + """Packet with an empty body — should cause NPE or validation failure.""" + return make_packet("request_project_create", {}) + + +# Scenario mix + +SUCCESS_GENERATORS = [ + (gen_valid_project_create, 3), + (gen_valid_account_create, 3), + (gen_valid_user_modify, 1), + (gen_inform_transaction_complete, 2), +] + +FAILURE_GENERATORS = [ + (gen_missing_global_id, 2), + (gen_missing_grant_number, 1), + (gen_missing_email, 2), + (gen_missing_pi_name, 1), + (gen_invalid_person_modify, 2), + (gen_unknown_packet_type, 1), + (gen_empty_body, 1), +] + + +def generate_batch(success_count=6, failure_count=4): + """Generate a mixed batch of success and failure packets.""" + packets = [] + + success_pool = [] + for gen, weight in SUCCESS_GENERATORS: + success_pool.extend([gen] * weight) + + failure_pool = [] + for gen, weight in FAILURE_GENERATORS: + failure_pool.extend([gen] * weight) + + for _ in range(success_count): + gen = random.choice(success_pool) + packets.append(gen()) + + for _ in range(failure_count): + gen = random.choice(failure_pool) + packets.append(gen()) + + random.shuffle(packets) + return packets + + +# API endpoints + [email protected]("/packets/<site>", methods=["GET"]) +def get_packets(site): + """Return pending packets (mimics AMIE API poll). + Returns a JSON array at the top level, matching the format + that AmieClient.parsePacketsFromResponse() expects.""" + if not pending_packets: + return jsonify([]) + + batch = list(pending_packets) + pending_packets.clear() + stats["fetched"] += len(batch) + + app.logger.info(f"Serving {len(batch)} packets to site {site}") + return jsonify(batch) + + [email protected]("/packets/<site>/<int:packet_rec_id>/reply", methods=["POST"]) +def reply_to_packet(site, packet_rec_id): + """Accept a reply from the service.""" + replied_packets[packet_rec_id] = request.get_json(silent=True) + stats["replied"] += 1 + return jsonify({"message": "Reply accepted"}), 200 + + [email protected]("/test/<site>/reset", methods=["POST"]) +def reset(site): + """Reset all state.""" + pending_packets.clear() + replied_packets.clear() + stats["created"] = 0 + stats["fetched"] = 0 + stats["replied"] = 0 + app.logger.info(f"Reset state for site {site}") + return jsonify({"message": f"Reset site data for {site}", "result": None}) + + [email protected]("/test/<site>/scenarios", methods=["POST"]) +def create_scenario(site): + """Generate a batch of mixed success/failure scenarios.""" + scenario_type = request.args.get("type", "mixed") + + if scenario_type == "mixed": + packets = generate_batch(success_count=6, failure_count=4) + elif scenario_type == "failures_only": + packets = generate_batch(success_count=0, failure_count=8) + elif scenario_type == "success_only": + packets = generate_batch(success_count=8, failure_count=0) + elif scenario_type == "heavy": + packets = generate_batch(success_count=15, failure_count=10) + else: + packets = generate_batch(success_count=3, failure_count=2) + + pending_packets.extend(packets) + stats["created"] += len(packets) + + app.logger.info(f"Created {len(packets)} packets ({scenario_type}) for site {site}") + return jsonify({"message": "Test scenario initiated", "result": None}) + + [email protected]("/stats", methods=["GET"]) +def get_stats(): + """Stats endpoint for debugging.""" + return jsonify({ + "pending": len(pending_packets), + "replied": len(replied_packets), + "stats": stats, + }) + + +if __name__ == "__main__": + print("Mock AMIE Server starting on http://localhost:8180") + print("Point your service at: access.amie.base-url=http://localhost:8180") + print("") + print("Scenario types:") + print(" POST /test/{site}/scenarios?type=mixed — 6 success + 4 failure") + print(" POST /test/{site}/scenarios?type=failures_only — 8 failures") + print(" POST /test/{site}/scenarios?type=success_only — 8 successes") + print(" POST /test/{site}/scenarios?type=heavy — 15 success + 10 failure") + print("") + app.run(host="0.0.0.0", port=8180, debug=False)
