Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions workloads/fsi/loadgenerator/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Base Python image (pinned digest for reproducibility)
FROM python:3.12.6-slim@sha256:ad48727987b259854d52241fac3bc633574364867b8e20aec305e6e7f4028b26 AS base

# Builder stage
FROM base AS builder
WORKDIR /app

# Install build tools & git (only here, not in final)
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
&& rm -rf /var/lib/apt/lists/*

# Clone Bank of Anthos repo to get requirements
RUN git clone https://github.com/GoogleCloudPlatform/bank-of-anthos.git /tmp/bank-of-anthos

# Upgrade pip and install requirements into /install
RUN pip install --no-cache-dir --upgrade pip setuptools wheel \
&& pip install --prefix="/install" -r /tmp/bank-of-anthos/src/loadgenerator/requirements.txt

# Final stage
FROM base
WORKDIR /app

# Copy installed packages from builder
COPY --from=builder /install /usr/local

# Copy application code
COPY locustfile.py indexing.py requirements.txt .
COPY --chmod=755 entrypoint.sh /app/entrypoint.sh

# Install overlay-specific requirements
RUN pip install --no-cache-dir --upgrade pip setuptools wheel \
&& pip install --no-cache-dir -r requirements.txt

# Create non-root user
RUN useradd -m locust
USER locust

# Show Python logs as they occur
ENV PYTHONUNBUFFERED=0
ENV GEVENT_SUPPORT=True
ENV LOG_LEVEL=info
ENV TEST=yes

# Entrypoint script
ENTRYPOINT ["/app/entrypoint.sh"]
17 changes: 17 additions & 0 deletions workloads/fsi/loadgenerator/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#!/bin/sh
echo "Delaying start for 90s..."
sleep 90

locust --host=http://${FRONTEND_ADDR} \
--loglevel "${LOG_LEVEL}" \
--headless \
--users="${USERS:-100}" \
--run-time 1m \
--print-stats \
--csv=/tmp/locust-results

echo "Running indexing.py..."
python /app/indexing.py

echo "Sleeping for 1 hour..."
sleep 3600
72 changes: 72 additions & 0 deletions workloads/fsi/loadgenerator/indexing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
import csv
import json
import os
import uuid
from datetime import datetime
from opensearchpy import OpenSearch, helpers
from urllib.parse import urlparse

# OpenSearch connection via single ES_SERVER env var
ES_SERVER = os.getenv("ES_SERVER", "http://opensearch:9200")
parsed = urlparse(ES_SERVER)

if parsed.username and parsed.password:
es_client = OpenSearch(
f"{parsed.scheme}://{parsed.hostname}:{parsed.port or 9200}",
http_auth=(parsed.username, parsed.password),
verify_certs=False
)
else:
es_client = OpenSearch(ES_SERVER, verify_certs=False)

ES_INDEX = os.getenv("ES_INDEX", "locust-results")
csv_file = "/tmp/locust-results_stats.csv"

def csv_to_json(csv_path):
"""Read CSV and yield JSON objects."""
combined = {}
with open(csv_path, newline="") as f:
reader = csv.DictReader(f)
for row in reader:
name = row.get("Name", f"row_{len(combined)}")

# Handle endpoint naming
if name == "/":
name = "home" # rename "/" to "home"
elif name.startswith("/"):
name = name[1:] # strip leading slash for other endpoints

# Convert numeric fields from string to float/int
for key in row:
try:
if "." in row[key]:
row[key] = float(row[key])
else:
row[key] = int(row[key])
except (ValueError, TypeError):
pass

combined[name] = row
return combined

def index_to_es(doc):
"""Index a single document to OpenSearch."""
# Add UUID, timestamp, worker_count and ocp_version from environment
doc["uuid"] = os.environ.get("UUID", str(uuid.uuid4()))
doc["timestamp"] = datetime.utcnow().isoformat()
doc["worker_count"] = int(os.environ.get("WORKER_COUNT", "0"))
doc["ocp_version"] = os.environ.get("OCP_VERSION", "")

json_doc = json.dumps(doc, indent=2)
print("Indexing combined document:\n", json_doc)
es_client.index(index=ES_INDEX, body=doc)
print(f"Indexed combined document to {ES_INDEX}")

if __name__ == "__main__":
combined_doc = csv_to_json(csv_file)
if combined_doc:
index_to_es(combined_doc)
else:
print("No data to index")

221 changes: 221 additions & 0 deletions workloads/fsi/loadgenerator/locustfile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
#!/usr/bin/python
#
# Copyright 2020 Google LLC
#
# Licensed 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.
"""
Exercises the frontend endpoints for the system
"""


import json
import logging
import sys
from string import ascii_letters, digits
from random import randint, random, choice

from locust import HttpUser, TaskSet, SequentialTaskSet, task, between, constant


logging.basicConfig(level=logging.INFO)
logging.info("Locust started with arguments: %s", sys.argv)


MASTER_PASSWORD = "password"

TRANSACTION_ACCT_LIST = [str(randint(1111100000, 1111199999)) for _ in range(50)]

def signup_helper(locust, username):
"""
create a new user account in the system
succeeds if token was returned
"""
userdata = {"username":username,
"password":MASTER_PASSWORD,
"password-repeat":MASTER_PASSWORD,
"firstname": username,
"lastname":"TestAccount",
"birthday":"01/01/2000",
"timezone":"82",
"address":"1021 Valley St",
"city":"Seattle",
"state":"WA",
"zip":"98103",
"ssn":"111-22-3333"}
with locust.client.post("/signup", data=userdata, catch_response=True) as response:
for r_hist in response.history:
if r_hist.cookies.get('token') is not None:
response.success()
logging.debug("created user = %s", username)
return True

response.failure("login failed")
return False

def generate_username():
"""
generates random 15 character
alphanumeric username
"""
return ''.join(choice(ascii_letters + digits) for _ in range(15))

class AllTasks(SequentialTaskSet):
"""
wrapper for UnauthenticatedTasks and AuthenticatedTasks sets
"""
@task
class UnauthenticatedTasks(TaskSet):
"""
set of tasks to run before obtaining an auth token
"""
@task(5)
def view_login(self):
"""
load the /login page
fails if already logged on (redirects to /home)
"""
with self.client.get("/login", catch_response=True) as response:
for r_hist in response.history:
if r_hist.status_code > 200 and r_hist.status_code < 400:
response.failure("Logged on: Got redirect to /home")

@task(5)
def view_signup(self):
"""
load the /signup page
fails if not logged on (redirects to /home)
"""
with self.client.get("/signup", catch_response=True) as response:
for r_hist in response.history:
if r_hist.status_code > 200 and r_hist.status_code < 400:
response.failure("Logged on: Got redirect to /home")

@task(1)
def signup(self):
"""
sends POST request to /signup to create a new user
on success, exits UnauthenticatedTasks
"""
# sign up
new_username = generate_username()
success = signup_helper(self, new_username)
if success:
# go to AuthenticatedTasks
self.user.username = new_username
self.interrupt()

@task
class AuthenticatedTasks(TaskSet):
"""
set of tasks to run after obtaining an auth token
"""
def on_start(self):
"""
on start, deposit a large balance into each account
to ensure all payments are covered
"""
self.deposit(1000000)

@task(10)
def view_index(self):
"""
load the / page
fails if not logged on (redirects to /login)
"""
with self.client.get("/", catch_response=True) as response:
for r_hist in response.history:
if r_hist.status_code > 200 and r_hist.status_code < 400:
response.failure("Not logged on: Got redirect to /login")

@task(10)
def view_home(self):
"""
load the /home page (identical to /)
fails if not logged on (redirects to /login)
"""
with self.client.get("/home", catch_response=True) as response:
for r_hist in response.history:
if r_hist.status_code > 200 and r_hist.status_code < 400:
response.failure("Not logged on: Got redirect to /login")

@task(5)
def payment(self, amount=None):
"""
POST to /payment, sending money to other account
"""
if amount is None:
amount = random() * 1000
transaction = {"account_num": choice(TRANSACTION_ACCT_LIST),
"amount": amount,
"uuid": generate_username()}
with self.client.post("/payment",
data=transaction,
catch_response=True) as response:
if response.url is None or "failed" in response.url:
response.failure("payment failed")

@task(5)
def deposit(self, amount=None):
"""
POST to /deposit, depositing external money into account
"""
if amount is None:
amount = random() * 1000
acct_info = {"account_num": choice(TRANSACTION_ACCT_LIST),
"routing_num":"111111111"}
transaction = {"account": json.dumps(acct_info),
"amount": amount,
"uuid": generate_username()}
with self.client.post("/deposit",
data=transaction,
catch_response=True) as response:
if response.url is None or "failed" in response.url:
response.failure("deposit failed")

@task(5)
def login(self):
"""
sends POST request to /login with stored credentials
succeeds if a token was returned
"""
with self.client.post("/login", {"username":self.user.username,
"password":MASTER_PASSWORD},
catch_response=True) as response:
for r_hist in response.history:
if r_hist.cookies.get('token') is not None:
response.success()
return
response.failure("login failed")

@task(1)
def logout(self):
"""
sends a /logout POST request
fails if not logged in
exits AuthenticatedTasks
"""
with self.client.post("/logout", catch_response=True) as response:
for r_hist in response.history:
if r_hist.status_code > 200 and r_hist.status_code < 400:
response.success()
self.user.username = None
# go to UnauthenticatedTasks
self.interrupt()


class WebsiteUser(HttpUser):
"""
Locust class to simulate HTTP users
"""
tasks = [AllTasks]
wait_time = constant(0)
1 change: 1 addition & 0 deletions workloads/fsi/loadgenerator/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
opensearch-py>=2.0.0
Loading