trunft 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.
- trunft/__init__.py +3 -0
- trunft/api.py +197 -0
- trunft/cli.py +219 -0
- trunft/core/__init__.py +0 -0
- trunft/core/config.py +97 -0
- trunft/core/disruption.py +96 -0
- trunft/core/models.py +57 -0
- trunft/core/pillar.py +127 -0
- trunft/core/runner.py +79 -0
- trunft/core/thresholds.py +35 -0
- trunft/core/tooling.py +125 -0
- trunft/mcp_server.py +165 -0
- trunft/pillars/__init__.py +1 -0
- trunft/pillars/accessibility.py +78 -0
- trunft/pillars/chaos.py +93 -0
- trunft/pillars/compatibility.py +73 -0
- trunft/pillars/compliance.py +99 -0
- trunft/pillars/installability.py +85 -0
- trunft/pillars/performance.py +88 -0
- trunft/pillars/reliability.py +57 -0
- trunft/pillars/security.py +96 -0
- trunft/reporting/__init__.py +91 -0
- trunft/scaffold.py +42 -0
- trunft/setup_tools.py +115 -0
- trunft/templates/__init__.py +0 -0
- trunft/templates/project/chaos/experiments/api-pod-kill.json.example +31 -0
- trunft/templates/project/config/environments/dev.yaml +23 -0
- trunft/templates/project/config/environments/staging.yaml +19 -0
- trunft/templates/project/config/trunft.yaml +161 -0
- trunft/templates/project/docker/docker-compose.yml +23 -0
- trunft/templates/project/reports/.gitkeep +0 -0
- trunft/templates/project/workloads/k6/scenario.js +42 -0
- trunft-0.1.0.dist-info/METADATA +237 -0
- trunft-0.1.0.dist-info/RECORD +38 -0
- trunft-0.1.0.dist-info/WHEEL +5 -0
- trunft-0.1.0.dist-info/entry_points.txt +3 -0
- trunft-0.1.0.dist-info/licenses/LICENSE +21 -0
- trunft-0.1.0.dist-info/top_level.txt +1 -0
trunft/__init__.py
ADDED
trunft/api.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Service layer shared by the CLI and the MCP server.
|
|
2
|
+
|
|
3
|
+
Everything returns plain JSON-serialisable dicts so either interface can render it.
|
|
4
|
+
Long runs (soak tests last hours) execute in background threads via ``RunManager``.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import importlib.util
|
|
9
|
+
import json
|
|
10
|
+
import threading
|
|
11
|
+
import uuid
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from trunft import __version__, reporting
|
|
17
|
+
from trunft.core.config import Config, load_config
|
|
18
|
+
from trunft.core.models import NFTResult, Platform
|
|
19
|
+
from trunft.core.pillar import all_pillars
|
|
20
|
+
from trunft.core.runner import PlanItem, build_plan, execute, new_run_dir, reports_root
|
|
21
|
+
from trunft.core.tooling import KNOWN_TOOLS, which
|
|
22
|
+
|
|
23
|
+
_SECRET_HINTS = ("token", "password", "secret", "authorization", "apikey", "api_key", "cookie")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# --- discovery -------------------------------------------------------------------
|
|
27
|
+
def capabilities() -> dict[str, Any]:
|
|
28
|
+
"""Pillars, their test types, descriptions and per-platform applicability."""
|
|
29
|
+
return {
|
|
30
|
+
"version": __version__,
|
|
31
|
+
"platforms": [p.value for p in Platform],
|
|
32
|
+
"pillars": [
|
|
33
|
+
{
|
|
34
|
+
"name": p.name,
|
|
35
|
+
"title": p.title,
|
|
36
|
+
"test_types": [
|
|
37
|
+
{"name": t, "description": d, "required_tools": p.tools.get(t, []),
|
|
38
|
+
"platforms": [pl.value for pl in Platform if p.supports(pl, t)]}
|
|
39
|
+
for t, d in p.test_types.items()
|
|
40
|
+
],
|
|
41
|
+
}
|
|
42
|
+
for p in all_pillars().values()
|
|
43
|
+
],
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def doctor() -> dict[str, Any]:
|
|
48
|
+
tools = {t: {"available": bool(which(t)), "purpose": why} for t, why in KNOWN_TOOLS.items()}
|
|
49
|
+
tools["playwright"] = {"available": importlib.util.find_spec("playwright") is not None,
|
|
50
|
+
"purpose": "Compatibility: browser/device matrix (pip install 'trunft[web]')"}
|
|
51
|
+
return {"tools": tools}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def environments(root: Path) -> list[str]:
|
|
55
|
+
return sorted(p.stem for p in (Path(root) / "config" / "environments").glob("*.yaml"))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _redact(node: Any) -> Any:
|
|
59
|
+
if isinstance(node, dict):
|
|
60
|
+
return {k: ("***" if any(h in str(k).lower() for h in _SECRET_HINTS) and v else _redact(v))
|
|
61
|
+
for k, v in node.items()}
|
|
62
|
+
if isinstance(node, list):
|
|
63
|
+
return [_redact(v) for v in node]
|
|
64
|
+
return node
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def effective_config(root: Path, environment: str | None = None) -> dict[str, Any]:
|
|
68
|
+
"""Merged config for an environment, with secret-looking values redacted."""
|
|
69
|
+
cfg = load_config(root, environment)
|
|
70
|
+
return {"environment": cfg.environment, "config": _redact(cfg.data)}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# --- planning --------------------------------------------------------------------
|
|
74
|
+
def _plan(cfg: Config, platforms, pillars, test_types) -> list[PlanItem]:
|
|
75
|
+
return build_plan(cfg, [Platform(p) for p in platforms] if platforms else None,
|
|
76
|
+
pillars or None, test_types or None)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def plan(root: Path, environment: str | None = None, platforms: list[str] | None = None,
|
|
80
|
+
pillars: list[str] | None = None, test_types: list[str] | None = None) -> dict[str, Any]:
|
|
81
|
+
cfg = load_config(root, environment)
|
|
82
|
+
items = _plan(cfg, platforms, pillars, test_types)
|
|
83
|
+
return {"environment": cfg.environment, "count": len(items),
|
|
84
|
+
"items": [{"platform": i.platform.value, "pillar": i.pillar.name, "test_type": i.test_type}
|
|
85
|
+
for i in items]}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# --- execution -------------------------------------------------------------------
|
|
89
|
+
@dataclass
|
|
90
|
+
class RunHandle:
|
|
91
|
+
run_id: str
|
|
92
|
+
environment: str
|
|
93
|
+
output_dir: Path
|
|
94
|
+
total: int
|
|
95
|
+
dry_run: bool
|
|
96
|
+
state: str = "running" # running | completed | cancelled | crashed
|
|
97
|
+
results: list[NFTResult] = field(default_factory=list)
|
|
98
|
+
error: str = ""
|
|
99
|
+
cancel_requested: bool = False
|
|
100
|
+
thread: threading.Thread | None = None
|
|
101
|
+
|
|
102
|
+
def snapshot(self, include_results: bool = True) -> dict[str, Any]:
|
|
103
|
+
failures = [r for r in self.results if r.is_failure]
|
|
104
|
+
data = {
|
|
105
|
+
"run_id": self.run_id, "state": self.state, "environment": self.environment,
|
|
106
|
+
"dry_run": self.dry_run, "output_dir": str(self.output_dir),
|
|
107
|
+
"progress": {"done": len(self.results), "total": self.total},
|
|
108
|
+
"summary": reporting.summarize(self.results), "has_failures": bool(failures),
|
|
109
|
+
}
|
|
110
|
+
if self.error:
|
|
111
|
+
data["error"] = self.error
|
|
112
|
+
if include_results:
|
|
113
|
+
data["results"] = [r.to_dict() for r in self.results]
|
|
114
|
+
return data
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class RunManager:
|
|
118
|
+
"""Starts runs in background threads and tracks them (in-process, per server)."""
|
|
119
|
+
|
|
120
|
+
def __init__(self) -> None:
|
|
121
|
+
self._runs: dict[str, RunHandle] = {}
|
|
122
|
+
self._lock = threading.Lock()
|
|
123
|
+
|
|
124
|
+
def start(self, root: Path, environment: str | None = None, platforms: list[str] | None = None,
|
|
125
|
+
pillars: list[str] | None = None, test_types: list[str] | None = None,
|
|
126
|
+
dry_run: bool = False) -> RunHandle:
|
|
127
|
+
cfg = load_config(root, environment)
|
|
128
|
+
items = _plan(cfg, platforms, pillars, test_types) # validate before spawning
|
|
129
|
+
out = new_run_dir(cfg)
|
|
130
|
+
run_id = f"{out.name}-{uuid.uuid4().hex[:6]}"
|
|
131
|
+
out = out.with_name(run_id)
|
|
132
|
+
handle = RunHandle(run_id, cfg.environment, out, len(items), dry_run)
|
|
133
|
+
|
|
134
|
+
def work() -> None:
|
|
135
|
+
try:
|
|
136
|
+
execute(cfg, items, dry_run=dry_run, out_dir=out, on_result=handle.results.append,
|
|
137
|
+
should_stop=lambda: handle.cancel_requested)
|
|
138
|
+
handle.state = "cancelled" if handle.cancel_requested else "completed"
|
|
139
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
140
|
+
handle.state, handle.error = "crashed", f"{type(exc).__name__}: {exc}"
|
|
141
|
+
|
|
142
|
+
handle.thread = threading.Thread(target=work, name=f"trunft-{run_id}", daemon=True)
|
|
143
|
+
with self._lock:
|
|
144
|
+
self._runs[run_id] = handle
|
|
145
|
+
handle.thread.start()
|
|
146
|
+
return handle
|
|
147
|
+
|
|
148
|
+
def get(self, run_id: str) -> RunHandle | None:
|
|
149
|
+
return self._runs.get(run_id)
|
|
150
|
+
|
|
151
|
+
def cancel(self, run_id: str) -> RunHandle | None:
|
|
152
|
+
handle = self._runs.get(run_id)
|
|
153
|
+
if handle and handle.state == "running":
|
|
154
|
+
handle.cancel_requested = True
|
|
155
|
+
return handle
|
|
156
|
+
|
|
157
|
+
def active(self) -> list[RunHandle]:
|
|
158
|
+
return [h for h in self._runs.values() if h.state == "running"]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def run_sync(root: Path, environment: str | None = None, platforms: list[str] | None = None,
|
|
162
|
+
pillars: list[str] | None = None, test_types: list[str] | None = None,
|
|
163
|
+
dry_run: bool = False, output_root: Path | None = None, on_result=None):
|
|
164
|
+
cfg = load_config(root, environment)
|
|
165
|
+
items = _plan(cfg, platforms, pillars, test_types)
|
|
166
|
+
results, out, reports = execute(cfg, items, output_root, dry_run=dry_run, on_result=on_result)
|
|
167
|
+
return cfg, items, results, out, reports
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# --- historical reports ----------------------------------------------------------
|
|
171
|
+
def list_reports(root: Path, limit: int = 20) -> list[dict[str, Any]]:
|
|
172
|
+
cfg = load_config(root)
|
|
173
|
+
base = reports_root(cfg)
|
|
174
|
+
runs = sorted((p for p in base.glob("*/results.json")), key=lambda p: p.parent.name, reverse=True)
|
|
175
|
+
out = []
|
|
176
|
+
for path in runs[:limit]:
|
|
177
|
+
try:
|
|
178
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
179
|
+
except (OSError, json.JSONDecodeError):
|
|
180
|
+
continue
|
|
181
|
+
out.append({"run": path.parent.name, "meta": data.get("meta"), "summary": data.get("summary")})
|
|
182
|
+
return out
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def read_report(root: Path, run: str, only_failures: bool = False) -> dict[str, Any]:
|
|
186
|
+
cfg = load_config(root)
|
|
187
|
+
base = reports_root(cfg).resolve()
|
|
188
|
+
path = (base / run / "results.json").resolve()
|
|
189
|
+
if base not in path.parents:
|
|
190
|
+
raise ValueError("run must be a report folder name, e.g. 20261105-161739-dev")
|
|
191
|
+
if not path.exists():
|
|
192
|
+
raise FileNotFoundError(f"No results.json for run '{run}'")
|
|
193
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
194
|
+
if only_failures:
|
|
195
|
+
data["results"] = [r for r in data["results"] if r["status"] in ("failed", "error")]
|
|
196
|
+
data["report_dir"] = str(path.parent)
|
|
197
|
+
return data
|
trunft/cli.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""TruNFT command line. Mirrors the MCP server: both sit on trunft.api.
|
|
2
|
+
|
|
3
|
+
trunft init [path] # create a project (config, workloads, ...)
|
|
4
|
+
trunft list [--json] # pillars x test types x platforms
|
|
5
|
+
trunft doctor [--json] # which external tools are installed
|
|
6
|
+
trunft setup [--tool k6] [--dry-run] # install missing tools
|
|
7
|
+
trunft plan -e staging --pillar performance # preview, executes nothing
|
|
8
|
+
trunft run -e staging -p web --pillar performance -t load
|
|
9
|
+
trunft run --dry-run
|
|
10
|
+
trunft reports # recent runs on disk
|
|
11
|
+
trunft report <run> [--failures] # one run's results
|
|
12
|
+
trunft mcp [--transport stdio|streamable-http] # start the MCP server
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from trunft import __version__, api
|
|
23
|
+
from trunft.core.config import ConfigError
|
|
24
|
+
from trunft.core.models import NFTResult, Platform, Status
|
|
25
|
+
|
|
26
|
+
_ICON = {Status.PASSED: "PASS", Status.FAILED: "FAIL", Status.ERROR: "ERR ",
|
|
27
|
+
Status.SKIPPED: "SKIP", Status.NOT_IMPLEMENTED: "TODO"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _dump(data: Any) -> int:
|
|
31
|
+
print(json.dumps(data, indent=2, default=str))
|
|
32
|
+
return 0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cmd_list(args: argparse.Namespace) -> int:
|
|
36
|
+
caps = api.capabilities()
|
|
37
|
+
if args.json:
|
|
38
|
+
return _dump(caps)
|
|
39
|
+
plats = caps["platforms"]
|
|
40
|
+
print(f"{'pillar / test type':42}" + "".join(f"{p:>16}" for p in plats))
|
|
41
|
+
for pillar in caps["pillars"]:
|
|
42
|
+
print(f"\n{pillar['title']} [{pillar['name']}]")
|
|
43
|
+
for t in pillar["test_types"]:
|
|
44
|
+
print(f" {t['name']:40}" + "".join(f"{('x' if p in t['platforms'] else '-'):>16}" for p in plats))
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
49
|
+
tools = api.doctor()["tools"]
|
|
50
|
+
if args.json:
|
|
51
|
+
return _dump(tools)
|
|
52
|
+
print("External tools (optional - tests needing a missing tool are SKIPPED):\n")
|
|
53
|
+
for name, info in tools.items():
|
|
54
|
+
print(f" [{'ok' if info['available'] else '--'}] {name:10} {info['purpose']}")
|
|
55
|
+
from trunft.setup_tools import OS, RECIPES
|
|
56
|
+
|
|
57
|
+
installable = [r.tool for r in RECIPES if not tools.get(r.tool, {}).get("available", True) and r.steps.get(OS)]
|
|
58
|
+
if installable:
|
|
59
|
+
print(f"\nInstall missing tools ({', '.join(installable)}) with: trunft setup (preview: --dry-run)")
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
64
|
+
from trunft.scaffold import init_project
|
|
65
|
+
|
|
66
|
+
target = Path(args.path).resolve()
|
|
67
|
+
written, skipped = init_project(target, force=args.force)
|
|
68
|
+
print(f"TruNFT project at {target}\n")
|
|
69
|
+
for rel in written:
|
|
70
|
+
print(f" + {rel}")
|
|
71
|
+
for rel in skipped:
|
|
72
|
+
print(f" = {rel} (exists, kept - use --force to overwrite)")
|
|
73
|
+
print("\nNext: edit config/environments/*.yaml (target URLs), then `trunft doctor` and `trunft run --dry-run`.")
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def cmd_setup(args: argparse.Namespace) -> int:
|
|
78
|
+
from trunft.setup_tools import setup
|
|
79
|
+
|
|
80
|
+
print(f"TruNFT setup{' (dry run)' if args.dry_run else ''}\n")
|
|
81
|
+
outcome = setup(args.tool, dry_run=args.dry_run)
|
|
82
|
+
return 1 if "failed" in outcome.values() else 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def cmd_plan(args: argparse.Namespace) -> int:
|
|
86
|
+
data = api.plan(Path(args.root), args.env, args.platform, args.pillar, args.type)
|
|
87
|
+
if args.json:
|
|
88
|
+
return _dump(data)
|
|
89
|
+
print(f"env={data['environment']} | {data['count']} test(s)\n")
|
|
90
|
+
for i in data["items"]:
|
|
91
|
+
print(f" {i['platform']:15} {i['pillar']:14} {i['test_type']}")
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _print_result(r: NFTResult) -> None:
|
|
96
|
+
print(f" [{_ICON[r.status]}] {r.platform.value:15} {r.pillar:14} {r.test_type:20} {r.message}")
|
|
97
|
+
for b in r.threshold_breaches:
|
|
98
|
+
print(f" ! {b}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
102
|
+
print(f"TruNFT {__version__}{' | DRY RUN' if args.dry_run else ''}\n")
|
|
103
|
+
cfg, _, results, out, reports = api.run_sync(
|
|
104
|
+
Path(args.root), args.env, args.platform, args.pillar, args.type, dry_run=args.dry_run,
|
|
105
|
+
output_root=Path(args.output) if args.output else None, on_result=_print_result)
|
|
106
|
+
failures = [r for r in results if r.is_failure]
|
|
107
|
+
print(f"\nenv={cfg.environment} | reports: {out}")
|
|
108
|
+
for p in reports:
|
|
109
|
+
print(f" - {p.name}")
|
|
110
|
+
print(f"\n{len(results) - len(failures)}/{len(results)} without failures"
|
|
111
|
+
+ (f"; {len(failures)} FAILED/ERROR" if failures else ""))
|
|
112
|
+
return 1 if failures else 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def cmd_reports(args: argparse.Namespace) -> int:
|
|
116
|
+
runs = api.list_reports(Path(args.root), args.limit)
|
|
117
|
+
if args.json:
|
|
118
|
+
return _dump(runs)
|
|
119
|
+
if not runs:
|
|
120
|
+
print("No reports yet.")
|
|
121
|
+
for r in runs:
|
|
122
|
+
s = r["summary"] or {}
|
|
123
|
+
print(f" {r['run']:34} total={s.get('total', 0):<4} passed={s.get('passed', 0):<4} "
|
|
124
|
+
f"failed={s.get('failed', 0):<4} error={s.get('error', 0):<4} dry_run={(r['meta'] or {}).get('dry_run')}")
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def cmd_report(args: argparse.Namespace) -> int:
|
|
129
|
+
data = api.read_report(Path(args.root), args.run, args.failures)
|
|
130
|
+
if args.json:
|
|
131
|
+
return _dump(data)
|
|
132
|
+
print(f"{args.run} | {data['summary']}\n{data['report_dir']}\n")
|
|
133
|
+
for r in data["results"]:
|
|
134
|
+
print(f" [{r['status']:>15}] {r['platform']:15} {r['pillar']:14} {r['test_type']:20} {r['message']}")
|
|
135
|
+
for b in r["threshold_breaches"]:
|
|
136
|
+
print(f" ! {b}")
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def cmd_mcp(args: argparse.Namespace) -> int:
|
|
141
|
+
try:
|
|
142
|
+
from trunft import mcp_server
|
|
143
|
+
except ImportError:
|
|
144
|
+
print("error: MCP support not installed - pip install -e \".[mcp]\"", file=sys.stderr)
|
|
145
|
+
return 2
|
|
146
|
+
mcp_server.serve(args.root, args.transport, args.host, args.port)
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _filters(p: argparse.ArgumentParser) -> None:
|
|
151
|
+
p.add_argument("-e", "--env", help="environment (config/environments/<env>.yaml)")
|
|
152
|
+
p.add_argument("-p", "--platform", action="append", choices=[pl.value for pl in Platform])
|
|
153
|
+
p.add_argument("--pillar", action="append", help="performance, security, reliability, chaos, ...")
|
|
154
|
+
p.add_argument("-t", "--type", action="append", help="test type, e.g. load, spike, access_control")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
158
|
+
parser = argparse.ArgumentParser(prog="trunft", description="TruNFT - unified non-functional testing")
|
|
159
|
+
parser.add_argument("--version", action="version", version=f"trunft {__version__}")
|
|
160
|
+
parser.add_argument("--root", default=".", help="project root containing config/ (default: cwd)")
|
|
161
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
162
|
+
|
|
163
|
+
for name, fn, help_ in (("list", cmd_list, "show pillars, test types and platform coverage"),
|
|
164
|
+
("doctor", cmd_doctor, "check external tool availability")):
|
|
165
|
+
p = sub.add_parser(name, help=help_)
|
|
166
|
+
p.add_argument("--json", action="store_true")
|
|
167
|
+
p.set_defaults(fn=fn)
|
|
168
|
+
|
|
169
|
+
p = sub.add_parser("init", help="create a TruNFT project (config, k6 workload, chaos, docker)")
|
|
170
|
+
p.add_argument("path", nargs="?", default=".", help="directory to create the project in (default: cwd)")
|
|
171
|
+
p.add_argument("--force", action="store_true", help="overwrite existing files")
|
|
172
|
+
p.set_defaults(fn=cmd_init)
|
|
173
|
+
|
|
174
|
+
p = sub.add_parser("setup", help="install missing external tools (winget/brew/npm/pip)")
|
|
175
|
+
p.add_argument("--tool", action="append", help="only these tools, e.g. --tool k6 --tool adb")
|
|
176
|
+
p.add_argument("--dry-run", action="store_true", help="show what would be installed")
|
|
177
|
+
p.set_defaults(fn=cmd_setup)
|
|
178
|
+
|
|
179
|
+
p = sub.add_parser("plan", help="preview what a run would execute")
|
|
180
|
+
_filters(p)
|
|
181
|
+
p.add_argument("--json", action="store_true")
|
|
182
|
+
p.set_defaults(fn=cmd_plan)
|
|
183
|
+
|
|
184
|
+
p = sub.add_parser("run", help="execute non-functional tests")
|
|
185
|
+
_filters(p)
|
|
186
|
+
p.add_argument("-o", "--output", help="report output root (default: reports/)")
|
|
187
|
+
p.add_argument("--dry-run", action="store_true", help="plan and report without executing")
|
|
188
|
+
p.set_defaults(fn=cmd_run)
|
|
189
|
+
|
|
190
|
+
p = sub.add_parser("reports", help="list recent runs")
|
|
191
|
+
p.add_argument("-n", "--limit", type=int, default=20)
|
|
192
|
+
p.add_argument("--json", action="store_true")
|
|
193
|
+
p.set_defaults(fn=cmd_reports)
|
|
194
|
+
|
|
195
|
+
p = sub.add_parser("report", help="show one run's results")
|
|
196
|
+
p.add_argument("run", help="report folder name (see `trunft reports`)")
|
|
197
|
+
p.add_argument("--failures", action="store_true", help="only failed/error tests")
|
|
198
|
+
p.add_argument("--json", action="store_true")
|
|
199
|
+
p.set_defaults(fn=cmd_report)
|
|
200
|
+
|
|
201
|
+
p = sub.add_parser("mcp", help="start the TruNFT MCP server")
|
|
202
|
+
p.add_argument("--transport", choices=["stdio", "streamable-http"], default="stdio")
|
|
203
|
+
p.add_argument("--host", default="127.0.0.1")
|
|
204
|
+
p.add_argument("--port", type=int, default=8765)
|
|
205
|
+
p.set_defaults(fn=cmd_mcp)
|
|
206
|
+
return parser
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def main(argv: list[str] | None = None) -> int:
|
|
210
|
+
args = build_parser().parse_args(argv)
|
|
211
|
+
try:
|
|
212
|
+
return args.fn(args)
|
|
213
|
+
except (ConfigError, ValueError, FileNotFoundError) as exc:
|
|
214
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
215
|
+
return 2
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
if __name__ == "__main__":
|
|
219
|
+
sys.exit(main())
|
trunft/core/__init__.py
ADDED
|
File without changes
|
trunft/core/config.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Layered YAML configuration: config/trunft.yaml <- config/environments/<env>.yaml.
|
|
2
|
+
|
|
3
|
+
String values support ``${VAR}`` and ``${VAR:-default}`` interpolation from the
|
|
4
|
+
process environment so secrets (tokens, passwords) never live in the repo.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import copy
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
_VAR = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConfigError(Exception):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def interpolate(value: Any) -> Any:
|
|
25
|
+
if isinstance(value, str):
|
|
26
|
+
return _VAR.sub(lambda m: os.environ.get(m.group(1), m.group(2) or ""), value)
|
|
27
|
+
if isinstance(value, dict):
|
|
28
|
+
return {k: interpolate(v) for k, v in value.items()}
|
|
29
|
+
if isinstance(value, list):
|
|
30
|
+
return [interpolate(v) for v in value]
|
|
31
|
+
return value
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Paths whose dict values are replaced wholesale instead of merged ("*" = any key).
|
|
35
|
+
# k6 scenario options are executor-specific: merging a constant-vus override into a
|
|
36
|
+
# ramping-vus base would leave an invalid hybrid (e.g. `stages` on constant-vus).
|
|
37
|
+
REPLACE_PATHS: tuple[tuple[str, ...], ...] = (("pillars", "performance", "profiles", "*"),)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _replaces(path: tuple[str, ...]) -> bool:
|
|
41
|
+
return any(len(p) == len(path) and all(a in ("*", b) for a, b in zip(p, path)) for p in REPLACE_PATHS)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def deep_merge(base: dict, override: dict, _path: tuple[str, ...] = ()) -> dict:
|
|
45
|
+
out = copy.deepcopy(base)
|
|
46
|
+
for key, value in override.items():
|
|
47
|
+
path = (*_path, str(key))
|
|
48
|
+
if isinstance(value, dict) and isinstance(out.get(key), dict) and not _replaces(path):
|
|
49
|
+
out[key] = deep_merge(out[key], value, path)
|
|
50
|
+
else:
|
|
51
|
+
out[key] = copy.deepcopy(value)
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class Config:
|
|
57
|
+
data: dict[str, Any]
|
|
58
|
+
environment: str
|
|
59
|
+
root: Path
|
|
60
|
+
|
|
61
|
+
def get(self, dotted: str, default: Any = None) -> Any:
|
|
62
|
+
node: Any = self.data
|
|
63
|
+
for part in dotted.split("."):
|
|
64
|
+
if not isinstance(node, dict) or part not in node:
|
|
65
|
+
return default
|
|
66
|
+
node = node[part]
|
|
67
|
+
return node
|
|
68
|
+
|
|
69
|
+
def pillar(self, name: str) -> dict[str, Any]:
|
|
70
|
+
return self.get(f"pillars.{name}", {}) or {}
|
|
71
|
+
|
|
72
|
+
def thresholds(self, pillar: str, test_type: str) -> dict[str, Any]:
|
|
73
|
+
"""``thresholds.<pillar>.default`` overlaid with ``thresholds.<pillar>.<test_type>``."""
|
|
74
|
+
block = self.get(f"thresholds.{pillar}", {}) or {}
|
|
75
|
+
return {**(block.get("default") or {}), **(block.get(test_type) or {})}
|
|
76
|
+
|
|
77
|
+
def target(self, platform: str) -> dict[str, Any]:
|
|
78
|
+
return self.get(f"targets.{platform}", {}) or {}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _read_yaml(path: Path) -> dict:
|
|
82
|
+
try:
|
|
83
|
+
with path.open(encoding="utf-8") as fh:
|
|
84
|
+
return yaml.safe_load(fh) or {}
|
|
85
|
+
except FileNotFoundError as exc:
|
|
86
|
+
raise ConfigError(f"Config file not found: {path}") from exc
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def load_config(root: Path, environment: str | None = None) -> Config:
|
|
90
|
+
root = Path(root).resolve()
|
|
91
|
+
base = _read_yaml(root / "config" / "trunft.yaml")
|
|
92
|
+
env = environment or base.get("default_environment", "dev")
|
|
93
|
+
env_file = root / "config" / "environments" / f"{env}.yaml"
|
|
94
|
+
merged = deep_merge(base, _read_yaml(env_file)) if env_file.exists() else base
|
|
95
|
+
if not env_file.exists() and environment:
|
|
96
|
+
raise ConfigError(f"Unknown environment '{env}' (expected {env_file})")
|
|
97
|
+
return Config(data=interpolate(merged), environment=env, root=root)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Shared machinery for reliability and chaos tests.
|
|
2
|
+
|
|
3
|
+
An ``AvailabilityProbe`` polls a health URL in the background while a fault is
|
|
4
|
+
injected (service killed, failover triggered, latency added...). Afterwards we
|
|
5
|
+
know availability, latency percentiles, longest outage and time to recover.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from .thresholds import percentile
|
|
14
|
+
from .tooling import http_request, run_cmd
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class _Sample:
|
|
19
|
+
t: float
|
|
20
|
+
ok: bool
|
|
21
|
+
latency_ms: float
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AvailabilityProbe(threading.Thread):
|
|
25
|
+
def __init__(self, url: str, interval_s: float = 1.0, timeout_s: float = 5.0, headers: dict | None = None):
|
|
26
|
+
super().__init__(daemon=True)
|
|
27
|
+
self.url, self.interval_s, self.timeout_s, self.headers = url, interval_s, timeout_s, headers
|
|
28
|
+
self.samples: list[_Sample] = []
|
|
29
|
+
self._stop_evt = threading.Event()
|
|
30
|
+
|
|
31
|
+
def run(self) -> None:
|
|
32
|
+
while not self._stop_evt.is_set():
|
|
33
|
+
r = http_request(self.url, headers=self.headers, timeout=self.timeout_s)
|
|
34
|
+
self.samples.append(_Sample(time.monotonic(), r.ok, r.latency_ms))
|
|
35
|
+
self._stop_evt.wait(self.interval_s)
|
|
36
|
+
|
|
37
|
+
def stop(self) -> None:
|
|
38
|
+
self._stop_evt.set()
|
|
39
|
+
self.join(timeout=self.timeout_s + self.interval_s + 1)
|
|
40
|
+
|
|
41
|
+
def metrics(self) -> dict:
|
|
42
|
+
total = len(self.samples)
|
|
43
|
+
fails = sum(1 for s in self.samples if not s.ok)
|
|
44
|
+
ok_lat = [s.latency_ms for s in self.samples if s.ok]
|
|
45
|
+
longest, start = 0.0, None
|
|
46
|
+
for s in self.samples:
|
|
47
|
+
if not s.ok and start is None:
|
|
48
|
+
start = s.t
|
|
49
|
+
elif s.ok and start is not None:
|
|
50
|
+
longest, start = max(longest, s.t - start), None
|
|
51
|
+
if start is not None and self.samples:
|
|
52
|
+
longest = max(longest, self.samples[-1].t - start)
|
|
53
|
+
return {
|
|
54
|
+
"probes": total,
|
|
55
|
+
"failed_probes": fails,
|
|
56
|
+
"availability": round((total - fails) / total, 5) if total else 0.0,
|
|
57
|
+
"error_rate": round(fails / total, 5) if total else 1.0,
|
|
58
|
+
"p95_ms": round(percentile(ok_lat, 95) or 0, 1),
|
|
59
|
+
"max_outage_s": round(longest, 2),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def wait_until_healthy(url: str, timeout_s: float, interval_s: float = 1.0) -> float | None:
|
|
64
|
+
"""Seconds until ``url`` answered healthily, or None on timeout."""
|
|
65
|
+
start = time.monotonic()
|
|
66
|
+
while time.monotonic() - start < timeout_s:
|
|
67
|
+
if http_request(url, timeout=min(5.0, interval_s * 5)).ok:
|
|
68
|
+
return round(time.monotonic() - start, 2)
|
|
69
|
+
time.sleep(interval_s)
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def run_disruption(
|
|
74
|
+
health_url: str,
|
|
75
|
+
trigger: str,
|
|
76
|
+
restore: str | None = None,
|
|
77
|
+
observe_s: float = 60,
|
|
78
|
+
recovery_timeout_s: float = 300,
|
|
79
|
+
interval_s: float = 1.0,
|
|
80
|
+
log_dir=None,
|
|
81
|
+
) -> dict:
|
|
82
|
+
"""Probe ``health_url`` while running ``trigger``; measure impact and recovery."""
|
|
83
|
+
probe = AvailabilityProbe(health_url, interval_s=interval_s)
|
|
84
|
+
probe.start()
|
|
85
|
+
time.sleep(min(5, observe_s / 4)) # baseline before the fault
|
|
86
|
+
trig = run_cmd(trigger, log_file=log_dir / "trigger.log" if log_dir else None)
|
|
87
|
+
time.sleep(observe_s)
|
|
88
|
+
recovery = wait_until_healthy(health_url, recovery_timeout_s, interval_s)
|
|
89
|
+
probe.stop()
|
|
90
|
+
if restore:
|
|
91
|
+
run_cmd(restore, log_file=log_dir / "restore.log" if log_dir else None)
|
|
92
|
+
metrics = probe.metrics()
|
|
93
|
+
metrics["trigger_exit_code"] = trig.returncode
|
|
94
|
+
metrics["recovery_s"] = recovery if recovery is not None else float("inf")
|
|
95
|
+
metrics["recovered"] = recovery is not None
|
|
96
|
+
return metrics
|
trunft/core/models.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Platform(str, Enum):
|
|
9
|
+
WEB = "web"
|
|
10
|
+
MOBILE_NATIVE = "mobile-native"
|
|
11
|
+
MOBILE_BROWSER = "mobile-browser"
|
|
12
|
+
MOBILE_HYBRID = "mobile-hybrid"
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def all(cls) -> frozenset["Platform"]:
|
|
16
|
+
return frozenset(cls)
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def mobile_apps(cls) -> frozenset["Platform"]:
|
|
20
|
+
"""Platforms that ship an installable app binary."""
|
|
21
|
+
return frozenset({cls.MOBILE_NATIVE, cls.MOBILE_HYBRID})
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def browser_based(cls) -> frozenset["Platform"]:
|
|
25
|
+
"""Platforms reachable through a URL in a browser."""
|
|
26
|
+
return frozenset({cls.WEB, cls.MOBILE_BROWSER})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Status(str, Enum):
|
|
30
|
+
PASSED = "passed"
|
|
31
|
+
FAILED = "failed" # ran, but breached a threshold / assertion
|
|
32
|
+
ERROR = "error" # could not complete (tool crashed, target down...)
|
|
33
|
+
SKIPPED = "skipped" # dry-run, not applicable, or missing tooling
|
|
34
|
+
NOT_IMPLEMENTED = "not_implemented" # placeholder awaiting implementation/config
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class NFTResult:
|
|
39
|
+
pillar: str
|
|
40
|
+
test_type: str
|
|
41
|
+
platform: Platform
|
|
42
|
+
status: Status
|
|
43
|
+
message: str = ""
|
|
44
|
+
duration_s: float = 0.0
|
|
45
|
+
metrics: dict[str, Any] = field(default_factory=dict)
|
|
46
|
+
threshold_breaches: list[str] = field(default_factory=list)
|
|
47
|
+
artifacts: list[str] = field(default_factory=list)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def is_failure(self) -> bool:
|
|
51
|
+
return self.status in (Status.FAILED, Status.ERROR)
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> dict[str, Any]:
|
|
54
|
+
data = asdict(self)
|
|
55
|
+
data["platform"] = self.platform.value
|
|
56
|
+
data["status"] = self.status.value
|
|
57
|
+
return data
|