proofrun 0.1.1__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.
- proofrun/__init__.py +10 -0
- proofrun/__main__.py +9 -0
- proofrun/cli.py +325 -0
- proofrun/core/__init__.py +40 -0
- proofrun/core/compiler.py +136 -0
- proofrun/core/config.py +150 -0
- proofrun/core/context.py +185 -0
- proofrun/core/engine.py +231 -0
- proofrun/core/notifier.py +75 -0
- proofrun/core/provider.py +55 -0
- proofrun/core/state.py +81 -0
- proofrun-0.1.1.dist-info/METADATA +268 -0
- proofrun-0.1.1.dist-info/RECORD +16 -0
- proofrun-0.1.1.dist-info/WHEEL +4 -0
- proofrun-0.1.1.dist-info/entry_points.txt +2 -0
- proofrun-0.1.1.dist-info/licenses/LICENSE +21 -0
proofrun/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Proofrun synthetic API monitoring package."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("proofrun")
|
|
7
|
+
except PackageNotFoundError: # pragma: no cover
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
proofrun/__main__.py
ADDED
proofrun/cli.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Command Line Interface for Proofrun synthetic API monitoring daemon.
|
|
2
|
+
|
|
3
|
+
Provides terminal rendering and workflow orchestration for running synthetic
|
|
4
|
+
monitoring scenarios using Typer and Rich.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import random
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Annotated
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
import typer
|
|
16
|
+
import uvloop
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.panel import Panel
|
|
19
|
+
from rich.table import Table
|
|
20
|
+
from rich.text import Text
|
|
21
|
+
|
|
22
|
+
from proofrun import __version__
|
|
23
|
+
from proofrun.core.engine import ScenarioResult, ScenarioRunner
|
|
24
|
+
from proofrun.core.notifier import (
|
|
25
|
+
NotificationEvent,
|
|
26
|
+
Notifier,
|
|
27
|
+
WebhookNotifier,
|
|
28
|
+
)
|
|
29
|
+
from proofrun.core.provider import ConfigProvider
|
|
30
|
+
from proofrun.core.state import StateManager, Status
|
|
31
|
+
|
|
32
|
+
app = typer.Typer(
|
|
33
|
+
name="proofrun",
|
|
34
|
+
help="Synthetic API monitoring as code daemon.",
|
|
35
|
+
add_completion=False,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
console = Console()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def version_callback(value: bool) -> None:
|
|
42
|
+
"""Print the Proofrun version and exit."""
|
|
43
|
+
if value:
|
|
44
|
+
console.print(
|
|
45
|
+
f"[bold cyan]Proofrun[/bold cyan] "
|
|
46
|
+
f"version [bold green]{__version__}[/bold green]"
|
|
47
|
+
)
|
|
48
|
+
raise typer.Exit(code=0)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def render_scenario_result(result: ScenarioResult) -> None:
|
|
52
|
+
"""Render a scenario result using Rich."""
|
|
53
|
+
status_style = "bold green" if result.passed else "bold red"
|
|
54
|
+
status_text = "PASSED" if result.passed else "FAILED"
|
|
55
|
+
|
|
56
|
+
table = Table(
|
|
57
|
+
title=f"Scenario: [bold white]{result.scenario_name}[/bold white]",
|
|
58
|
+
title_style="bold cyan",
|
|
59
|
+
show_header=True,
|
|
60
|
+
header_style="bold magenta",
|
|
61
|
+
expand=True,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
table.add_column("Step Name", style="dim", min_width=25)
|
|
65
|
+
table.add_column("Status", justify="center", width=10)
|
|
66
|
+
table.add_column("HTTP Code", justify="right", width=10)
|
|
67
|
+
table.add_column("Latency", justify="right", width=12)
|
|
68
|
+
table.add_column("Error Details", style="red", min_width=20)
|
|
69
|
+
|
|
70
|
+
for step in result.step_results:
|
|
71
|
+
step_status = (
|
|
72
|
+
"[bold green]PASS[/bold green]"
|
|
73
|
+
if step.passed
|
|
74
|
+
else "[bold red]FAIL[/bold red]"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
code_style = "green" if step.passed else "red"
|
|
78
|
+
code_str = f"[{code_style}]{step.status_code}[/{code_style}]"
|
|
79
|
+
latency_str = f"{step.latency_ms:.2f} ms"
|
|
80
|
+
error_str = step.error or "-"
|
|
81
|
+
|
|
82
|
+
table.add_row(
|
|
83
|
+
step.step_name,
|
|
84
|
+
step_status,
|
|
85
|
+
code_str,
|
|
86
|
+
latency_str,
|
|
87
|
+
error_str,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
console.print(table)
|
|
91
|
+
|
|
92
|
+
summary_text = Text()
|
|
93
|
+
summary_text.append("Status: ", style="bold")
|
|
94
|
+
summary_text.append(status_text, style=status_style)
|
|
95
|
+
summary_text.append(" Total Latency: ", style="bold")
|
|
96
|
+
summary_text.append(
|
|
97
|
+
f"{result.total_latency_ms:.2f} ms",
|
|
98
|
+
style="cyan",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
panel = Panel(
|
|
102
|
+
summary_text,
|
|
103
|
+
title="[bold]Summary[/bold]",
|
|
104
|
+
border_style="green" if result.passed else "red",
|
|
105
|
+
expand=False,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
console.print(panel)
|
|
109
|
+
console.print()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def execute_config(
|
|
113
|
+
config_provider: ConfigProvider,
|
|
114
|
+
client: httpx.AsyncClient,
|
|
115
|
+
state_manager: StateManager | None = None,
|
|
116
|
+
notifier: Notifier | None = None,
|
|
117
|
+
) -> tuple[bool, int]:
|
|
118
|
+
"""Load and execute all scenarios concurrently.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
config_provider: Cached configuration provider.
|
|
122
|
+
client: Reusable HTTP client for connection pooling.
|
|
123
|
+
state_manager: Tracks historical health across execution cycles.
|
|
124
|
+
notifier: Optional notification destination for state transitions.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
A tuple containing (all_passed, smallest_configured_interval).
|
|
128
|
+
"""
|
|
129
|
+
proofrun_config, config_error = config_provider.load()
|
|
130
|
+
|
|
131
|
+
if config_error is not None:
|
|
132
|
+
console.print(f"[bold red]Configuration Error:[/bold red] {config_error}")
|
|
133
|
+
|
|
134
|
+
if proofrun_config is None:
|
|
135
|
+
return False, 60
|
|
136
|
+
|
|
137
|
+
console.print("[yellow]Using last known-good configuration.[/yellow]")
|
|
138
|
+
|
|
139
|
+
if proofrun_config is None:
|
|
140
|
+
console.print("[bold red]No valid configuration is available.[/bold red]")
|
|
141
|
+
return False, 60
|
|
142
|
+
|
|
143
|
+
if state_manager is not None:
|
|
144
|
+
active_scenario_names = {s.name for s in proofrun_config.scenarios}
|
|
145
|
+
state_manager.sync(active_scenario_names)
|
|
146
|
+
|
|
147
|
+
runners = [ScenarioRunner(scenario) for scenario in proofrun_config.scenarios]
|
|
148
|
+
tasks = [runner.run(client) for runner in runners]
|
|
149
|
+
|
|
150
|
+
results = await asyncio.gather(*tasks)
|
|
151
|
+
|
|
152
|
+
all_passed = True
|
|
153
|
+
scenario_intervals: list[int] = []
|
|
154
|
+
|
|
155
|
+
for result, scenario in zip(
|
|
156
|
+
results,
|
|
157
|
+
proofrun_config.scenarios,
|
|
158
|
+
strict=True,
|
|
159
|
+
):
|
|
160
|
+
scenario_intervals.append(scenario.interval_seconds)
|
|
161
|
+
|
|
162
|
+
render_scenario_result(result)
|
|
163
|
+
|
|
164
|
+
if not result.passed:
|
|
165
|
+
all_passed = False
|
|
166
|
+
|
|
167
|
+
if state_manager is None:
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
transition = state_manager.update(
|
|
171
|
+
scenario.name,
|
|
172
|
+
result.passed,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if notifier is None or not transition.changed:
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
if transition.new_status == Status.FAILING:
|
|
179
|
+
errors = [
|
|
180
|
+
f"- {step.step_name}: {step.error}"
|
|
181
|
+
for step in result.step_results
|
|
182
|
+
if not step.passed
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
event = NotificationEvent(
|
|
186
|
+
scenario_name=transition.scenario_name,
|
|
187
|
+
old_status=transition.old_status,
|
|
188
|
+
new_status=transition.new_status,
|
|
189
|
+
details="\n".join(errors),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
await notifier.notify(event)
|
|
193
|
+
|
|
194
|
+
elif (
|
|
195
|
+
transition.old_status == Status.FAILING
|
|
196
|
+
and transition.new_status == Status.HEALTHY
|
|
197
|
+
):
|
|
198
|
+
event = NotificationEvent(
|
|
199
|
+
scenario_name=transition.scenario_name,
|
|
200
|
+
old_status=transition.old_status,
|
|
201
|
+
new_status=transition.new_status,
|
|
202
|
+
details="All steps passing.",
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
await notifier.notify(event)
|
|
206
|
+
|
|
207
|
+
interval = min(scenario_intervals) if scenario_intervals else 60
|
|
208
|
+
|
|
209
|
+
return all_passed, interval
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@app.command()
|
|
213
|
+
def run_command(
|
|
214
|
+
config_path: Annotated[
|
|
215
|
+
Path,
|
|
216
|
+
typer.Argument(
|
|
217
|
+
exists=True,
|
|
218
|
+
file_okay=True,
|
|
219
|
+
dir_okay=False,
|
|
220
|
+
readable=True,
|
|
221
|
+
help="Path to YAML scenario configuration file.",
|
|
222
|
+
),
|
|
223
|
+
],
|
|
224
|
+
once: Annotated[
|
|
225
|
+
bool,
|
|
226
|
+
typer.Option(
|
|
227
|
+
"--once/--daemon",
|
|
228
|
+
help="Run scenarios once and exit, or loop continuously in daemon mode.",
|
|
229
|
+
),
|
|
230
|
+
] = True,
|
|
231
|
+
interval: Annotated[
|
|
232
|
+
int | None,
|
|
233
|
+
typer.Option(
|
|
234
|
+
"--interval",
|
|
235
|
+
"-i",
|
|
236
|
+
min=1,
|
|
237
|
+
help="Override configured scenario interval in seconds for daemon mode.",
|
|
238
|
+
),
|
|
239
|
+
] = None,
|
|
240
|
+
webhook_url: Annotated[
|
|
241
|
+
str | None,
|
|
242
|
+
typer.Option(
|
|
243
|
+
"--webhook-url",
|
|
244
|
+
envvar="PROOF_WEBHOOK_URL",
|
|
245
|
+
help="HTTP webhook URL for state transition alerts.",
|
|
246
|
+
),
|
|
247
|
+
] = None,
|
|
248
|
+
_show_version: Annotated[
|
|
249
|
+
bool | None,
|
|
250
|
+
typer.Option(
|
|
251
|
+
"--version",
|
|
252
|
+
"-v",
|
|
253
|
+
help="Show Proofrun version and exit.",
|
|
254
|
+
callback=version_callback,
|
|
255
|
+
is_eager=True,
|
|
256
|
+
),
|
|
257
|
+
] = None,
|
|
258
|
+
) -> None:
|
|
259
|
+
"""Execute synthetic monitoring scenarios defined in YAML."""
|
|
260
|
+
console.print(
|
|
261
|
+
f"[bold cyan]Proofrun[/bold cyan] executing config: "
|
|
262
|
+
f"[bold yellow]{config_path}[/bold yellow]\n"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
async def _run_loop() -> int:
|
|
266
|
+
config_provider = ConfigProvider(config_path)
|
|
267
|
+
state_manager = StateManager()
|
|
268
|
+
|
|
269
|
+
async with httpx.AsyncClient() as client:
|
|
270
|
+
notifier: Notifier | None = None
|
|
271
|
+
|
|
272
|
+
if webhook_url is not None:
|
|
273
|
+
notifier = WebhookNotifier(
|
|
274
|
+
client=client,
|
|
275
|
+
webhook_url=webhook_url,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
if once:
|
|
279
|
+
passed, _ = await execute_config(
|
|
280
|
+
config_provider,
|
|
281
|
+
client,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
return 0 if passed else 1
|
|
285
|
+
|
|
286
|
+
console.print(
|
|
287
|
+
"[bold green]Starting daemon mode... "
|
|
288
|
+
"Press Ctrl+C to stop.[/bold green]\n"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
while True:
|
|
292
|
+
_, config_interval = await execute_config(
|
|
293
|
+
config_provider,
|
|
294
|
+
client,
|
|
295
|
+
state_manager,
|
|
296
|
+
notifier,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
base_sleep = interval if interval is not None else config_interval
|
|
300
|
+
|
|
301
|
+
sleep_time = base_sleep * random.uniform(
|
|
302
|
+
0.85,
|
|
303
|
+
1.15,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
console.print(
|
|
307
|
+
f"[dim]Sleeping for {sleep_time:.1f}s "
|
|
308
|
+
"(incl. jitter) before next execution cycle..."
|
|
309
|
+
"[/dim]\n"
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
await asyncio.sleep(sleep_time)
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
exit_code = uvloop.run(_run_loop())
|
|
316
|
+
except KeyboardInterrupt:
|
|
317
|
+
console.print("\n[yellow]Daemon execution stopped by user.[/yellow]")
|
|
318
|
+
raise typer.Exit(code=0) from None
|
|
319
|
+
|
|
320
|
+
raise typer.Exit(code=exit_code) # pragma: no cover
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def main() -> None:
|
|
324
|
+
"""Run the Proofrun CLI."""
|
|
325
|
+
app()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Proofrun core package initialization."""
|
|
2
|
+
|
|
3
|
+
from proofrun.core.compiler import ConfigCompilerError, load_and_compile_config
|
|
4
|
+
from proofrun.core.config import (
|
|
5
|
+
ExpectConfig,
|
|
6
|
+
GlobalConfig,
|
|
7
|
+
ProofrunConfig,
|
|
8
|
+
RequestConfig,
|
|
9
|
+
ScenarioConfig,
|
|
10
|
+
StepConfig,
|
|
11
|
+
)
|
|
12
|
+
from proofrun.core.context import ExecutionContext
|
|
13
|
+
from proofrun.core.engine import EngineError, ScenarioResult, ScenarioRunner, StepResult
|
|
14
|
+
from proofrun.core.notifier import NotificationEvent, Notifier, WebhookNotifier
|
|
15
|
+
from proofrun.core.provider import ConfigProvider
|
|
16
|
+
from proofrun.core.state import ScenarioState, StateManager, StateTransition, Status
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"ExpectConfig",
|
|
20
|
+
"GlobalConfig",
|
|
21
|
+
"ProofrunConfig",
|
|
22
|
+
"RequestConfig",
|
|
23
|
+
"ScenarioConfig",
|
|
24
|
+
"StepConfig",
|
|
25
|
+
"ConfigCompilerError",
|
|
26
|
+
"ConfigProvider",
|
|
27
|
+
"load_and_compile_config",
|
|
28
|
+
"ExecutionContext",
|
|
29
|
+
"EngineError",
|
|
30
|
+
"ScenarioResult",
|
|
31
|
+
"ScenarioRunner",
|
|
32
|
+
"StepResult",
|
|
33
|
+
"ScenarioState",
|
|
34
|
+
"StateManager",
|
|
35
|
+
"StateTransition",
|
|
36
|
+
"Status",
|
|
37
|
+
"NotificationEvent",
|
|
38
|
+
"Notifier",
|
|
39
|
+
"WebhookNotifier",
|
|
40
|
+
]
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""AOT Schema Compiler for Proofrun scenario configurations.
|
|
2
|
+
|
|
3
|
+
This module loads YAML configuration, validates it using Pydantic, resolves
|
|
4
|
+
global defaults, and pre-compiles JSON schemas into executable validators.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, cast
|
|
11
|
+
|
|
12
|
+
import fastjsonschema
|
|
13
|
+
import yaml
|
|
14
|
+
from pydantic import ValidationError
|
|
15
|
+
|
|
16
|
+
from proofrun.core.config import GlobalConfig, ProofrunConfig
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ConfigCompilerError(Exception):
|
|
20
|
+
"""Raised when configuration loading or compilation fails."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_and_compile_config(config_path: str | Path) -> ProofrunConfig:
|
|
24
|
+
"""Load, validate, resolve, and compile a Proofrun configuration.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
config_path: Path to the YAML configuration file.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
A validated and executable ProofrunConfig.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
ConfigCompilerError: If loading, validation, inheritance, or schema
|
|
34
|
+
compilation fails.
|
|
35
|
+
"""
|
|
36
|
+
path = Path(config_path).resolve()
|
|
37
|
+
|
|
38
|
+
if not path.exists():
|
|
39
|
+
raise ConfigCompilerError(f"Configuration file not found: {path}")
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
with path.open("r", encoding="utf-8") as file:
|
|
43
|
+
raw_data = yaml.safe_load(file)
|
|
44
|
+
except yaml.YAMLError as err:
|
|
45
|
+
raise ConfigCompilerError(f"Invalid YAML syntax in {path}: {err}") from err
|
|
46
|
+
except OSError as err:
|
|
47
|
+
raise ConfigCompilerError(
|
|
48
|
+
f"Failed to read configuration file {path}: {err}"
|
|
49
|
+
) from err
|
|
50
|
+
|
|
51
|
+
if not isinstance(raw_data, dict):
|
|
52
|
+
raise ConfigCompilerError(
|
|
53
|
+
f"Invalid format in {path}. Expected a top-level YAML mapping."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
config = ProofrunConfig.model_validate(raw_data)
|
|
58
|
+
except ValidationError as err:
|
|
59
|
+
raise ConfigCompilerError(f"Config validation failed:\n{err}") from err
|
|
60
|
+
|
|
61
|
+
global_config = config.global_config or GlobalConfig()
|
|
62
|
+
|
|
63
|
+
global_base_url = global_config.base_url
|
|
64
|
+
global_timeout = global_config.timeout_seconds
|
|
65
|
+
global_headers = global_config.headers
|
|
66
|
+
|
|
67
|
+
for scenario in config.scenarios:
|
|
68
|
+
if scenario.base_url is None:
|
|
69
|
+
if global_base_url is None:
|
|
70
|
+
raise ConfigCompilerError(
|
|
71
|
+
f"Scenario '{scenario.name}' is missing 'base_url' "
|
|
72
|
+
"and no global 'base_url' is defined."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
scenario.base_url = global_base_url
|
|
76
|
+
|
|
77
|
+
for step in scenario.steps:
|
|
78
|
+
if step.request.timeout is None:
|
|
79
|
+
step.request.timeout = global_timeout
|
|
80
|
+
|
|
81
|
+
step.request.headers = {
|
|
82
|
+
**global_headers,
|
|
83
|
+
**step.request.headers,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if step.expect.schema_data is None:
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
raw_schema: dict[str, Any] | None = None
|
|
90
|
+
|
|
91
|
+
if isinstance(step.expect.schema_data, dict):
|
|
92
|
+
raw_schema = step.expect.schema_data
|
|
93
|
+
|
|
94
|
+
elif isinstance(step.expect.schema_data, str):
|
|
95
|
+
schema_file = path.parent / step.expect.schema_data
|
|
96
|
+
|
|
97
|
+
if not schema_file.exists():
|
|
98
|
+
schema_file = Path(step.expect.schema_data)
|
|
99
|
+
|
|
100
|
+
if not schema_file.exists():
|
|
101
|
+
raise ConfigCompilerError(
|
|
102
|
+
f"JSON Schema file not found: "
|
|
103
|
+
f"'{step.expect.schema_data}' "
|
|
104
|
+
f"(referenced in scenario '{scenario.name}', "
|
|
105
|
+
f"step '{step.name}')"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
with schema_file.open("r", encoding="utf-8") as file:
|
|
110
|
+
raw_schema = json.load(file)
|
|
111
|
+
except (OSError, json.JSONDecodeError) as err:
|
|
112
|
+
raise ConfigCompilerError(
|
|
113
|
+
f"Failed to read JSON schema file "
|
|
114
|
+
f"'{step.expect.schema_data}': {err}"
|
|
115
|
+
) from err
|
|
116
|
+
|
|
117
|
+
if raw_schema is None: # pragma: no cover
|
|
118
|
+
raise ConfigCompilerError(
|
|
119
|
+
f"Invalid schema configuration in scenario "
|
|
120
|
+
f"'{scenario.name}', step '{step.name}'."
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
compiled = fastjsonschema.compile(raw_schema)
|
|
125
|
+
step.expect._compiled_schema = cast(
|
|
126
|
+
Callable[[Any], Any],
|
|
127
|
+
compiled,
|
|
128
|
+
)
|
|
129
|
+
except Exception as err:
|
|
130
|
+
raise ConfigCompilerError(
|
|
131
|
+
f"Failed to compile JSON schema in "
|
|
132
|
+
f"scenario '{scenario.name}', "
|
|
133
|
+
f"step '{step.name}': {err}"
|
|
134
|
+
) from err
|
|
135
|
+
|
|
136
|
+
return config
|
proofrun/core/config.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Declarative configuration models for Proofrun synthetic monitoring scenarios.
|
|
2
|
+
|
|
3
|
+
This module defines the Pydantic schemas used to parse, validate, and structure
|
|
4
|
+
YAML monitoring scenarios, including global settings, request parameters,
|
|
5
|
+
expectations, schema validations, and extraction rules.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Any, Literal
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field, HttpUrl, PrivateAttr
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GlobalConfig(BaseModel):
|
|
17
|
+
"""Global default settings applied across all scenarios and steps."""
|
|
18
|
+
|
|
19
|
+
base_url: HttpUrl | None = Field(
|
|
20
|
+
default=None,
|
|
21
|
+
description="Default root URL target for scenarios.",
|
|
22
|
+
)
|
|
23
|
+
timeout_seconds: float = Field(
|
|
24
|
+
default=10.0,
|
|
25
|
+
gt=0,
|
|
26
|
+
description="Default HTTP request timeout ceiling in seconds.",
|
|
27
|
+
)
|
|
28
|
+
headers: dict[str, str] = Field(
|
|
29
|
+
default_factory=dict,
|
|
30
|
+
description="Default HTTP headers sent with all requests.",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RequestConfig(BaseModel):
|
|
35
|
+
"""Defines the parameters for an outgoing HTTP request."""
|
|
36
|
+
|
|
37
|
+
method: Literal["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] = Field(
|
|
38
|
+
...,
|
|
39
|
+
description="HTTP method.",
|
|
40
|
+
)
|
|
41
|
+
path: str = Field(
|
|
42
|
+
...,
|
|
43
|
+
description="Endpoint path relative to base_url.",
|
|
44
|
+
)
|
|
45
|
+
headers: dict[str, str] = Field(
|
|
46
|
+
default_factory=dict,
|
|
47
|
+
description="Key-value pairs for HTTP request headers.",
|
|
48
|
+
)
|
|
49
|
+
params: dict[str, Any] = Field(
|
|
50
|
+
default_factory=dict,
|
|
51
|
+
description="URL query parameters.",
|
|
52
|
+
)
|
|
53
|
+
json_payload: Any = Field(
|
|
54
|
+
default=None,
|
|
55
|
+
alias="json",
|
|
56
|
+
description="JSON serializable body payload.",
|
|
57
|
+
)
|
|
58
|
+
timeout: float | None = Field(
|
|
59
|
+
default=None,
|
|
60
|
+
gt=0,
|
|
61
|
+
description="Step-specific HTTP request timeout ceiling in seconds.",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ExpectConfig(BaseModel):
|
|
66
|
+
"""Defines assertion criteria evaluated against the HTTP response."""
|
|
67
|
+
|
|
68
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
69
|
+
|
|
70
|
+
status: int | list[int] = Field(
|
|
71
|
+
...,
|
|
72
|
+
description="Expected HTTP status code(s).",
|
|
73
|
+
)
|
|
74
|
+
headers: dict[str, str] = Field(
|
|
75
|
+
default_factory=dict,
|
|
76
|
+
description="Expected HTTP headers and their exact values.",
|
|
77
|
+
)
|
|
78
|
+
max_latency_ms: float | None = Field(
|
|
79
|
+
default=None,
|
|
80
|
+
gt=0,
|
|
81
|
+
description="Maximum allowed response latency in milliseconds.",
|
|
82
|
+
)
|
|
83
|
+
schema_data: str | dict[str, Any] | None = Field(
|
|
84
|
+
default=None,
|
|
85
|
+
alias="schema",
|
|
86
|
+
description="Path to a JSON schema file or inline schema dictionary.",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
_compiled_schema: Callable[[Any], Any] | None = PrivateAttr(default=None)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class StepConfig(BaseModel):
|
|
93
|
+
"""Represents an atomic execution step in a multi-step API scenario."""
|
|
94
|
+
|
|
95
|
+
name: str = Field(
|
|
96
|
+
...,
|
|
97
|
+
description="Human-readable identifier for the scenario step.",
|
|
98
|
+
)
|
|
99
|
+
request: RequestConfig = Field(
|
|
100
|
+
...,
|
|
101
|
+
description="HTTP request specifications.",
|
|
102
|
+
)
|
|
103
|
+
expect: ExpectConfig = Field(
|
|
104
|
+
...,
|
|
105
|
+
description="Response assertion rules.",
|
|
106
|
+
)
|
|
107
|
+
extract: dict[str, str] = Field(
|
|
108
|
+
default_factory=dict,
|
|
109
|
+
description="Mapping of variable names to JSONPath expressions.",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class ScenarioConfig(BaseModel):
|
|
114
|
+
"""Defines a sequence of dependent execution steps against a target host."""
|
|
115
|
+
|
|
116
|
+
name: str = Field(
|
|
117
|
+
...,
|
|
118
|
+
description="Human-readable scenario suite name.",
|
|
119
|
+
)
|
|
120
|
+
base_url: HttpUrl | None = Field(
|
|
121
|
+
default=None,
|
|
122
|
+
description="Root URL target for all steps in this scenario.",
|
|
123
|
+
)
|
|
124
|
+
interval_seconds: int = Field(
|
|
125
|
+
default=60,
|
|
126
|
+
gt=0,
|
|
127
|
+
description="Execution interval in seconds for daemon mode.",
|
|
128
|
+
)
|
|
129
|
+
steps: list[StepConfig] = Field(
|
|
130
|
+
...,
|
|
131
|
+
description="Ordered sequence of steps executed within shared context memory.",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class ProofrunConfig(BaseModel):
|
|
136
|
+
"""Root configuration model representing a parsed Proofrun YAML file."""
|
|
137
|
+
|
|
138
|
+
version: Literal["1.0"] = Field(
|
|
139
|
+
...,
|
|
140
|
+
description="Configuration schema version. Currently only '1.0' is supported.",
|
|
141
|
+
)
|
|
142
|
+
global_config: GlobalConfig | None = Field(
|
|
143
|
+
default=None,
|
|
144
|
+
alias="global",
|
|
145
|
+
description="Global configuration settings applied across scenarios.",
|
|
146
|
+
)
|
|
147
|
+
scenarios: list[ScenarioConfig] = Field(
|
|
148
|
+
...,
|
|
149
|
+
description="List of scenario workflows defined in the configuration file.",
|
|
150
|
+
)
|