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 ADDED
@@ -0,0 +1,3 @@
1
+ """TruNFT - one platform for non-functional testing across Web and Mobile."""
2
+
3
+ __version__ = "0.1.0"
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())
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