jac-loadtest 0.1.0__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/PKG-INFO +20 -1
- {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/README.md +19 -0
- jac_loadtest-0.2.2/jac_loadtest/bridge/auth.py +114 -0
- jac_loadtest-0.2.2/jac_loadtest/bridge/topology.py +201 -0
- {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/jac_loadtest/cli.py +35 -5
- jac_loadtest-0.2.2/jac_loadtest/config.py +149 -0
- {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/jac_loadtest/core/engine.py +78 -12
- jac_loadtest-0.2.2/jac_loadtest/core/har_parser.py +279 -0
- {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/jac_loadtest/core/metrics.py +15 -6
- {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/jac_loadtest/output/reporter.py +32 -8
- {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/jac_loadtest/plugin.py +16 -16
- {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/jac_loadtest.egg-info/PKG-INFO +20 -1
- {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/pyproject.toml +1 -1
- jac_loadtest-0.1.0/jac_loadtest/bridge/auth.py +0 -15
- jac_loadtest-0.1.0/jac_loadtest/bridge/topology.py +0 -24
- jac_loadtest-0.1.0/jac_loadtest/config.py +0 -123
- jac_loadtest-0.1.0/jac_loadtest/core/har_parser.py +0 -161
- {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/jac_loadtest/__init__.py +0 -0
- {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/jac_loadtest/bridge/__init__.py +0 -0
- {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/jac_loadtest/core/__init__.py +0 -0
- {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/jac_loadtest/output/__init__.py +0 -0
- {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/jac_loadtest.egg-info/SOURCES.txt +0 -0
- {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/jac_loadtest.egg-info/dependency_links.txt +0 -0
- {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/jac_loadtest.egg-info/entry_points.txt +0 -0
- {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/jac_loadtest.egg-info/requires.txt +0 -0
- {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/jac_loadtest.egg-info/top_level.txt +0 -0
- {jac_loadtest-0.1.0 → jac_loadtest-0.2.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: jac-loadtest
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: HAR-based load testing CLI for jac-scale applications
|
|
5
5
|
Requires-Python: >=3.12
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -19,6 +19,25 @@ HAR-based load testing CLI for [jac-scale](https://github.com/jaseci-labs/jaseci
|
|
|
19
19
|
|
|
20
20
|
The tool registers itself as a `jac` subcommand, so after installation you run `jac loadtest` alongside `jac start`, `jac deploy`, and the rest of the jac ecosystem.
|
|
21
21
|
|
|
22
|
+
## Testing Modes
|
|
23
|
+
|
|
24
|
+
**Monolith mode** (default) — all requests go through a single `--url`. Use this for production-realistic load testing: it measures what users actually experience end-to-end through the gateway.
|
|
25
|
+
|
|
26
|
+
**Microservice mode** — route requests directly to individual service processes by URL path prefix. Use this locally or inside your cluster to isolate per-service latency and identify which service is the bottleneck — without gateway overhead masking the signal.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# Monolith: all traffic through the gateway (default, production-realistic)
|
|
30
|
+
jac loadtest recording.har --url http://localhost:8000 --vus 10 --duration 30s
|
|
31
|
+
|
|
32
|
+
# Microservice: bypass gateway, route by path prefix to individual services
|
|
33
|
+
jac loadtest recording.har --mode microservice \
|
|
34
|
+
--url http://localhost:8000 \
|
|
35
|
+
--services-map '{"order_service":"http://localhost:18001","inventory_service":"http://localhost:18002"}' \
|
|
36
|
+
--vus 10 --duration 30s
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
> **Note:** Microservice mode requires direct network access to service ports. This means it's only usable locally (`jac serve`) or from inside a Kubernetes cluster — not from outside production. For remote or production load testing, use monolith mode.
|
|
40
|
+
|
|
22
41
|
## Quick Start
|
|
23
42
|
|
|
24
43
|
```bash
|
|
@@ -4,6 +4,25 @@ HAR-based load testing CLI for [jac-scale](https://github.com/jaseci-labs/jaseci
|
|
|
4
4
|
|
|
5
5
|
The tool registers itself as a `jac` subcommand, so after installation you run `jac loadtest` alongside `jac start`, `jac deploy`, and the rest of the jac ecosystem.
|
|
6
6
|
|
|
7
|
+
## Testing Modes
|
|
8
|
+
|
|
9
|
+
**Monolith mode** (default) — all requests go through a single `--url`. Use this for production-realistic load testing: it measures what users actually experience end-to-end through the gateway.
|
|
10
|
+
|
|
11
|
+
**Microservice mode** — route requests directly to individual service processes by URL path prefix. Use this locally or inside your cluster to isolate per-service latency and identify which service is the bottleneck — without gateway overhead masking the signal.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# Monolith: all traffic through the gateway (default, production-realistic)
|
|
15
|
+
jac loadtest recording.har --url http://localhost:8000 --vus 10 --duration 30s
|
|
16
|
+
|
|
17
|
+
# Microservice: bypass gateway, route by path prefix to individual services
|
|
18
|
+
jac loadtest recording.har --mode microservice \
|
|
19
|
+
--url http://localhost:8000 \
|
|
20
|
+
--services-map '{"order_service":"http://localhost:18001","inventory_service":"http://localhost:18002"}' \
|
|
21
|
+
--vus 10 --duration 30s
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
> **Note:** Microservice mode requires direct network access to service ports. This means it's only usable locally (`jac serve`) or from inside a Kubernetes cluster — not from outside production. For remote or production load testing, use monolith mode.
|
|
25
|
+
|
|
7
26
|
## Quick Start
|
|
8
27
|
|
|
9
28
|
```bash
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""jac-scale auth: per-VU JWT login and header injection.
|
|
2
|
+
|
|
3
|
+
AuthProvider loads credentials from a CSV file or shared username/password,
|
|
4
|
+
authenticates each VU independently against /user/login, and returns the
|
|
5
|
+
Bearer token for injection into subsequent requests.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import csv
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
import aiohttp
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from jac_loadtest.config import LoadTestConfig
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AuthenticationError(Exception):
|
|
20
|
+
"""Raised when login fails; carries a human-readable message for the user."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Credential:
|
|
25
|
+
username: str
|
|
26
|
+
password: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AuthProvider:
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
credentials: list[Credential],
|
|
33
|
+
login_path: str = "/user/login",
|
|
34
|
+
) -> None:
|
|
35
|
+
self._credentials = credentials
|
|
36
|
+
self._login_path = login_path
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_config(cls, config: LoadTestConfig) -> AuthProvider | None:
|
|
40
|
+
"""Return an AuthProvider if credentials are configured, else None."""
|
|
41
|
+
if config.credentials_file:
|
|
42
|
+
creds = _load_csv(config.credentials_file)
|
|
43
|
+
elif config.username and config.password:
|
|
44
|
+
creds = [Credential(config.username, config.password)]
|
|
45
|
+
else:
|
|
46
|
+
return None
|
|
47
|
+
return cls(creds, config.login_path)
|
|
48
|
+
|
|
49
|
+
def get_credential(self, vu_id: int) -> Credential:
|
|
50
|
+
"""Wrap-around assignment: VU i gets row i % len(credentials)."""
|
|
51
|
+
return self._credentials[vu_id % len(self._credentials)]
|
|
52
|
+
|
|
53
|
+
async def authenticate(
|
|
54
|
+
self,
|
|
55
|
+
vu_id: int,
|
|
56
|
+
session: aiohttp.ClientSession,
|
|
57
|
+
base_url: str,
|
|
58
|
+
) -> str:
|
|
59
|
+
"""POST /user/login for VU vu_id and return the JWT token string.
|
|
60
|
+
|
|
61
|
+
identity.type is inferred: values containing '@' use 'email', else 'username'.
|
|
62
|
+
Raises AuthenticationError on 4xx/5xx so callers get a clear message.
|
|
63
|
+
"""
|
|
64
|
+
cred = self.get_credential(vu_id)
|
|
65
|
+
identity_type = "email" if "@" in cred.username else "username"
|
|
66
|
+
payload = {
|
|
67
|
+
"identity": {"type": identity_type, "value": cred.username},
|
|
68
|
+
"credential": {"type": "password", "password": cred.password},
|
|
69
|
+
}
|
|
70
|
+
url = base_url.rstrip("/") + self._login_path
|
|
71
|
+
try:
|
|
72
|
+
async with session.post(url, json=payload) as resp:
|
|
73
|
+
if resp.status == 401:
|
|
74
|
+
raise AuthenticationError(
|
|
75
|
+
f"Login failed for VU {vu_id} ({identity_type}={cred.username!r}): "
|
|
76
|
+
f"401 Unauthorized — check credentials."
|
|
77
|
+
)
|
|
78
|
+
if not resp.ok:
|
|
79
|
+
raise AuthenticationError(
|
|
80
|
+
f"Login failed for VU {vu_id} ({identity_type}={cred.username!r}): "
|
|
81
|
+
f"server returned {resp.status}."
|
|
82
|
+
)
|
|
83
|
+
body = await resp.json()
|
|
84
|
+
except aiohttp.ClientConnectorError as exc:
|
|
85
|
+
raise AuthenticationError(
|
|
86
|
+
f"Cannot reach login endpoint {url!r}: {exc}"
|
|
87
|
+
) from exc
|
|
88
|
+
try:
|
|
89
|
+
return body["data"]["token"]
|
|
90
|
+
except (KeyError, TypeError) as exc:
|
|
91
|
+
raise AuthenticationError(
|
|
92
|
+
f"Unexpected login response shape from {url!r}: {body!r}"
|
|
93
|
+
) from exc
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _load_csv(path: str) -> list[Credential]:
|
|
97
|
+
"""Read credentials.csv and return a list of Credential objects.
|
|
98
|
+
|
|
99
|
+
Skips a header row if the first column value is 'username' (case-insensitive).
|
|
100
|
+
Raises ValueError if the file is empty or has no valid rows.
|
|
101
|
+
"""
|
|
102
|
+
credentials: list[Credential] = []
|
|
103
|
+
with open(path, newline="") as f:
|
|
104
|
+
reader = csv.reader(f)
|
|
105
|
+
for i, row in enumerate(reader):
|
|
106
|
+
if len(row) < 2:
|
|
107
|
+
continue
|
|
108
|
+
username, password = row[0].strip(), row[1].strip()
|
|
109
|
+
if i == 0 and username.lower() == "username":
|
|
110
|
+
continue
|
|
111
|
+
credentials.append(Credential(username=username, password=password))
|
|
112
|
+
if not credentials:
|
|
113
|
+
raise ValueError(f"No credentials found in {path!r}")
|
|
114
|
+
return credentials
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Build prefix→URL routing table from jac-scale ServiceRegistry or jac.toml.
|
|
2
|
+
|
|
3
|
+
Implements longest-prefix matching to mirror jac-scale's gateway routing algorithm:
|
|
4
|
+
path == prefix OR path.startswith(prefix + "/")
|
|
5
|
+
Empty prefix is a catch-all (used for monolith mode).
|
|
6
|
+
|
|
7
|
+
Implemented in Phase 3.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from urllib.parse import urlparse, urlunparse
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from jac_loadtest.config import LoadTestConfig
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ServiceRoute:
|
|
23
|
+
name: str # service label used in metrics/report (e.g. "order_service")
|
|
24
|
+
prefix: str # path prefix for routing (e.g. "/walker/order"); "" = catch-all
|
|
25
|
+
url: str # service base URL, no trailing slash (e.g. "http://localhost:18001")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TopologyRouter:
|
|
29
|
+
"""Routes a HAR entry URL to the correct service URL and returns the service name.
|
|
30
|
+
|
|
31
|
+
Construction: TopologyRouter.from_config(config) is the normal entry point.
|
|
32
|
+
Direct construction (TopologyRouter(routes, fallback_url)) is used in tests.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
routes: list[ServiceRoute],
|
|
38
|
+
fallback_url: str | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
# Sort once at construction — longest prefix first (mirrors ServiceRegistry)
|
|
41
|
+
self._routes = sorted(routes, key=lambda r: len(r.prefix), reverse=True)
|
|
42
|
+
self._fallback_url = fallback_url
|
|
43
|
+
|
|
44
|
+
def resolve(self, entry_url: str) -> tuple[str, str]:
|
|
45
|
+
"""Return (routed_full_url, service_name) for the given HAR entry URL.
|
|
46
|
+
|
|
47
|
+
Reconstructs the full URL using the matched service's base + original path + query.
|
|
48
|
+
Match logic mirrors jac-scale ServiceRegistry.match_route() exactly:
|
|
49
|
+
empty prefix → catch-all (monolith)
|
|
50
|
+
path == prefix OR path.startswith(prefix + "/")
|
|
51
|
+
"""
|
|
52
|
+
parsed = urlparse(entry_url)
|
|
53
|
+
path = parsed.path
|
|
54
|
+
|
|
55
|
+
for route in self._routes:
|
|
56
|
+
if _prefix_matches(path, route.prefix):
|
|
57
|
+
t = urlparse(route.url)
|
|
58
|
+
# Strip the gateway prefix before sending directly to the service.
|
|
59
|
+
# jac-scale's gateway strips the route prefix when forwarding; we replicate
|
|
60
|
+
# that so the service receives the path it actually handles.
|
|
61
|
+
# Empty prefix (monolith catch-all) keeps the path unchanged.
|
|
62
|
+
if route.prefix:
|
|
63
|
+
service_path = path[len(route.prefix):] or "/"
|
|
64
|
+
else:
|
|
65
|
+
service_path = path
|
|
66
|
+
base_path = t.path.rstrip("/") if t.path else ""
|
|
67
|
+
routed_path = f"{base_path}{service_path}" if base_path else service_path
|
|
68
|
+
routed = urlunparse((
|
|
69
|
+
t.scheme, t.netloc,
|
|
70
|
+
routed_path, parsed.params, parsed.query, "",
|
|
71
|
+
))
|
|
72
|
+
return routed, route.name
|
|
73
|
+
|
|
74
|
+
if self._fallback_url:
|
|
75
|
+
t = urlparse(self._fallback_url)
|
|
76
|
+
base_path = t.path.rstrip("/") if t.path else ""
|
|
77
|
+
routed_path = f"{base_path}{path}" if base_path else path
|
|
78
|
+
routed = urlunparse((
|
|
79
|
+
t.scheme, t.netloc,
|
|
80
|
+
routed_path, parsed.params, parsed.query, "",
|
|
81
|
+
))
|
|
82
|
+
return routed, "gateway"
|
|
83
|
+
|
|
84
|
+
raise ValueError(
|
|
85
|
+
f"No route for path '{path}' and no --url fallback provided. "
|
|
86
|
+
"Use --services-map or add [plugins.scale.microservices.routes] to jac.toml."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def from_config(cls, config: LoadTestConfig) -> TopologyRouter:
|
|
91
|
+
"""Build a TopologyRouter from LoadTestConfig.
|
|
92
|
+
|
|
93
|
+
Monolith mode: single catch-all route to config.url.
|
|
94
|
+
Microservice mode: --services-map JSON (highest priority) or jac.toml auto-discovery.
|
|
95
|
+
"""
|
|
96
|
+
if config.mode != "microservice":
|
|
97
|
+
return cls(
|
|
98
|
+
routes=[ServiceRoute(name="monolith", prefix="", url=config.url or "")],
|
|
99
|
+
fallback_url=None,
|
|
100
|
+
)
|
|
101
|
+
return cls._build_microservice(config)
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def _build_microservice(cls, config: LoadTestConfig) -> TopologyRouter:
|
|
105
|
+
toml_routes = _load_toml_routes() # service_name → prefix; {} if unavailable
|
|
106
|
+
|
|
107
|
+
routes: list[ServiceRoute]
|
|
108
|
+
|
|
109
|
+
if config.services_map:
|
|
110
|
+
try:
|
|
111
|
+
services_json: dict[str, str] = json.loads(config.services_map)
|
|
112
|
+
except json.JSONDecodeError as exc:
|
|
113
|
+
raise ValueError(f"--services-map is not valid JSON: {exc}") from exc
|
|
114
|
+
|
|
115
|
+
routes = [
|
|
116
|
+
ServiceRoute(
|
|
117
|
+
# Strip leading slash from name so service labels display cleanly
|
|
118
|
+
name=name.lstrip("/") if name.startswith("/") else name,
|
|
119
|
+
# Key starting with "/" is used directly as prefix; otherwise derive from key
|
|
120
|
+
prefix=toml_routes.get(name, name if name.startswith("/") else f"/{name}"),
|
|
121
|
+
url=url.rstrip("/"),
|
|
122
|
+
)
|
|
123
|
+
for name, url in services_json.items()
|
|
124
|
+
]
|
|
125
|
+
return cls(routes, fallback_url=config.url)
|
|
126
|
+
|
|
127
|
+
# Auto-discovery: jac.toml routes + JAC_SV_<NAME>_URL env vars
|
|
128
|
+
if not toml_routes:
|
|
129
|
+
raise ValueError(
|
|
130
|
+
"Microservice mode requires either --services-map or "
|
|
131
|
+
"[plugins.scale.microservices.routes] in jac.toml. Neither was found."
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
routes = []
|
|
135
|
+
missing: list[str] = []
|
|
136
|
+
for name, prefix in toml_routes.items():
|
|
137
|
+
env_var = f"JAC_SV_{name.upper()}_URL"
|
|
138
|
+
url = os.environ.get(env_var)
|
|
139
|
+
if not url:
|
|
140
|
+
missing.append(env_var)
|
|
141
|
+
else:
|
|
142
|
+
routes.append(ServiceRoute(name=name, prefix=prefix, url=url.rstrip("/")))
|
|
143
|
+
|
|
144
|
+
if missing:
|
|
145
|
+
raise ValueError(
|
|
146
|
+
f"Microservice mode: missing environment variable(s): {', '.join(missing)}. "
|
|
147
|
+
"Expected format: JAC_SV_<SERVICE_NAME>_URL=http://host:port"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return cls(routes, fallback_url=config.url)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _prefix_matches(path: str, prefix: str) -> bool:
|
|
154
|
+
"""Return True if path matches prefix using jac-scale's exact routing semantics."""
|
|
155
|
+
if not prefix:
|
|
156
|
+
return True # empty prefix = catch-all (monolith)
|
|
157
|
+
return path == prefix or path.startswith(prefix + "/")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _load_toml_routes() -> dict[str, str]:
|
|
161
|
+
"""Read [plugins.scale.microservices.routes] from jac.toml.
|
|
162
|
+
|
|
163
|
+
Returns {service_name: prefix} dict.
|
|
164
|
+
Returns {} on any error (missing file, missing section, jac_scale unavailable).
|
|
165
|
+
|
|
166
|
+
Isolated as a module-level function so tests can monkeypatch it without
|
|
167
|
+
importing jac_scale at all — keeping topology unit tests jac-scale-free.
|
|
168
|
+
"""
|
|
169
|
+
try:
|
|
170
|
+
from pathlib import Path
|
|
171
|
+
from jac_scale.config_loader import get_scale_config, reset_scale_config
|
|
172
|
+
reset_scale_config()
|
|
173
|
+
ms_config = get_scale_config(project_dir=Path.cwd()).get_microservices_config()
|
|
174
|
+
return dict(ms_config.get("routes", {}))
|
|
175
|
+
except Exception:
|
|
176
|
+
return {}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
# Backward-compat shims — kept so any external code that imported these names
|
|
181
|
+
# does not break. Neither is called by production or test code in this repo.
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
def build_routing_table(
|
|
185
|
+
mode: str,
|
|
186
|
+
base_url: str | None = None,
|
|
187
|
+
services_map_json: str | None = None,
|
|
188
|
+
) -> dict[str, str]:
|
|
189
|
+
raise NotImplementedError(
|
|
190
|
+
"build_routing_table() is deprecated. Use TopologyRouter.from_config() instead."
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def resolve_url(path: str, routing_table: dict[str, str], fallback_url: str | None) -> str:
|
|
195
|
+
"""Longest-prefix match: mirrors jac-scale ServiceRegistry.match_route()."""
|
|
196
|
+
for prefix in sorted(routing_table, key=len, reverse=True):
|
|
197
|
+
if path.startswith(prefix):
|
|
198
|
+
return routing_table[prefix]
|
|
199
|
+
if fallback_url:
|
|
200
|
+
return fallback_url
|
|
201
|
+
raise ValueError(f"No route for path '{path}' and no --url fallback provided.")
|
|
@@ -19,18 +19,20 @@ def run(args: object) -> None:
|
|
|
19
19
|
|
|
20
20
|
config = from_args(args)
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
# --url required for monolith mode; optional for microservice (becomes fallback)
|
|
23
|
+
if config.mode == "monolith" and not config.url:
|
|
24
|
+
print("Error: --url is required for monolith mode", file=sys.stderr)
|
|
24
25
|
sys.exit(2)
|
|
25
26
|
|
|
26
27
|
if not config.har_file:
|
|
27
28
|
print("Error: har_file positional argument is required", file=sys.stderr)
|
|
28
29
|
sys.exit(2)
|
|
29
30
|
|
|
31
|
+
# Parse HAR — monolith rewrites all URLs to config.url; microservice keeps originals
|
|
30
32
|
try:
|
|
31
33
|
entries = parse_har(
|
|
32
34
|
config.har_file,
|
|
33
|
-
target_url=config.url,
|
|
35
|
+
target_url=config.url if config.mode == "monolith" else None,
|
|
34
36
|
include_static=config.include_static,
|
|
35
37
|
login_path=config.login_path,
|
|
36
38
|
)
|
|
@@ -46,11 +48,39 @@ def run(args: object) -> None:
|
|
|
46
48
|
)
|
|
47
49
|
sys.exit(2)
|
|
48
50
|
|
|
51
|
+
from jac_loadtest.bridge.auth import AuthProvider, AuthenticationError
|
|
52
|
+
|
|
53
|
+
auth_provider = AuthProvider.from_config(config)
|
|
54
|
+
|
|
55
|
+
# In microservice mode, auth still goes to the gateway (--url); require it when set
|
|
56
|
+
if config.mode == "microservice" and auth_provider is not None and not config.url:
|
|
57
|
+
print(
|
|
58
|
+
"Error: --url (gateway URL) is required when using authentication "
|
|
59
|
+
"in microservice mode",
|
|
60
|
+
file=sys.stderr,
|
|
61
|
+
)
|
|
62
|
+
sys.exit(2)
|
|
63
|
+
|
|
64
|
+
# Build topology router — validates service map JSON and service URL availability
|
|
65
|
+
from jac_loadtest.bridge.topology import TopologyRouter
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
topology = TopologyRouter.from_config(config)
|
|
69
|
+
except ValueError as exc:
|
|
70
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
71
|
+
sys.exit(2)
|
|
72
|
+
|
|
49
73
|
metrics = MetricsCollector(max_samples=config.max_samples)
|
|
50
74
|
t_start = time.time()
|
|
51
75
|
|
|
52
|
-
|
|
76
|
+
try:
|
|
77
|
+
asyncio.run(
|
|
78
|
+
run_all_vus(entries, config, metrics, topology=topology, auth_provider=auth_provider)
|
|
79
|
+
)
|
|
80
|
+
except AuthenticationError as exc:
|
|
81
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
82
|
+
sys.exit(2)
|
|
53
83
|
|
|
54
84
|
duration_s = time.time() - t_start
|
|
55
85
|
stats = metrics.compute_endpoint_stats(duration_s)
|
|
56
|
-
render_console(stats, config)
|
|
86
|
+
render_console(stats, config, actual_duration_s=duration_s)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""LoadTestConfig — three-layer resolution: jac.toml → CLI flags → built-in defaults.
|
|
2
|
+
|
|
3
|
+
Phase 0: dataclass with built-in defaults only.
|
|
4
|
+
Phase 2 will add jac.toml reading via jac_scale.config_loader.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
BUILT_IN_DEFAULTS: dict = {
|
|
12
|
+
"vus": 1,
|
|
13
|
+
"duration": "30s",
|
|
14
|
+
"iterations": 1,
|
|
15
|
+
"ramp_up": "0s",
|
|
16
|
+
"timeout": "30s",
|
|
17
|
+
"mode": "monolith",
|
|
18
|
+
"think_time": "none",
|
|
19
|
+
"think_time_scale": 1.0,
|
|
20
|
+
"rps": 0,
|
|
21
|
+
"include_static": False,
|
|
22
|
+
"login_path": "/user/login",
|
|
23
|
+
"fail_on_error_rate": None,
|
|
24
|
+
"fail_on_p95": None,
|
|
25
|
+
"fail_on_p99": None,
|
|
26
|
+
"abort_on_fail": False,
|
|
27
|
+
"threshold_start_delay": "0s",
|
|
28
|
+
"report_format": "console",
|
|
29
|
+
"max_samples": 1_000_000,
|
|
30
|
+
"csrf": False,
|
|
31
|
+
"debug": False,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class LoadTestConfig:
|
|
37
|
+
# Load shape
|
|
38
|
+
vus: int = 1
|
|
39
|
+
duration: str = "30s"
|
|
40
|
+
iterations: int = 1
|
|
41
|
+
ramp_up: str = "0s"
|
|
42
|
+
timeout: str = "30s"
|
|
43
|
+
|
|
44
|
+
# Traffic
|
|
45
|
+
mode: str = "monolith"
|
|
46
|
+
think_time: str = "none"
|
|
47
|
+
think_time_scale: float = 1.0
|
|
48
|
+
rps: int = 0
|
|
49
|
+
include_static: bool = False
|
|
50
|
+
|
|
51
|
+
# Auth
|
|
52
|
+
login_path: str = "/user/login"
|
|
53
|
+
csrf: bool = False
|
|
54
|
+
|
|
55
|
+
# CI thresholds
|
|
56
|
+
fail_on_error_rate: float | None = None
|
|
57
|
+
fail_on_p95: float | None = None
|
|
58
|
+
fail_on_p99: float | None = None
|
|
59
|
+
abort_on_fail: bool = False
|
|
60
|
+
threshold_start_delay: str = "0s"
|
|
61
|
+
|
|
62
|
+
# Output
|
|
63
|
+
report_format: str = "console"
|
|
64
|
+
max_samples: int = 1_000_000
|
|
65
|
+
debug: bool = False
|
|
66
|
+
|
|
67
|
+
# CLI-only — not sourced from jac.toml (environment-specific or security-sensitive)
|
|
68
|
+
har_file: str = ""
|
|
69
|
+
url: str | None = None
|
|
70
|
+
username: str | None = None
|
|
71
|
+
password: str | None = None
|
|
72
|
+
credentials_file: str | None = None
|
|
73
|
+
services_map: str | None = None
|
|
74
|
+
report_out: str | None = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def parse_duration(s: str) -> float:
|
|
78
|
+
"""Convert a duration string ('30s', '2m', '1h') to seconds."""
|
|
79
|
+
s = s.strip()
|
|
80
|
+
if s.endswith("h"):
|
|
81
|
+
return float(s[:-1]) * 3600
|
|
82
|
+
if s.endswith("m"):
|
|
83
|
+
return float(s[:-1]) * 60
|
|
84
|
+
if s.endswith("s"):
|
|
85
|
+
return float(s[:-1])
|
|
86
|
+
return float(s)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _load_toml_defaults() -> dict:
|
|
90
|
+
"""Read [plugins.scale.loadtest] from jac.toml using jac-scale's native config API.
|
|
91
|
+
|
|
92
|
+
Returns an empty dict if jac.toml is absent, the section is missing, or
|
|
93
|
+
the import fails (e.g. jac-scale not installed in a minimal test env).
|
|
94
|
+
"""
|
|
95
|
+
try:
|
|
96
|
+
from pathlib import Path
|
|
97
|
+
from jac_scale.config_loader import get_scale_config, reset_scale_config
|
|
98
|
+
reset_scale_config()
|
|
99
|
+
scale_config = get_scale_config(project_dir=Path.cwd())
|
|
100
|
+
return scale_config.get_section("loadtest")
|
|
101
|
+
except Exception:
|
|
102
|
+
return {}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def from_args(args: object) -> LoadTestConfig:
|
|
106
|
+
"""Build LoadTestConfig using three-layer resolution: CLI > jac.toml > built-in defaults."""
|
|
107
|
+
toml = _load_toml_defaults()
|
|
108
|
+
|
|
109
|
+
# For toml-sourced fields, use CLI value if provided (not None), else toml value,
|
|
110
|
+
# else built-in default.
|
|
111
|
+
def resolve(name: str) -> Any:
|
|
112
|
+
cli_val = getattr(args, name, None)
|
|
113
|
+
if cli_val is not None:
|
|
114
|
+
return cli_val
|
|
115
|
+
if name in toml:
|
|
116
|
+
return toml[name]
|
|
117
|
+
return BUILT_IN_DEFAULTS.get(name)
|
|
118
|
+
|
|
119
|
+
return LoadTestConfig(
|
|
120
|
+
# CLI-only fields: not sourced from jac.toml
|
|
121
|
+
har_file=getattr(args, "har_file", "") or "",
|
|
122
|
+
url=getattr(args, "url", None),
|
|
123
|
+
username=getattr(args, "username", None),
|
|
124
|
+
password=getattr(args, "password", None),
|
|
125
|
+
credentials_file=getattr(args, "credentials_file", None),
|
|
126
|
+
services_map=getattr(args, "services_map", None),
|
|
127
|
+
report_out=getattr(args, "report_out", None),
|
|
128
|
+
# Three-layer resolved fields
|
|
129
|
+
iterations=resolve("iterations"),
|
|
130
|
+
mode=resolve("mode"),
|
|
131
|
+
vus=resolve("vus"),
|
|
132
|
+
duration=resolve("duration"),
|
|
133
|
+
ramp_up=resolve("ramp_up"),
|
|
134
|
+
timeout=resolve("timeout"),
|
|
135
|
+
think_time=resolve("think_time"),
|
|
136
|
+
think_time_scale=resolve("think_time_scale"),
|
|
137
|
+
login_path=resolve("login_path"),
|
|
138
|
+
include_static=resolve("include_static"),
|
|
139
|
+
rps=resolve("rps"),
|
|
140
|
+
max_samples=resolve("max_samples"),
|
|
141
|
+
csrf=resolve("csrf"),
|
|
142
|
+
fail_on_error_rate=resolve("fail_on_error_rate"),
|
|
143
|
+
fail_on_p95=resolve("fail_on_p95"),
|
|
144
|
+
fail_on_p99=resolve("fail_on_p99"),
|
|
145
|
+
abort_on_fail=resolve("abort_on_fail"),
|
|
146
|
+
threshold_start_delay=resolve("threshold_start_delay"),
|
|
147
|
+
report_format=resolve("report_format"),
|
|
148
|
+
debug=resolve("debug"),
|
|
149
|
+
)
|