loaderup 0.1.0__py3-none-any.whl
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.
- agents/analyzer.py +78 -0
- agents/generator.py +110 -0
- agents/runner.py +40 -0
- loader/__init__.py +0 -0
- loader/demo_registry_target.py +25 -0
- loader/history.py +36 -0
- loader/main.py +150 -0
- loader/models.py +93 -0
- loader/pipeline.py +111 -0
- loader/settings.py +4 -0
- loader/store.py +43 -0
- loader/web/assets/app.js +773 -0
- loader/web/assets/styles.css +570 -0
- loader/web/index.html +16 -0
- loaderup/__init__.py +10 -0
- loaderup/autodiscovery.py +65 -0
- loaderup/cli.py +219 -0
- loaderup/collector.py +11 -0
- loaderup/decorators.py +34 -0
- loaderup/importer.py +120 -0
- loaderup/models.py +36 -0
- loaderup/registry.py +31 -0
- loaderup-0.1.0.dist-info/METADATA +78 -0
- loaderup-0.1.0.dist-info/RECORD +27 -0
- loaderup-0.1.0.dist-info/WHEEL +5 -0
- loaderup-0.1.0.dist-info/entry_points.txt +2 -0
- loaderup-0.1.0.dist-info/top_level.txt +3 -0
agents/analyzer.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any, Dict, Tuple
|
|
3
|
+
from loader.models import SummaryMetrics
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _metric_value(metrics: Dict[str, Any], metric_name: str, field: str):
|
|
7
|
+
metric = metrics.get(metric_name, {})
|
|
8
|
+
if not isinstance(metric, dict):
|
|
9
|
+
return None
|
|
10
|
+
return metric.get(field)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _checks_pass_rate(metrics: Dict[str, Any]) -> float | None:
|
|
14
|
+
checks = metrics.get("checks", {})
|
|
15
|
+
if not isinstance(checks, dict):
|
|
16
|
+
return None
|
|
17
|
+
|
|
18
|
+
# Some k6 outputs provide "value"
|
|
19
|
+
if "value" in checks:
|
|
20
|
+
return checks.get("value")
|
|
21
|
+
|
|
22
|
+
passes = checks.get("passes")
|
|
23
|
+
fails = checks.get("fails")
|
|
24
|
+
|
|
25
|
+
if isinstance(passes, (int, float)) and isinstance(fails, (int, float)):
|
|
26
|
+
total = passes + fails
|
|
27
|
+
if total > 0:
|
|
28
|
+
return passes / total
|
|
29
|
+
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _http_req_failed_rate(metrics: Dict[str, Any]) -> float | None:
|
|
34
|
+
failed = metrics.get("http_req_failed", {})
|
|
35
|
+
if not isinstance(failed, dict):
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
# Best source in k6 summary output
|
|
39
|
+
value = failed.get("value")
|
|
40
|
+
if isinstance(value, (int, float)):
|
|
41
|
+
return float(value)
|
|
42
|
+
|
|
43
|
+
# Some formats may expose "rate"
|
|
44
|
+
rate = failed.get("rate")
|
|
45
|
+
if isinstance(rate, (int, float)):
|
|
46
|
+
return float(rate)
|
|
47
|
+
|
|
48
|
+
# Fallback: in k6 this metric is a Rate metric, and "passes"
|
|
49
|
+
# usually means failed requests, while "fails" means non-failed requests.
|
|
50
|
+
passes = failed.get("passes")
|
|
51
|
+
fails = failed.get("fails")
|
|
52
|
+
|
|
53
|
+
if isinstance(passes, (int, float)) and isinstance(fails, (int, float)):
|
|
54
|
+
total = passes + fails
|
|
55
|
+
if total > 0:
|
|
56
|
+
return float(passes) / float(total)
|
|
57
|
+
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse_k6_summary(summary_path: str) -> Tuple[SummaryMetrics, Dict[str, Any]]:
|
|
62
|
+
with open(summary_path, "r", encoding="utf-8") as f:
|
|
63
|
+
data = json.load(f)
|
|
64
|
+
|
|
65
|
+
metrics = data.get("metrics", {})
|
|
66
|
+
|
|
67
|
+
parsed = SummaryMetrics(
|
|
68
|
+
total_requests=_metric_value(metrics, "http_reqs", "count"),
|
|
69
|
+
avg_http_req_duration_ms=_metric_value(metrics, "http_req_duration", "avg"),
|
|
70
|
+
med_http_req_duration_ms=_metric_value(metrics, "http_req_duration", "med"),
|
|
71
|
+
p90_http_req_duration_ms=_metric_value(metrics, "http_req_duration", "p(90)"),
|
|
72
|
+
p95_http_req_duration_ms=_metric_value(metrics, "http_req_duration", "p(95)"),
|
|
73
|
+
max_http_req_duration_ms=_metric_value(metrics, "http_req_duration", "max"),
|
|
74
|
+
http_req_failed_rate=_http_req_failed_rate(metrics),
|
|
75
|
+
checks_pass_rate=_checks_pass_rate(metrics),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return parsed, data
|
agents/generator.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import List
|
|
4
|
+
from loader.models import LoadTarget
|
|
5
|
+
from loader.settings import ARTIFACTS_DIR
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _js_string(value: str) -> str:
|
|
9
|
+
return json.dumps(value)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_k6_script(
|
|
13
|
+
base_url: str,
|
|
14
|
+
targets: List[LoadTarget],
|
|
15
|
+
vus: int,
|
|
16
|
+
duration_seconds: int,
|
|
17
|
+
) -> str:
|
|
18
|
+
target_entries = []
|
|
19
|
+
|
|
20
|
+
for target in targets:
|
|
21
|
+
entry = {
|
|
22
|
+
"name": target.name,
|
|
23
|
+
"method": target.method,
|
|
24
|
+
"path": target.path,
|
|
25
|
+
"headers": target.headers,
|
|
26
|
+
"payload": target.payload_example,
|
|
27
|
+
"expected_status": target.expected_status,
|
|
28
|
+
"weight": target.weight,
|
|
29
|
+
}
|
|
30
|
+
target_entries.append(entry)
|
|
31
|
+
|
|
32
|
+
targets_json = json.dumps(target_entries, indent=2)
|
|
33
|
+
|
|
34
|
+
script = f"""import http from 'k6/http';
|
|
35
|
+
import {{ check, sleep }} from 'k6';
|
|
36
|
+
|
|
37
|
+
export const options = {{
|
|
38
|
+
vus: {vus},
|
|
39
|
+
duration: '{duration_seconds}s',
|
|
40
|
+
}};
|
|
41
|
+
|
|
42
|
+
const BASE_URL = {_js_string(base_url.rstrip("/"))};
|
|
43
|
+
const TARGETS = {targets_json};
|
|
44
|
+
|
|
45
|
+
function weightedPick(targets) {{
|
|
46
|
+
const expanded = [];
|
|
47
|
+
for (const t of targets) {{
|
|
48
|
+
const weight = Math.max(1, Math.round(t.weight || 1));
|
|
49
|
+
for (let i = 0; i < weight; i++) {{
|
|
50
|
+
expanded.push(t);
|
|
51
|
+
}}
|
|
52
|
+
}}
|
|
53
|
+
const idx = Math.floor(Math.random() * expanded.length);
|
|
54
|
+
return expanded[idx];
|
|
55
|
+
}}
|
|
56
|
+
|
|
57
|
+
export default function () {{
|
|
58
|
+
const target = weightedPick(TARGETS);
|
|
59
|
+
const url = `${{BASE_URL}}${{target.path}}`;
|
|
60
|
+
const method = String(target.method || 'GET').toUpperCase();
|
|
61
|
+
const payloadMethods = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
62
|
+
|
|
63
|
+
let res;
|
|
64
|
+
|
|
65
|
+
if (method === 'GET') {{
|
|
66
|
+
res = http.get(url, {{
|
|
67
|
+
headers: target.headers || {{}},
|
|
68
|
+
tags: {{
|
|
69
|
+
target_name: target.name,
|
|
70
|
+
method,
|
|
71
|
+
path: target.path,
|
|
72
|
+
}},
|
|
73
|
+
}});
|
|
74
|
+
}} else if (payloadMethods.has(method)) {{
|
|
75
|
+
const payload = target.payload ? JSON.stringify(target.payload) : null;
|
|
76
|
+
const headers = {{ ...(target.headers || {{}}) }};
|
|
77
|
+
if (payload !== null && !headers['Content-Type'] && !headers['content-type']) {{
|
|
78
|
+
headers['Content-Type'] = 'application/json';
|
|
79
|
+
}}
|
|
80
|
+
|
|
81
|
+
res = http.request(method, url, payload, {{
|
|
82
|
+
headers,
|
|
83
|
+
tags: {{
|
|
84
|
+
target_name: target.name,
|
|
85
|
+
method,
|
|
86
|
+
path: target.path,
|
|
87
|
+
}},
|
|
88
|
+
}});
|
|
89
|
+
}} else {{
|
|
90
|
+
throw new Error(`Unsupported method: ${{method}}`);
|
|
91
|
+
}}
|
|
92
|
+
|
|
93
|
+
check(res, {{
|
|
94
|
+
[`status is expected`]: (r) => r.status === target.expected_status,
|
|
95
|
+
[`status is < 500`]: (r) => r.status < 500,
|
|
96
|
+
}});
|
|
97
|
+
|
|
98
|
+
sleep(1);
|
|
99
|
+
}}
|
|
100
|
+
"""
|
|
101
|
+
return script
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def save_k6_script(job_id: str, script_text: str) -> str:
|
|
105
|
+
out_dir = ARTIFACTS_DIR / job_id
|
|
106
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
|
|
108
|
+
script_path = out_dir / "script.js"
|
|
109
|
+
script_path.write_text(script_text, encoding="utf-8")
|
|
110
|
+
return str(script_path)
|
agents/runner.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from loader.settings import ARTIFACTS_DIR
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
async def run_k6(script_path: str, job_id: str) -> dict:
|
|
6
|
+
out_dir = ARTIFACTS_DIR / job_id
|
|
7
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
8
|
+
|
|
9
|
+
summary_path = out_dir / "summary.json"
|
|
10
|
+
|
|
11
|
+
process = await asyncio.create_subprocess_exec(
|
|
12
|
+
"k6",
|
|
13
|
+
"run",
|
|
14
|
+
"--summary-export",
|
|
15
|
+
str(summary_path),
|
|
16
|
+
script_path,
|
|
17
|
+
stdout=asyncio.subprocess.PIPE,
|
|
18
|
+
stderr=asyncio.subprocess.PIPE,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
stdout, stderr = await process.communicate()
|
|
22
|
+
|
|
23
|
+
stdout_text = stdout.decode(errors="replace")
|
|
24
|
+
stderr_text = stderr.decode(errors="replace")
|
|
25
|
+
|
|
26
|
+
if process.returncode != 0:
|
|
27
|
+
raise RuntimeError(
|
|
28
|
+
"k6 execution failed\n"
|
|
29
|
+
f"stdout:\n{stdout_text}\n"
|
|
30
|
+
f"stderr:\n{stderr_text}"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if not summary_path.exists():
|
|
34
|
+
raise RuntimeError("k6 completed but summary.json was not created")
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
"summary_path": str(summary_path),
|
|
38
|
+
"stdout": stdout_text,
|
|
39
|
+
"stderr": stderr_text,
|
|
40
|
+
}
|
loader/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# demo_registry_targets.py
|
|
2
|
+
|
|
3
|
+
from loaderup import load_target
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@load_target(
|
|
7
|
+
name="home",
|
|
8
|
+
method="GET",
|
|
9
|
+
path="/",
|
|
10
|
+
expected_status=200,
|
|
11
|
+
tags=["public", "homepage"],
|
|
12
|
+
)
|
|
13
|
+
def home():
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
@load_target(
|
|
17
|
+
name="home",
|
|
18
|
+
method="POST",
|
|
19
|
+
path="/gg",
|
|
20
|
+
expected_status=200,
|
|
21
|
+
tags=["public", "homepage"],
|
|
22
|
+
)
|
|
23
|
+
def gg():
|
|
24
|
+
pass
|
|
25
|
+
|
loader/history.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, Dict, List
|
|
4
|
+
|
|
5
|
+
from loader.models import Job
|
|
6
|
+
|
|
7
|
+
HISTORY_FILE = (
|
|
8
|
+
Path(__file__).resolve().parents[1] / "artifacts" / "history" / "runs.jsonl"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def persist_job(job: Job) -> None:
|
|
13
|
+
HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
with HISTORY_FILE.open("a", encoding="utf-8") as handle:
|
|
15
|
+
handle.write(json.dumps(job.model_dump(), ensure_ascii=True) + "\n")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def list_runs(limit: int = 20) -> List[Dict[str, Any]]:
|
|
19
|
+
if not HISTORY_FILE.exists():
|
|
20
|
+
return []
|
|
21
|
+
|
|
22
|
+
rows: List[Dict[str, Any]] = []
|
|
23
|
+
with HISTORY_FILE.open("r", encoding="utf-8") as handle:
|
|
24
|
+
for line in handle:
|
|
25
|
+
line = line.strip()
|
|
26
|
+
if not line:
|
|
27
|
+
continue
|
|
28
|
+
try:
|
|
29
|
+
rows.append(json.loads(line))
|
|
30
|
+
except json.JSONDecodeError:
|
|
31
|
+
continue
|
|
32
|
+
|
|
33
|
+
if limit < 1:
|
|
34
|
+
return []
|
|
35
|
+
|
|
36
|
+
return list(reversed(rows[-limit:]))
|
loader/main.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import shutil
|
|
6
|
+
import uuid
|
|
7
|
+
from fastapi import FastAPI, HTTPException
|
|
8
|
+
from fastapi.responses import FileResponse, StreamingResponse
|
|
9
|
+
from fastapi.staticfiles import StaticFiles
|
|
10
|
+
|
|
11
|
+
from loader.models import Job, RunTargetsRequest, RunConfig
|
|
12
|
+
from loader.history import list_runs
|
|
13
|
+
from loaderup.importer import import_target_modules
|
|
14
|
+
from loaderup.models import RunRegistryRequest
|
|
15
|
+
from loader.store import create_job, get_job, get_queue, list_jobs
|
|
16
|
+
from loader.pipeline import run_targets_pipeline
|
|
17
|
+
from loader import demo_registry_target
|
|
18
|
+
|
|
19
|
+
app = FastAPI(title="LoadAgent")
|
|
20
|
+
WEB_DIR = Path(__file__).resolve().parent / "web"
|
|
21
|
+
|
|
22
|
+
app.mount("/assets", StaticFiles(directory=str(WEB_DIR / "assets")), name="assets")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.get("/", include_in_schema=False)
|
|
26
|
+
async def dashboard():
|
|
27
|
+
return FileResponse(str(WEB_DIR / "index.html"))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.get("/health")
|
|
31
|
+
async def health():
|
|
32
|
+
return {"status": "ok", "k6_installed": shutil.which("k6") is not None}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.on_event("startup")
|
|
36
|
+
async def load_cli_target_modules():
|
|
37
|
+
modules_csv = os.getenv("LOADERUP_TARGET_MODULES", "").strip()
|
|
38
|
+
if not modules_csv:
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
modules = [name.strip() for name in modules_csv.split(",") if name.strip()]
|
|
42
|
+
if modules:
|
|
43
|
+
import_target_modules(modules)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.post("/run/targets")
|
|
47
|
+
async def run_targets(payload: RunTargetsRequest):
|
|
48
|
+
job_id = str(uuid.uuid4())
|
|
49
|
+
|
|
50
|
+
job = Job(job_id=job_id)
|
|
51
|
+
create_job(job)
|
|
52
|
+
|
|
53
|
+
config = RunConfig(
|
|
54
|
+
base_url=payload.base_url.rstrip("/"),
|
|
55
|
+
vus=payload.vus,
|
|
56
|
+
duration_seconds=payload.duration_seconds,
|
|
57
|
+
targets=payload.targets,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
asyncio.create_task(run_targets_pipeline(job_id, config))
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
"job_id": job_id,
|
|
64
|
+
"status": job.status,
|
|
65
|
+
"stream_url": f"/run/{job_id}/stream",
|
|
66
|
+
"result_url": f"/run/{job_id}/result",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.get("/run/{job_id}/result")
|
|
71
|
+
async def get_result(job_id: str):
|
|
72
|
+
job = get_job(job_id)
|
|
73
|
+
if not job:
|
|
74
|
+
raise HTTPException(status_code=404, detail="Job not found")
|
|
75
|
+
return job
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.get("/run/{job_id}/stream")
|
|
79
|
+
async def stream_run(job_id: str):
|
|
80
|
+
q = get_queue(job_id)
|
|
81
|
+
if not q:
|
|
82
|
+
raise HTTPException(status_code=404, detail="Job not found")
|
|
83
|
+
|
|
84
|
+
async def event_generator():
|
|
85
|
+
while True:
|
|
86
|
+
item = await q.get()
|
|
87
|
+
if item is None:
|
|
88
|
+
break
|
|
89
|
+
yield f"data: {json.dumps(item)}\n\n"
|
|
90
|
+
|
|
91
|
+
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@app.post("/run/registry")
|
|
95
|
+
async def run_registry(payload: RunRegistryRequest):
|
|
96
|
+
from loaderup import collect_load_targets
|
|
97
|
+
|
|
98
|
+
registry_targets = collect_load_targets()
|
|
99
|
+
if not registry_targets:
|
|
100
|
+
raise HTTPException(status_code=400, detail="No registered targets found")
|
|
101
|
+
|
|
102
|
+
job_id = str(uuid.uuid4())
|
|
103
|
+
|
|
104
|
+
job = Job(job_id=job_id)
|
|
105
|
+
create_job(job)
|
|
106
|
+
|
|
107
|
+
config = RunConfig(
|
|
108
|
+
base_url=payload.base_url.rstrip("/"),
|
|
109
|
+
vus=payload.vus,
|
|
110
|
+
duration_seconds=payload.duration_seconds,
|
|
111
|
+
targets=registry_targets,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
asyncio.create_task(run_targets_pipeline(job_id, config))
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
"job_id": job_id,
|
|
118
|
+
"status": job.status,
|
|
119
|
+
"registered_targets": len(registry_targets),
|
|
120
|
+
"stream_url": f"/run/{job_id}/stream",
|
|
121
|
+
"result_url": f"/run/{job_id}/result",
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@app.get("/registry/targets")
|
|
126
|
+
async def get_registry_targets():
|
|
127
|
+
from loaderup import collect_load_targets
|
|
128
|
+
|
|
129
|
+
targets = collect_load_targets()
|
|
130
|
+
return {
|
|
131
|
+
"count": len(targets),
|
|
132
|
+
"targets": [target.model_dump() for target in targets],
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@app.get("/runs/active")
|
|
137
|
+
async def get_active_runs(limit: int = 20):
|
|
138
|
+
jobs = list_jobs(limit=limit)
|
|
139
|
+
return {"runs": [job.model_dump() for job in jobs]}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@app.get("/runs/history")
|
|
143
|
+
async def get_run_history(limit: int = 20):
|
|
144
|
+
return {"runs": list_runs(limit=limit)}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
if __name__ == "__main__":
|
|
148
|
+
import uvicorn
|
|
149
|
+
|
|
150
|
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
loader/models.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import Optional, List, Dict, Any, Literal
|
|
3
|
+
from pydantic import BaseModel, Field, model_validator
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class JobStatus(str, Enum):
|
|
7
|
+
pending = "pending"
|
|
8
|
+
generating = "generating"
|
|
9
|
+
running = "running"
|
|
10
|
+
analyzing = "analyzing"
|
|
11
|
+
done = "done"
|
|
12
|
+
failed = "failed"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LoadTarget(BaseModel):
|
|
16
|
+
name: str
|
|
17
|
+
method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
|
|
18
|
+
path: str
|
|
19
|
+
headers: Dict[str, str] = Field(default_factory=dict)
|
|
20
|
+
payload_example: Optional[Dict[str, Any]] = None
|
|
21
|
+
weight: float = 1.0
|
|
22
|
+
tags: List[str] = Field(default_factory=list)
|
|
23
|
+
expected_status: int = 200
|
|
24
|
+
|
|
25
|
+
@model_validator(mode="after")
|
|
26
|
+
def validate_target(self):
|
|
27
|
+
if not self.path.startswith("/"):
|
|
28
|
+
raise ValueError("path must start with '/'")
|
|
29
|
+
return self
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RunTargetsRequest(BaseModel):
|
|
33
|
+
base_url: str
|
|
34
|
+
targets: List[LoadTarget]
|
|
35
|
+
vus: int = 10
|
|
36
|
+
duration_seconds: int = 30
|
|
37
|
+
|
|
38
|
+
@model_validator(mode="after")
|
|
39
|
+
def validate_request(self):
|
|
40
|
+
if not self.base_url.startswith(("http://", "https://")):
|
|
41
|
+
raise ValueError("base_url must start with http:// or https://")
|
|
42
|
+
if not self.targets:
|
|
43
|
+
raise ValueError("targets cannot be empty")
|
|
44
|
+
if self.vus < 1:
|
|
45
|
+
raise ValueError("vus must be >= 1")
|
|
46
|
+
if self.duration_seconds < 1:
|
|
47
|
+
raise ValueError("duration_seconds must be >= 1")
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class RunConfig(BaseModel):
|
|
52
|
+
base_url: str
|
|
53
|
+
vus: int
|
|
54
|
+
duration_seconds: int
|
|
55
|
+
targets: List[LoadTarget]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SummaryMetrics(BaseModel):
|
|
59
|
+
total_requests: Optional[int] = None
|
|
60
|
+
avg_http_req_duration_ms: Optional[float] = None
|
|
61
|
+
med_http_req_duration_ms: Optional[float] = None
|
|
62
|
+
p90_http_req_duration_ms: Optional[float] = None
|
|
63
|
+
p95_http_req_duration_ms: Optional[float] = None
|
|
64
|
+
max_http_req_duration_ms: Optional[float] = None
|
|
65
|
+
http_req_failed_rate: Optional[float] = None
|
|
66
|
+
checks_pass_rate: Optional[float] = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TargetMetric(BaseModel):
|
|
70
|
+
name: str
|
|
71
|
+
method: str
|
|
72
|
+
path: str
|
|
73
|
+
expected_status: int
|
|
74
|
+
note: Optional[str] = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class JobResult(BaseModel):
|
|
78
|
+
config: Optional[RunConfig] = None
|
|
79
|
+
k6_script_path: Optional[str] = None
|
|
80
|
+
k6_summary_path: Optional[str] = None
|
|
81
|
+
k6_stdout: Optional[str] = None
|
|
82
|
+
k6_stderr: Optional[str] = None
|
|
83
|
+
metrics: Optional[SummaryMetrics] = None
|
|
84
|
+
target_metrics: List[TargetMetric] = Field(default_factory=list)
|
|
85
|
+
raw_summary: Optional[Dict[str, Any]] = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Job(BaseModel):
|
|
89
|
+
job_id: str
|
|
90
|
+
status: JobStatus = JobStatus.pending
|
|
91
|
+
progress: List[str] = Field(default_factory=list)
|
|
92
|
+
error: Optional[str] = None
|
|
93
|
+
result: JobResult = Field(default_factory=JobResult)
|
loader/pipeline.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from loader.models import JobStatus, RunConfig, TargetMetric
|
|
2
|
+
from loader.history import persist_job
|
|
3
|
+
from loader.store import get_job, update_job, push_event, close_queue
|
|
4
|
+
from agents.generator import build_k6_script, save_k6_script
|
|
5
|
+
from agents.runner import run_k6
|
|
6
|
+
from agents.analyzer import parse_k6_summary
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def set_status(job, status: JobStatus, message: str):
|
|
10
|
+
job.status = status
|
|
11
|
+
job.progress.append(message)
|
|
12
|
+
update_job(job)
|
|
13
|
+
|
|
14
|
+
await push_event(
|
|
15
|
+
job.job_id, {"type": "progress", "status": status.value, "message": message}
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def run_targets_pipeline(job_id: str, config: RunConfig):
|
|
20
|
+
job = get_job(job_id)
|
|
21
|
+
if not job:
|
|
22
|
+
return
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
job.result.config = config
|
|
26
|
+
job.result.target_metrics = [
|
|
27
|
+
TargetMetric(
|
|
28
|
+
name=t.name,
|
|
29
|
+
method=t.method,
|
|
30
|
+
path=t.path,
|
|
31
|
+
expected_status=t.expected_status,
|
|
32
|
+
note="Phase 1 target registered",
|
|
33
|
+
)
|
|
34
|
+
for t in config.targets
|
|
35
|
+
]
|
|
36
|
+
update_job(job)
|
|
37
|
+
|
|
38
|
+
await push_event(
|
|
39
|
+
job.job_id,
|
|
40
|
+
{"type": "data", "stage": "config", "config": config.model_dump()},
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
await set_status(job, JobStatus.generating, "Generating real k6 script")
|
|
44
|
+
script_text = build_k6_script(
|
|
45
|
+
base_url=config.base_url,
|
|
46
|
+
targets=config.targets,
|
|
47
|
+
vus=config.vus,
|
|
48
|
+
duration_seconds=config.duration_seconds,
|
|
49
|
+
)
|
|
50
|
+
script_path = save_k6_script(job_id, script_text)
|
|
51
|
+
job.result.k6_script_path = script_path
|
|
52
|
+
update_job(job)
|
|
53
|
+
|
|
54
|
+
await push_event(
|
|
55
|
+
job.job_id,
|
|
56
|
+
{"type": "data", "stage": "generating", "script_path": script_path},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
await set_status(job, JobStatus.running, "Running k6 load test")
|
|
60
|
+
run_output = await run_k6(script_path=script_path, job_id=job_id)
|
|
61
|
+
|
|
62
|
+
job.result.k6_summary_path = run_output["summary_path"]
|
|
63
|
+
|
|
64
|
+
# only if you added these fields in models.py
|
|
65
|
+
if hasattr(job.result, "k6_stdout"):
|
|
66
|
+
job.result.k6_stdout = run_output["stdout"]
|
|
67
|
+
if hasattr(job.result, "k6_stderr"):
|
|
68
|
+
job.result.k6_stderr = run_output["stderr"]
|
|
69
|
+
|
|
70
|
+
update_job(job)
|
|
71
|
+
|
|
72
|
+
await push_event(
|
|
73
|
+
job.job_id,
|
|
74
|
+
{
|
|
75
|
+
"type": "data",
|
|
76
|
+
"stage": "running",
|
|
77
|
+
"summary_path": run_output["summary_path"],
|
|
78
|
+
},
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
await set_status(job, JobStatus.analyzing, "Parsing k6 summary")
|
|
82
|
+
metrics, raw_summary = parse_k6_summary(run_output["summary_path"])
|
|
83
|
+
job.result.metrics = metrics
|
|
84
|
+
job.result.raw_summary = raw_summary
|
|
85
|
+
update_job(job)
|
|
86
|
+
|
|
87
|
+
await push_event(
|
|
88
|
+
job.job_id,
|
|
89
|
+
{"type": "data", "stage": "analyzing", "metrics": metrics.model_dump()},
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
await set_status(job, JobStatus.done, "Run completed successfully")
|
|
93
|
+
|
|
94
|
+
await push_event(
|
|
95
|
+
job.job_id, {"type": "done", "result": job.result.model_dump()}
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
persist_job(job)
|
|
99
|
+
|
|
100
|
+
except Exception as e:
|
|
101
|
+
job.status = JobStatus.failed
|
|
102
|
+
job.error = str(e)
|
|
103
|
+
job.progress.append(f"Failed: {str(e)}")
|
|
104
|
+
update_job(job)
|
|
105
|
+
|
|
106
|
+
await push_event(job.job_id, {"type": "error", "message": str(e)})
|
|
107
|
+
|
|
108
|
+
persist_job(job)
|
|
109
|
+
|
|
110
|
+
finally:
|
|
111
|
+
await close_queue(job_id)
|
loader/settings.py
ADDED
loader/store.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Dict, List, Optional
|
|
3
|
+
|
|
4
|
+
from loader.models import Job
|
|
5
|
+
|
|
6
|
+
_jobs: Dict[str, Job] = {}
|
|
7
|
+
_queues: Dict[str, asyncio.Queue] = {}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_job(job: Job) -> None:
|
|
11
|
+
_jobs[job.job_id] = job
|
|
12
|
+
_queues[job.job_id] = asyncio.Queue()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_job(job_id: str) -> Optional[Job]:
|
|
16
|
+
return _jobs.get(job_id)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def update_job(job: Job) -> None:
|
|
20
|
+
_jobs[job.job_id] = job
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_queue(job_id: str) -> Optional[asyncio.Queue]:
|
|
24
|
+
return _queues.get(job_id)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def list_jobs(limit: int = 20) -> List[Job]:
|
|
28
|
+
jobs = list(_jobs.values())
|
|
29
|
+
if limit < 1:
|
|
30
|
+
return []
|
|
31
|
+
return jobs[-limit:][::-1]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def push_event(job_id: str, event: dict) -> None:
|
|
35
|
+
q = _queues.get(job_id)
|
|
36
|
+
if q:
|
|
37
|
+
await q.put(event)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def close_queue(job_id: str) -> None:
|
|
41
|
+
q = _queues.get(job_id)
|
|
42
|
+
if q:
|
|
43
|
+
await q.put(None)
|