Instead of taking wild guesses at the runtime of the target generation itself, we're just making sure to reset the system time to a fixed value when we ultimately start PostgREST. This allows us to create the right JWT expiry values ahead of time.
207 lines
5.6 KiB
Python
207 lines
5.6 KiB
Python
# generates a file to be used by the vegeta load testing tool
|
|
|
|
# It includes a worst case scenario for the JWT cache:
|
|
# - all requests will have a unique JWT so no cache hits
|
|
# - all jwts have an expiration that will be long enough to be
|
|
# valid at time of request but short enough that already
|
|
# validated jwts will expire later during the loadtest run
|
|
# - the above guarantees JWT cache purging will happen
|
|
# - we want this to track resource consumption in the worst case
|
|
|
|
# And a more normal scenario where non-expiring JWTs are picked
|
|
# from an array
|
|
import time
|
|
import argparse
|
|
import subprocess
|
|
import sys
|
|
import random
|
|
import jwt
|
|
from typing import Optional
|
|
from pathlib import Path
|
|
from enum import Enum
|
|
|
|
URL = "http://postgrest"
|
|
|
|
secret_key = b"reallyreallyreallyreallyverysafe"
|
|
|
|
|
|
def generate_jwt(
|
|
now: int,
|
|
exp_inc: Optional[int],
|
|
rsa_private_key: Optional[jwt.algorithms.RSAAlgorithm],
|
|
) -> str:
|
|
"""Generate an HS256 or RS256 JWT"""
|
|
payload = {
|
|
"sub": f"user_{random.getrandbits(32)}",
|
|
"iat": now,
|
|
"role": "postgrest_test_author",
|
|
}
|
|
|
|
if exp_inc is not None:
|
|
payload["exp"] = now + exp_inc
|
|
|
|
if rsa_private_key is None:
|
|
key = secret_key
|
|
alg = "HS256"
|
|
else:
|
|
key = rsa_private_key
|
|
alg = "RS256"
|
|
return jwt.encode(payload, key, alg)
|
|
|
|
|
|
HTTP_METHODS = (
|
|
"GET",
|
|
"OPTIONS",
|
|
)
|
|
|
|
HttpMethod = Enum(
|
|
"HttpMethod",
|
|
{method: method for method in HTTP_METHODS},
|
|
type=str,
|
|
module=__name__,
|
|
)
|
|
|
|
|
|
def append_targets(lines: list[str], token: str, http_method: HttpMethod):
|
|
lines.append(f"{http_method.value} {URL}/authors_only")
|
|
lines.append(f"Authorization: Bearer {token}")
|
|
lines.append("") # blank line to separate requests
|
|
|
|
|
|
# we use this to chain commands on loadtest.nix
|
|
def run_command(command: list[str]):
|
|
if not command:
|
|
return
|
|
|
|
if command[0] == "--":
|
|
command = command[1:]
|
|
|
|
if not command:
|
|
return
|
|
|
|
try:
|
|
subprocess.run(command, check=True)
|
|
except subprocess.CalledProcessError as exc:
|
|
print(
|
|
f"Error executing command {' '.join(command)}: {exc}",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(exc.returncode)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Generate Vegeta targets with unique JWTs"
|
|
)
|
|
parser.add_argument(
|
|
"targets_path",
|
|
metavar="TARGETS_PATH",
|
|
help="Path to write the generated targets file",
|
|
)
|
|
parser.add_argument(
|
|
"--private-key",
|
|
dest="private_key_path",
|
|
metavar="PRIVATE_KEY_PATH",
|
|
type=Path,
|
|
default=None,
|
|
help="Path to the RSA private key file (required when --rsa is used)",
|
|
)
|
|
parser.add_argument(
|
|
"--worst",
|
|
dest="worst",
|
|
action=argparse.BooleanOptionalAction,
|
|
default=False,
|
|
help="Generate worst case targets for a JWT cache",
|
|
)
|
|
parser.add_argument(
|
|
"--rsa",
|
|
dest="jwk_path",
|
|
metavar="JWK_PATH",
|
|
type=Path,
|
|
default=None,
|
|
help="Path to an existing RSA JWK file used for signing tokens",
|
|
)
|
|
parser.add_argument(
|
|
"--method",
|
|
dest="http_method",
|
|
choices=list(HTTP_METHODS),
|
|
required=True,
|
|
help="HTTP method for the vegeta targets",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
rsa_private_key: Optional[jwt.algorithms.RSAAlgorithm] = None
|
|
|
|
is_hs = args.jwk_path is None
|
|
|
|
http_method = HttpMethod(args.http_method)
|
|
|
|
nsamples = 1000
|
|
|
|
if is_hs:
|
|
ntargets = 200000
|
|
else:
|
|
# The asymmetric targets take too long to compute so we reduce them
|
|
ntargets = 50000
|
|
|
|
if not is_hs:
|
|
if args.private_key_path is None:
|
|
parser.error("--rsa requires the --private-key option")
|
|
try:
|
|
private_key_data = args.private_key_path.read_text()
|
|
except OSError as e:
|
|
err = (
|
|
f"Error reading RSA private key from {args.private_key_path}: "
|
|
f"{e}. Generate RSA materials first with gen_rsa_materials.py."
|
|
)
|
|
print(err, file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
try:
|
|
rsa_private_key = jwt.algorithms.RSAAlgorithm.from_jwk(private_key_data)
|
|
except Exception as exc: # broad exception to capture parsing errors
|
|
err = (
|
|
f"Error loading RSA private key from {args.private_key_path}: " f"{exc}"
|
|
)
|
|
print(err, file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
print(f"Generating {ntargets} targets...")
|
|
|
|
now = int(time.time())
|
|
|
|
lines = []
|
|
|
|
# We want to ensure 401 Unauthorized responses don't happen during
|
|
# JWT validation, this can happen when the jwt `exp` is too short.
|
|
# At the same time, we want to ensure the `exp` is not too big,
|
|
# so expires will occur and postgREST needs to
|
|
# clean cached expired JWTs
|
|
if args.worst:
|
|
# estimated time it takes to run postgrest itself
|
|
run_postgrest_time = 2
|
|
|
|
for i in range(ntargets):
|
|
token = generate_jwt(now, run_postgrest_time + i // 1000, rsa_private_key)
|
|
append_targets(lines, token, http_method)
|
|
|
|
else:
|
|
tokens = [generate_jwt(now, None, rsa_private_key) for _ in range(nsamples)]
|
|
for i in range(ntargets):
|
|
token = random.choice(tokens)
|
|
append_targets(lines, token, http_method)
|
|
|
|
try:
|
|
with open(args.targets_path, "w") as f:
|
|
f.write("\n".join(lines))
|
|
except IOError as e:
|
|
print(f"Error writing to {args.targets_path}: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
print(f"Created {ntargets} targets", end=" ")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|