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)

Reply via email to