1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
//! HTTP client configuration (P3c): `HttpConfig` and `TlsConfig` moved here
//! from `tropel-core` so the runtime publish set (and `tropel-http` itself)
//! stops resolving `tropel-core`. `tropel-core` re-exports these so engine
//! crates keep resolving `tropel_core::config::*` unchanged.
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use tropel_sdk::config::ExpectedStatus;
/// HTTP client configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct HttpConfig {
/// Expected response status codes/ranges that determine request success.
/// Used to drive the http_req_failed Rate metric.
/// Default: `["200-399"]` — 2xx and 3xx are success, everything else fails.
#[serde(default = "default_expected_statuses", alias = "expectedStatuses")]
pub expected_statuses: Vec<ExpectedStatus>,
/// Connection pool max idle connections.
pub max_idle_connections: usize,
/// Keep-alive duration.
pub keep_alive: Option<String>,
/// Timeout for idle connections.
pub idle_connection_timeout: Option<String>,
/// Global per-request timeout (k6 `timeout`), e.g. `"30s"`. Applied as
/// the client-level ceiling for every request; a per-request `timeout`
/// overrides it with a shorter value. `None` (default) uses the engine
/// default of 10 seconds. Bounds how long a hung server can stall a VU
/// (which in turn bounds the engine's VU-drain loop).
#[serde(default, alias = "requestTimeout")]
pub request_timeout: Option<String>,
/// Whether to enable HTTP/2.
pub http2: bool,
/// Number of HTTP/2 connection lanes (default 1). Each lane is an
/// independent reqwest::Client with its own connection pool. VUs are
/// assigned to lanes round-robin by vu_id % N. Spreading load across
/// N h2 connections hides per-connection server limits and parallelizes
/// the single-core frame demux. k6 cannot do this at all.
#[serde(default = "default_http2_connections", alias = "http2Connections")]
pub http2_connections: usize,
/// User-agent header value.
pub user_agent: String,
/// Whether to decompress response bodies.
pub decompress: bool,
/// Whether to discard response bodies entirely (don't store bytes).
/// Saves memory and bandwidth at the cost of not being able to inspect
/// response content in scripts.
#[serde(default)]
pub discard_response_bodies: bool,
/// Max redirects to follow.
pub max_redirects: u32,
/// Disable redirect following entirely (`--no-redirects`). When true the
/// 3xx response is returned as-is and no redirect hops are captured.
/// k6 always follows redirects; this flag lets Tropel opt out.
#[serde(default)]
pub no_redirects: bool,
/// Optional fixed ceiling for the latency histogram, in MILLISECONDS.
/// `None` (default) uses hdrhistogram auto-resize — no ceiling, so very
/// slow requests are recorded exactly instead of being clipped at 60 s.
/// Set this to bound memory for runs with pathological outliers.
#[serde(default, alias = "histogramMaxMs")]
pub histogram_max_ms: Option<u64>,
/// DNS cache TTL (k6 `dns.ttl`), e.g. `"5m"`, `"inf"`. `None` (default)
/// disables caching — every request resolves. `"0"` also disables it.
#[serde(default, alias = "dnsTtl")]
pub dns_ttl: Option<String>,
/// DNS address selection policy (k6 `dns.select`): `"first"`,
/// `"roundRobin"`, `"random"`. `None` (default) keeps all resolved
/// addresses in lookup order (reqwest's default behavior).
#[serde(default, alias = "dnsSelect")]
pub dns_select: Option<String>,
/// DNS address policy (k6 `dns.policy`): `"preferIPv4"`, `"preferIPv6"`,
/// `"onlyIPv4"`, `"onlyIPv6"`, `"any"`. `None` (default) keeps the
/// resolved address family order unchanged.
#[serde(default, alias = "dnsPolicy")]
pub dns_policy: Option<String>,
/// Close the connection after every request (k6 `noConnectionReuse`).
/// Disables connection pooling entirely — each request opens a fresh
/// connection, which trades latency for isolation.
#[serde(default, alias = "noConnectionReuse")]
pub no_connection_reuse: bool,
/// k6 `noVUConnectionReuse` parity. When true, forces a fresh client
/// (own connection pool) per VU. Default false: every VU shares one
/// pooled client via Arc clone, keeping connections warm and TLS
/// sessions reusable.
#[serde(default, alias = "noVUConnectionReuse")]
pub no_vu_connection_reuse: bool,
/// Global request-rate cap in requests/second (k6 `rps`). When set, the
/// whole run is paced so no more than this many requests start per second,
/// shared across all VUs. `None` (default) is unlimited.
#[serde(default)]
pub rps: Option<f64>,
/// Static hostname → IP mapping (k6 `hosts`), e.g.
/// `{"api.example.com": "127.0.0.1"}`. Lookups for these hosts are served
/// from the map without hitting DNS. Values may be comma-separated to
/// provide several addresses; keys may be wildcards (`"*.example.com"`).
#[serde(default)]
pub hosts: HashMap<String, String>,
/// IP addresses / CIDRs that requests may never connect to (k6
/// `blacklistIPs`), e.g. `["10.0.0.0/8", "192.168.1.5"]`. When every
/// resolved address is blacklisted the request fails with a clear error.
#[serde(default, alias = "blacklistIPs")]
pub blacklist_ips: Vec<String>,
/// Hard ceiling on the response body size in BYTES, enforced while the
/// body is streamed (final response AND redirect-hop bodies). `None`
/// (default) is unlimited — k6 semantics. Proxy-style consumers
/// (KnockPort relay) set this so a runaway upstream can't fill memory.
#[serde(default, alias = "maxResponseBytes")]
pub max_response_bytes: Option<u64>,
/// Log every HTTP request/response at debug level (method, URL, status,
/// timing). Off by default; enable with the `--http-debug` CLI flag.
/// k6's `--http-debug=full` also prints request/response bodies — that
/// extra mode is `http_debug_full`.
#[serde(default)]
/// Proxy configuration (ask 17). `ProxyMode::Off` by default, so every
/// existing config behaves exactly as before. The container's own
/// `#[serde(default)]` covers an absent key.
pub proxy: crate::proxy::ProxyConfig,
pub http_debug: bool,
/// `--http-debug=full` (k6 parity): also print the request/response
/// bodies, not just the head lines. Only meaningful with `http_debug`.
#[serde(default)]
pub http_debug_full: bool,
}
fn default_expected_statuses() -> Vec<ExpectedStatus> {
vec![ExpectedStatus::Range("200-399".to_string())]
}
fn default_http2_connections() -> usize {
1
}
impl Default for HttpConfig {
fn default() -> Self {
Self {
// 2xx-3xx = success (default, matches k6 behavior)
expected_statuses: default_expected_statuses(),
// The HTTP client is shared across all VUs (Arc), so this caps
// idle connections per host for the ENTIRE run, not per VU.
// reqwest's own default is usize::MAX (unlimited); k6 gives 6 per
// VU so 100 VUs = 600 idle connections per host. Using reqwest's
// default matches that scaling behavior.
max_idle_connections: usize::MAX,
keep_alive: Some("30s".to_string()),
// How long an idle connection is kept before being closed.
idle_connection_timeout: Some("30s".to_string()),
request_timeout: None,
http2: true,
http2_connections: 1,
user_agent: "Tropel/0.1.0".to_string(),
decompress: true,
max_redirects: 10,
no_redirects: false,
discard_response_bodies: false,
histogram_max_ms: None,
dns_ttl: None,
dns_select: None,
dns_policy: None,
no_connection_reuse: false,
no_vu_connection_reuse: false,
rps: None,
hosts: HashMap::new(),
blacklist_ips: Vec::new(),
max_response_bytes: None,
proxy: crate::proxy::ProxyConfig::default(),
http_debug: false,
http_debug_full: false,
}
}
}
/// TLS configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct TlsConfig {
pub insecure_skip_verify: bool,
pub min_version: Option<String>,
pub max_version: Option<String>,
pub client_cert: Option<String>,
pub client_key: Option<String>,
pub client_passphrase: Option<String>,
pub allowed_ciphers: Vec<String>,
/// PEM CA bundles to TRUST, in addition to the platform roots.
///
/// A list, not one path, because a private CA is commonly a chain split
/// across files and because two independent CAs (a corporate root and a
/// test root) is the ordinary case. Each entry is read and added
/// separately, so one unreadable bundle names itself in the error rather
/// than failing the whole set anonymously.
#[serde(default, alias = "rootCertPaths")]
pub root_cert_paths: Vec<String>,
/// Keep the platform verifier's roots alongside `root_cert_paths`.
///
/// TRUE by default, and that default is the important part: adding a
/// private CA is nearly always ADDITIVE — you still need to reach
/// github.com. Defaulting to false would make a config that adds one
/// internal root silently stop trusting the public internet, which
/// presents as "everything broke after I added our CA".
///
/// Set false deliberately to pin: only the supplied bundles are trusted,
/// which is what a locked-down test environment wants.
#[serde(default = "default_true", alias = "keepSystemRoots")]
pub keep_system_roots: bool,
}
fn default_true() -> bool {
true
}
impl Default for TlsConfig {
fn default() -> Self {
// Hand-written rather than derived because `keep_system_roots` must
// default TRUE and `#[derive(Default)]` would give false — the one
// field here whose zero value is the wrong answer.
Self {
insecure_skip_verify: false,
min_version: None,
max_version: None,
client_cert: None,
client_key: None,
client_passphrase: None,
allowed_ciphers: Vec::new(),
root_cert_paths: Vec::new(),
keep_system_roots: true,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn http_config_defaults_match_k6() {
let cfg = HttpConfig::default();
// Default expected list: 2xx + 3xx succeed, 4xx/5xx fail.
assert!(cfg.expected_statuses.iter().any(|e| e.matches(200)));
assert!(cfg.expected_statuses.iter().any(|e| e.matches(304)));
assert!(!cfg.expected_statuses.iter().any(|e| e.matches(404)));
assert_eq!(cfg.max_redirects, 10);
assert!(cfg.http2);
assert_eq!(cfg.http2_connections, 1);
}
#[test]
fn http2_connections_camel_case_alias() {
let json = r#"{"http2Connections": 4}"#;
let cfg: HttpConfig = serde_json::from_str(json).unwrap();
assert_eq!(cfg.http2_connections, 4);
}
}