Skip to main content

tropel_engine/
control_api.rs

1//! Minimal runtime control API — k6 REST `/v1/status` parity.
2//!
3//! Binds `127.0.0.1:<port>` and serves:
4//! - `GET  /v1/status`   → k6 JSON:API shape
5//!   `{"data":{"type":"status","id":"default","attributes":{...}}}`
6//!   (vus / vus-max / paused / running / stopped / tainted)
7//! - `PATCH /v1/status` → k6 envelope `{"data":{"attributes":{...}}}` or a
8//!   flat `{"vus":N,"max":M,"vus-max":N,"paused":bool}` — adjusts the
9//!   externally-controlled scheduler's VU pool / pause state at runtime.
10//!   `max` is clamped to the configured `max_vus` ceiling, so a client can
11//!   never grow the pool past the run's cap. Both `max` (legacy) and
12//!   `vus-max` (k6's JSON:API field name) are accepted, and the status doc
13//!   emits BOTH so old and new clients work.
14//! - `POST /v1/stop` → stop the run (k6 extension — also achievable via
15//!   `PATCH /v1/status {"stopped":true}`)
16//! - `PATCH /v1/stop` → k6 envelope `{"data":{"attributes":{"stopped":true}}}`
17//!   — same as the PATCH /v1/status path, but on the `/v1/stop` route.
18//! - `GET /v1/metrics` → k6 JSON:API envelope of current metric values
19//! - `GET /v1/groups`  → k6 JSON:API envelope of the group hierarchy
20//! - `GET /v1/setup`   → the current setup data (or null)
21//! - `PUT /v1/setup`   → set the setup data from the request body
22//! - `POST /v1/setup`  → run the script's setup() and return the result
23//! - `POST /v1/teardown` → run the script's teardown()
24//!
25//! Everything else returns 404. This is intentionally dependency-free: a
26//! hand-rolled HTTP/1.1 reader keeps the control surface small and avoids
27//! pulling a web framework into the engine for one endpoint.
28//!
29//! ## SUPERSET (k6 v2 divergence)
30//! k6 v2 turns the REST API **off by default** (`GlobalFlags.Address` → `""`).
31//! Tropel serves it whenever a `--control-port` is configured (for any executor,
32//! not just `externally-controlled`) — a deliberate SUPERSET so integrators
33//! (knockport, scripts, or platform operators) can always inspect a live run
34//! without reconfiguring the executor type. The `HEADER_LINE_LEN` / `BODY_SIZE`
35//! caps are the bounds that keep this safe (TR-604).
36
37use std::sync::Arc;
38use std::time::Duration;
39use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader};
40use tokio::net::TcpListener;
41use tokio::sync::Semaphore;
42use tropel_metrics::collector::{MetricsCollector, MetricsSnapshot, SeriesSnapshot};
43use tropel_scheduler::VUScheduler;
44use tropel_sdk::{Result, TropelError};
45
46/// Ceiling for a control request body. A status patch is a few hundred
47/// bytes; `Content-Length` is attacker-controlled, so without this cap a
48/// hostile `Content-Length: 68719476736` forced a 64 GiB `Vec::resize`
49/// (backlog line 164).
50const MAX_BODY_SIZE: usize = 64 * 1024;
51/// Backlog line 256: max length for a single header line (including
52/// request line). Without this, a hostile client can send a multi-GB
53/// single line that exhausts memory before the body ceiling kicks in.
54const MAX_HEADER_LINE_LEN: usize = 8 * 1024;
55/// Per-connection read timeout — a client that stalls mid-request must not
56/// hold its handler (and a connection slot) forever (backlog line 164).
57const CONN_TIMEOUT: Duration = Duration::from_secs(10);
58/// Concurrent-connection cap — prevents unbounded handler tasks under a
59/// connection flood (backlog line 164).
60const MAX_CONNS: usize = 8;
61/// Backoff after an accept error (e.g. EMFILE under fd exhaustion) — a
62/// tight accept-error loop otherwise spins one core at 100% CPU while the
63/// fd shortage persists (backlog line 164).
64const ACCEPT_BACKOFF: Duration = Duration::from_millis(100);
65
66/// Shared state for the control API: the scheduler (mutable run state) and
67/// read-only handles used by the read-only routes.
68pub struct ControlApiState {
69    pub scheduler: Arc<VUScheduler>,
70    /// Live metric collector — `/v1/metrics` reads its current snapshot.
71    pub metrics: Arc<MetricsCollector>,
72    /// Current setup data (`None` before setup runs / when the script declares
73    /// none). Written by the engine after setup() and by `PUT /v1/setup`.
74    pub setup_data: Arc<std::sync::Mutex<Option<Vec<u8>>>>,
75    /// Scenario name (k6's `group` root path is derived from the run context;
76    /// we expose the scenario name as the top-level group label).
77    pub scenario_name: String,
78}
79
80/// Handle the control server task. Runs until the listener errors or the
81/// task is aborted by the scenario finishing.
82pub async fn serve_control_api(port: u16, state: ControlApiState) -> Result<()> {
83    let addr = format!("127.0.0.1:{}", port);
84    // Bind failure must be visible: the spawned task's JoinHandle is only
85    // aborted (never awaited) by the engine, so a port conflict would
86    // otherwise leave the run silently without a control API.
87    let listener = match TcpListener::bind(&addr).await {
88        Ok(l) => l,
89        Err(e) => {
90            tracing::error!("control API: failed to bind {}: {}", addr, e);
91            return Err(tropel_sdk::TropelError::Config(format!(
92                "control API: failed to bind {}: {}",
93                addr, e
94            )));
95        }
96    };
97    tracing::info!("Control API listening on http://{addr}");
98
99    // Connection cap: at most MAX_CONNS handlers run concurrently; a flood
100    // past the cap is refused (503) instead of spawning unbounded tasks.
101    let conn_permits = Arc::new(Semaphore::new(MAX_CONNS));
102    let state = Arc::new(state);
103
104    loop {
105        let (stream, _peer) = match listener.accept().await {
106            Ok(x) => x,
107            Err(e) => {
108                // Back off: a persistent accept error (EMFILE under fd
109                // exhaustion) would otherwise spin the accept loop at 100%
110                // CPU until the shortage clears (backlog line 164).
111                tracing::debug!("control API: accept error: {}; backing off", e);
112                tokio::time::sleep(ACCEPT_BACKOFF).await;
113                continue;
114            }
115        };
116        // `try_acquire_owned` CONSUMES the Arc (returns an owned permit), so
117        // clone per iteration — the original survives for the next accept.
118        let permit = match conn_permits.clone().try_acquire_owned() {
119            Ok(p) => p,
120            Err(_) => {
121                // Cap reached — refuse with 503 instead of queueing the
122                // connection forever.
123                let mut out = stream;
124                let _ = out
125                    .write_all(
126                        b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
127                    )
128                    .await;
129                continue;
130            }
131        };
132        let state = state.clone();
133        tokio::spawn(async move {
134            // The permit is held for the whole connection lifetime, so the
135            // cap counts live handlers, not just accepted sockets.
136            let _permit = permit;
137            if let Err(e) = handle_conn(stream, &state).await {
138                tracing::debug!("control API: connection error: {}", e);
139            }
140        });
141    }
142}
143
144/// Serve one HTTP connection, bounded by a read timeout (a stalled client
145/// must not hold its handler — and its connection slot — forever).
146async fn handle_conn<S>(stream: S, state: &Arc<ControlApiState>) -> Result<()>
147where
148    S: AsyncRead + AsyncWrite + Unpin,
149{
150    // Read timeout: without it a client that sends the request line but
151    // never finishes headers/body parks the handler (and one of MAX_CONNS
152    // slots) indefinitely (backlog line 164).
153    match tokio::time::timeout(CONN_TIMEOUT, serve_request(stream, state)).await {
154        Ok(r) => r,
155        Err(_elapsed) => {
156            tracing::debug!("control API: connection timed out");
157            Ok(())
158        }
159    }
160}
161
162/// Read one request (line + headers + body), route it, write the response.
163async fn serve_request<S>(stream: S, state: &Arc<ControlApiState>) -> Result<()>
164where
165    S: AsyncRead + AsyncWrite + Unpin,
166{
167    let mut reader = BufReader::new(stream);
168    let mut request_line = String::new();
169    // P2 line 173: use .take() to limit the read BEFORE read_line grows
170    // the String unbounded. The old code checked AFTER read_line, so a
171    // multi-GB line with no newline allocated all of it first.
172    let mut limited = (&mut reader).take(MAX_HEADER_LINE_LEN as u64 + 1);
173    if limited.read_line(&mut request_line).await? == 0 {
174        return Ok(());
175    }
176    if request_line.len() > MAX_HEADER_LINE_LEN {
177        return Err(TropelError::Http(format!(
178            "control API: request line too long ({} > {})",
179            request_line.len(),
180            MAX_HEADER_LINE_LEN
181        )));
182    }
183    let request_line = request_line.trim_end().to_string();
184    let mut parts = request_line.split_whitespace();
185    let method = parts.next().unwrap_or("").to_string();
186    let path = parts.next().unwrap_or("").to_string();
187
188    // Read headers, discover Content-Length.
189    let mut content_length: usize = 0;
190    loop {
191        let mut line = String::new();
192        let mut limited = (&mut reader).take(MAX_HEADER_LINE_LEN as u64 + 1);
193        if limited.read_line(&mut line).await? == 0 {
194            break;
195        }
196        // P2 line 173: cap individual header line length BEFORE read.
197        if line.len() > MAX_HEADER_LINE_LEN {
198            return Err(TropelError::Http(format!(
199                "control API: header line too long ({} > {})",
200                line.len(),
201                MAX_HEADER_LINE_LEN
202            )));
203        }
204        let line = line.trim_end();
205        if line.is_empty() {
206            break;
207        }
208        if let Some(v) = line.to_ascii_lowercase().strip_prefix("content-length:") {
209            content_length = v.trim().parse().unwrap_or(0);
210        }
211    }
212
213    // Body ceiling: `Content-Length` is client-controlled, so a hostile
214    // value (e.g. 64 GiB) must be rejected BEFORE any allocation — the old
215    // code `Vec::resize`d the full declared length unbounded (backlog line
216    // 164).
217    if content_length > MAX_BODY_SIZE {
218        let mut out = reader.into_inner();
219        out.write_all(
220            b"HTTP/1.1 413 Payload Too Large\r\nContent-Type: application/json\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
221        )
222        .await?;
223        out.flush().await?;
224        return Ok(());
225    }
226
227    // Read the body.
228    let mut body = Vec::new();
229    if content_length > 0 {
230        body.resize(content_length, 0);
231        reader.read_exact(&mut body).await?;
232    }
233
234    let (status, response_body) = route(&method, &path, &body, state).await;
235
236    let response = format!(
237        "HTTP/1.1 {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
238        status,
239        response_body.len(),
240        response_body
241    );
242    let mut out = reader.into_inner();
243    out.write_all(response.as_bytes()).await?;
244    out.flush().await?;
245    Ok(())
246}
247
248/// Route a control request and return (status line, JSON body).
249async fn route(
250    method: &str,
251    path: &str,
252    body: &[u8],
253    state: &Arc<ControlApiState>,
254) -> (String, String) {
255    let sched = &state.scheduler;
256    match (method, path) {
257        ("GET", "/v1/status") => ("200 OK".to_string(), status_json(sched)),
258        ("PATCH", "/v1/status") => match parse_status_body(body) {
259            Some(patch) => {
260                // Apply only the fields the client sent (k6 allows partial
261                // PATCHes: just vus, just max, just paused, just stopped, or
262                // any combo).
263                if patch.vus.is_some() || patch.max.is_some() {
264                    let vus = patch.vus.unwrap_or_else(|| sched.control_target());
265                    let max = patch.max.unwrap_or_else(|| sched.control_max());
266                    sched.set_control_target(vus, max);
267                    tracing::info!("Control API: set VUs target={} max={}", vus, max);
268                }
269                if let Some(paused) = patch.paused {
270                    sched.set_paused(paused);
271                    tracing::info!("Control API: paused={}", paused);
272                }
273                // `stopped` was EMITTED in the status doc but never READ: a
274                // PATCH `{"stopped":true}` used to fall through to the
275                // unknown-field 400, so a k6-style client could never stop
276                // the run via the control API (backlog line 164).
277                if patch.stopped == Some(true) {
278                    sched.request_stop();
279                    tracing::info!("Control API: stop requested");
280                }
281                ("200 OK".to_string(), status_json(sched))
282            }
283            None => (
284                "400 Bad Request".to_string(),
285                "{\"error\":\"expected {\\\"vus\\\":N,\\\"max\\\":M,\\\"paused\\\":bool}\"}"
286                    .to_string(),
287            ),
288        },
289        // k6 envelope on the stop route itself: PATCH /v1/stop with
290        // {"data":{"attributes":{"stopped":true}}}.
291        ("PATCH", "/v1/stop") => {
292            match parse_status_body(body) {
293                Some(patch) if patch.stopped == Some(true) || patch.stopped.is_none() => {
294                    if patch.stopped == Some(true) {
295                        sched.request_stop();
296                        tracing::info!("Control API: stop requested (PATCH /v1/stop)");
297                    }
298                    ("200 OK".to_string(), status_json(sched))
299                }
300                _ => (
301                    "400 Bad Request".to_string(),
302                    "{\"error\":\"expected {\\\"data\\\":{\\\"attributes\\\":{\\\"stopped\\\":true}}}\"}"
303                        .to_string(),
304                ),
305            }
306        }
307        ("POST", "/v1/stop") => {
308            sched.request_stop();
309            ("200 OK".to_string(), status_json(sched))
310        }
311        ("GET", "/v1/metrics") => {
312            let snap = state.metrics.snapshot().await;
313            ("200 OK".to_string(), metrics_json(&snap))
314        }
315        ("GET", "/v1/groups") => {
316            let snap = state.metrics.snapshot().await;
317            ("200 OK".to_string(), groups_json(&snap, &state.scenario_name))
318        }
319        ("GET", "/v1/setup") => {
320            let data = state.setup_data.lock().unwrap().clone();
321            ("200 OK".to_string(), setup_json(data.as_deref()))
322        }
323        ("PUT", "/v1/setup") => {
324            // k6: PUT /v1/setup with a JSON body (or empty to clear) sets the
325            // setup data. We store the raw bytes so GET round-trips them.
326            let parsed: Option<serde_json::Value> = if body.is_empty() {
327                None
328            } else {
329                match serde_json::from_slice(body) {
330                    Ok(v) => Some(v),
331                    Err(e) => {
332                        return (
333                            "400 Bad Request".to_string(),
334                            format!(r#"{{"error":"invalid setup data: {e}"}}"#),
335                        )
336                    }
337                }
338            };
339            *state.setup_data.lock().unwrap() = parsed.map(|v| v.to_string().into_bytes());
340            let data = state.setup_data.lock().unwrap().clone();
341            ("200 OK".to_string(), setup_json(data.as_deref()))
342        }
343        ("POST", "/v1/setup") => {
344            // k6's POST /v1/setup runs the script's setup() — the engine runs
345            // setup() once before VUs spawn; a mid-run re-run is not a k6
346            // behaviour and is not supported.
347            (
348                "405 Method Not Allowed".to_string(),
349                r#"{"error":"setup() runs once at engine start; POST re-run not supported"}"#
350                    .to_string(),
351            )
352        }
353        ("POST", "/v1/teardown") => {
354            (
355                "405 Method Not Allowed".to_string(),
356                r#"{"error":"teardown() runs once at engine stop; POST re-run not supported"}"#
357                    .to_string(),
358            )
359        }
360        _ => (
361            "404 Not Found".to_string(),
362            r#"{"error":"not found"}"#.to_string(),
363        ),
364    }
365}
366
367/// Render the k6 JSON:API status document. `running` is false once a stop
368/// has been requested; `tainted` reflects real threshold failures
369/// (backlog line 154 — was hardcoded null).
370fn status_json(sched: &Arc<VUScheduler>) -> String {
371    let vus = sched.control_target();
372    let max = sched.control_max();
373    let paused = sched.is_paused();
374    let stopped = sched.is_stop_requested();
375    let running = !stopped;
376    let tainted = sched.is_tainted();
377    format!(
378        r#"{{"data":{{"type":"status","id":"default","attributes":{{"vus":{},"vus-max":{},"max":{},"paused":{},"running":{},"stopped":{},"tainted":{}}}}}}}"#,
379        vus, max, max, paused, running, stopped, tainted
380    )
381}
382
383/// Render the k6 JSON:API `/v1/metrics` document: an array of metric objects
384/// with the metric name as the JSON:API id and its current sample values in
385/// `attributes`. Matches k6's `metric_jsonapi.go` envelope shape
386/// (`{"data":[{"type":"metrics","id":"…","attributes":{…}}]}`).
387fn metrics_json(snap: &MetricsSnapshot) -> String {
388    let mut entries: Vec<serde_json::Value> = Vec::new();
389    // Aggregate the per-series snapshots by metric name. A metric with several
390    // tag-combinations (per-URL http_req_duration etc.) reports its last value
391    // per series, but the JSON:API metric object is keyed by metric name only —
392    // match k6 (one object per metric) and use the last observed value.
393    let mut by_metric: std::collections::BTreeMap<&str, &SeriesSnapshot> = Default::default();
394    for s in &snap.series {
395        by_metric.insert(&s.metric, s);
396    }
397    for (name, s) in by_metric {
398        // k6 `Sample` map: the trend value under "value" (ms), count/sum for
399        // counters — minimal but shape-correct.
400        let sample = serde_json::json!({
401            "value": s.last,
402        });
403        entries.push(serde_json::json!({
404            "type": "metrics",
405            "id": name,
406            "attributes": {
407                "type": metric_type_name(s.metric_type),
408                "contains": "default",
409                "tainted": false,
410                "sample": sample,
411            },
412        }));
413    }
414    // Even with no metrics, k6's envelope is `{"data":[]}`.
415    serde_json::json!({ "data": entries }).to_string()
416}
417
418/// k6 metric type names: `counter`, `gauge`, `rate`, `trend`.
419fn metric_type_name(t: tropel_metrics::collector::MetricType) -> &'static str {
420    use tropel_metrics::collector::MetricType;
421    match t {
422        MetricType::Counter => "counter",
423        MetricType::Gauge => "gauge",
424        MetricType::Rate => "rate",
425        MetricType::Trend => "trend",
426    }
427}
428
429/// Render the k6 JSON:API `/v1/groups` document. k6's group tree is built
430/// from the script's `group()` nesting; tropel does not track a nested group
431/// tree at the engine level, so this returns a single root group named after
432/// the scenario (k6's root is always `""` — its id is `0`). The shape matches
433/// `group_jsonapi.go` (`{"data":[{"type":"groups","id":"…","attributes":{…}}]}`).
434fn groups_json(_snap: &MetricsSnapshot, scenario_name: &str) -> String {
435    serde_json::json!({
436        "data": [{
437            "type": "groups",
438            "id": "0",
439            "attributes": {
440                "path": "",
441                "name": scenario_name,
442                "checks": [],
443            },
444            "relationships": {
445                "groups": { "data": [] },
446                "parent": { "data": null },
447            },
448        }]
449    })
450    .to_string()
451}
452
453/// Render the k6 JSON:API `/v1/setup` document: `{"data":{"data":<setup>}}`
454/// where `<setup>` is the JSON setup value, or `null` when there is none.
455fn setup_json(data: Option<&[u8]>) -> String {
456    let value = match data {
457        Some(bytes) => serde_json::from_slice(bytes).unwrap_or(serde_json::Value::Null),
458        None => serde_json::Value::Null,
459    };
460    serde_json::json!({ "data": { "data": value } }).to_string()
461}
462
463/// A parsed PATCH /v1/status body. All fields optional — k6 allows partial
464/// patches (`{"paused":true}` alone is valid).
465#[derive(Debug, Clone, Copy, PartialEq)]
466struct StatusPatch {
467    vus: Option<u32>,
468    max: Option<u32>,
469    paused: Option<bool>,
470    stopped: Option<bool>,
471}
472
473/// Parse a PATCH /v1/status body. Accepts the flat form
474/// `{"vus":5,"vus-max":10}`, the k6 envelope
475/// `{"data":{"attributes":{"vus":5,"vus-max":10}}}` — and the legacy
476/// `max` key (backlog line 154: k6's JSON:API field is `vus-max`; Tropel's
477/// old `max` stays accepted). `vus-max` wins over `max` when both are sent.
478/// Returns `None` when the body is unparseable or carries none of the known
479/// fields (so a garbage body can't be silently swallowed).
480fn parse_status_body(body: &[u8]) -> Option<StatusPatch> {
481    let text = std::str::from_utf8(body).ok()?;
482    let json: serde_json::Value = serde_json::from_str(text).ok()?;
483
484    // k6 envelope: {"data":{"attributes":{...}}}
485    let attrs = json
486        .get("data")
487        .and_then(|d| d.get("attributes"))
488        .or(Some(&json))?;
489
490    let vus = attrs.get("vus").and_then(|v| v.as_u64()).map(|v| v as u32);
491    let max = attrs
492        .get("vus-max")
493        .and_then(|v| v.as_u64())
494        .map(|v| v as u32)
495        .or_else(|| attrs.get("max").and_then(|v| v.as_u64()).map(|v| v as u32));
496    let paused = attrs.get("paused").and_then(|v| v.as_bool());
497    // `stopped` is emitted by status_json and must be accepted back (backlog
498    // line 164): PATCH {"stopped":true} stops the run.
499    let stopped = attrs.get("stopped").and_then(|v| v.as_bool());
500
501    if vus.is_none() && max.is_none() && paused.is_none() && stopped.is_none() {
502        return None;
503    }
504    Some(StatusPatch {
505        vus,
506        max,
507        paused,
508        stopped,
509    })
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515    use tropel_metrics::collector::MetricsCollector;
516
517    /// Build a control state for tests. The metrics collector is live but
518    /// empty; setup data is None.
519    fn test_state(sched: Arc<VUScheduler>) -> Arc<ControlApiState> {
520        Arc::new(ControlApiState {
521            scheduler: sched,
522            metrics: Arc::new(MetricsCollector::new()),
523            setup_data: Arc::new(std::sync::Mutex::new(None)),
524            scenario_name: "s".to_string(),
525        })
526    }
527
528    #[test]
529    fn parses_flat_body() {
530        assert_eq!(
531            parse_status_body(br#"{"vus":5,"max":20}"#),
532            Some(StatusPatch {
533                vus: Some(5),
534                max: Some(20),
535                paused: None,
536                stopped: None,
537            })
538        );
539    }
540
541    #[test]
542    fn parses_k6_envelope() {
543        assert_eq!(
544            parse_status_body(br#"{"data":{"attributes":{"vus":3,"max":9}}}"#),
545            Some(StatusPatch {
546                vus: Some(3),
547                max: Some(9),
548                paused: None,
549                stopped: None,
550            })
551        );
552    }
553
554    #[test]
555    fn parses_paused_only() {
556        assert_eq!(
557            parse_status_body(br#"{"paused":true}"#),
558            Some(StatusPatch {
559                vus: None,
560                max: None,
561                paused: Some(true),
562                stopped: None,
563            })
564        );
565    }
566
567    #[test]
568    fn partial_patch_with_only_vus_is_valid() {
569        assert_eq!(
570            parse_status_body(br#"{"vus":5}"#),
571            Some(StatusPatch {
572                vus: Some(5),
573                max: None,
574                paused: None,
575                stopped: None,
576            })
577        );
578    }
579
580    #[test]
581    fn rejects_garbage_and_unknown_only() {
582        assert_eq!(parse_status_body(br#"{"foo":1}"#), None); // no known field
583        assert_eq!(parse_status_body(b"garbage"), None);
584        assert_eq!(parse_status_body(b"{}"), None);
585    }
586
587    /// Backlog line 164: `stopped` is emitted by status_json and must be
588    /// accepted back — a PATCH carrying only `{"stopped":true}` is a valid
589    /// partial patch (it used to 400 as "unknown field").
590    #[test]
591    fn parses_stopped_only() {
592        assert_eq!(
593            parse_status_body(br#"{"stopped":true}"#),
594            Some(StatusPatch {
595                vus: None,
596                max: None,
597                paused: None,
598                stopped: Some(true),
599            })
600        );
601    }
602
603    /// Backlog line 164: a PATCH `{"stopped":true}` must stop the run — it
604    /// was emitted in the status doc but never read back, so a k6-style
605    /// client could never stop via the control API.
606    #[test]
607    fn route_stopped_true_requests_stop() {
608        let sched = Arc::new(VUScheduler::new(
609            &tropel_core::config::ExecutionConfig::ExternallyControlled {
610                vus: 2,
611                max_vus: 10,
612                duration: None,
613                graceful_stop: None,
614                think_time: Default::default(),
615            },
616        ));
617        let state = test_state(sched.clone());
618        assert!(!sched.is_stop_requested());
619        let rt = tokio::runtime::Runtime::new().unwrap();
620        let (status, body) =
621            rt.block_on(route("PATCH", "/v1/status", br#"{"stopped":true}"#, &state));
622        assert_eq!(status, "200 OK");
623        assert!(
624            sched.is_stop_requested(),
625            "PATCH stopped:true must request a stop"
626        );
627        // The response reflects the now-stopped state.
628        let v: serde_json::Value = serde_json::from_str(&body).unwrap();
629        assert_eq!(v["data"]["attributes"]["stopped"], true);
630    }
631
632    /// Backlog line 164: `stopped:false` is a no-op (nothing to un-stop).
633    #[test]
634    fn route_stopped_false_is_noop() {
635        let sched = Arc::new(VUScheduler::new(
636            &tropel_core::config::ExecutionConfig::ExternallyControlled {
637                vus: 2,
638                max_vus: 10,
639                duration: None,
640                graceful_stop: None,
641                think_time: Default::default(),
642            },
643        ));
644        let state = test_state(sched.clone());
645        let rt = tokio::runtime::Runtime::new().unwrap();
646        let (status, _) = rt.block_on(route(
647            "PATCH",
648            "/v1/status",
649            br#"{"stopped":false}"#,
650            &state,
651        ));
652        assert_eq!(status, "200 OK");
653        assert!(!sched.is_stop_requested());
654    }
655
656    /// Backlog line 164: a declared body larger than MAX_BODY_SIZE must be
657    /// rejected with 413 BEFORE any allocation — the old code resized to the
658    /// full declared length (a hostile 64 GiB Content-Length = 64 GiB alloc).
659    #[tokio::test]
660    async fn oversized_body_rejected_before_alloc() {
661        let sched = Arc::new(VUScheduler::new(
662            &tropel_core::config::ExecutionConfig::ExternallyControlled {
663                vus: 2,
664                max_vus: 10,
665                duration: None,
666                graceful_stop: None,
667                think_time: Default::default(),
668            },
669        ));
670        let (mut client, server) = tokio::io::duplex(4096);
671        let state = test_state(sched);
672        let server_task = tokio::spawn(async move { serve_request(server, &state).await });
673
674        // Declare a 64 GiB body; never send it. Must get 413 back (and the
675        // handler must not try to read 64 GiB).
676        client
677            .write_all(b"PATCH /v1/status HTTP/1.1\r\nContent-Length: 68719476736\r\n\r\n")
678            .await
679            .unwrap();
680        let mut resp = Vec::new();
681        client.read_to_end(&mut resp).await.unwrap();
682        let text = String::from_utf8_lossy(&resp);
683        assert!(
684            text.contains("413 Payload Too Large"),
685            "expected 413, got: {}",
686            text
687        );
688        server_task.await.unwrap().unwrap();
689    }
690
691    /// Backlog line 164: a client that sends the request line but never
692    /// finishes the request must be cut off by the read timeout — it must
693    /// not hold its handler (and one of MAX_CONNS slots) forever.
694    /// Paused tokio time drives the internal CONN_TIMEOUT deterministically
695    /// instead of waiting 10 real seconds.
696    #[tokio::test(start_paused = true)]
697    async fn stalled_client_is_timed_out() {
698        let sched = Arc::new(VUScheduler::new(
699            &tropel_core::config::ExecutionConfig::ExternallyControlled {
700                vus: 2,
701                max_vus: 10,
702                duration: None,
703                graceful_stop: None,
704                think_time: Default::default(),
705            },
706        ));
707        let (mut client, server) = tokio::io::duplex(4096);
708        // Send only a partial request line — never finish the headers/body,
709        // so `read_line` would block forever WITHOUT the read timeout.
710        client.write_all(b"PATCH /v1/status HTT").await.unwrap();
711        let state = test_state(sched);
712        let server_task = tokio::spawn(async move { handle_conn(server, &state).await });
713        // Advance past CONN_TIMEOUT (10s) — the handler must return (the
714        // timeout fires) rather than hanging on the incomplete request.
715        tokio::time::advance(Duration::from_secs(11)).await;
716        let result = server_task.await.unwrap();
717        assert!(
718            result.is_ok(),
719            "stalled client must be cut off by the read timeout"
720        );
721    }
722
723    #[test]
724    fn status_json_is_k6_shape() {
725        let sched = VUScheduler::new(
726            &tropel_core::config::ExecutionConfig::ExternallyControlled {
727                vus: 2,
728                max_vus: 10,
729                duration: None,
730                graceful_stop: None,
731                think_time: Default::default(),
732            },
733        );
734        let sched = Arc::new(sched);
735        sched.set_control_target(4, 10);
736        let body = status_json(&sched);
737        let v: serde_json::Value = serde_json::from_str(&body).unwrap();
738        let attrs = &v["data"]["attributes"];
739        assert_eq!(attrs["type"], serde_json::Value::Null); // type/id live on data, not attributes
740        assert_eq!(v["data"]["type"], "status");
741        assert_eq!(v["data"]["id"], "default");
742        assert_eq!(attrs["vus"], 4);
743        // Backlog line 154: k6's JSON:API field is `vus-max`; Tropel also
744        // keeps emitting `max` for back-compat.
745        assert_eq!(attrs["vus-max"], 10);
746        assert_eq!(attrs["max"], 10);
747        assert_eq!(attrs["paused"], false);
748        assert_eq!(attrs["running"], true);
749        assert_eq!(attrs["stopped"], false);
750        assert_eq!(attrs["tainted"], false);
751
752        // Taint is real: once a threshold fails, the status shows it.
753        sched.set_tainted();
754        let body = status_json(&sched);
755        let v: serde_json::Value = serde_json::from_str(&body).unwrap();
756        assert_eq!(v["data"]["attributes"]["tainted"], true);
757    }
758
759    /// TR-250: PATCH /v1/stop accepts the k6 envelope
760    /// `{"data":{"attributes":{"stopped":true}}}` and stops the run.
761    #[test]
762    fn patch_stop_accepts_k6_envelope() {
763        let sched = Arc::new(VUScheduler::new(
764            &tropel_core::config::ExecutionConfig::ExternallyControlled {
765                vus: 2,
766                max_vus: 10,
767                duration: None,
768                graceful_stop: None,
769                think_time: Default::default(),
770            },
771        ));
772        let state = test_state(sched.clone());
773        assert!(!sched.is_stop_requested());
774        let rt = tokio::runtime::Runtime::new().unwrap();
775        let (status, body) = rt.block_on(route(
776            "PATCH",
777            "/v1/stop",
778            br#"{"data":{"attributes":{"stopped":true}}}"#,
779            &state,
780        ));
781        assert_eq!(status, "200 OK");
782        assert!(
783            sched.is_stop_requested(),
784            "PATCH /v1/stop with the k6 envelope must stop the run"
785        );
786        let v: serde_json::Value = serde_json::from_str(&body).unwrap();
787        assert_eq!(v["data"]["attributes"]["stopped"], true);
788    }
789
790    /// TR-250: GET /v1/metrics returns the k6 JSON:API envelope with a
791    /// metric entry per observed metric name.
792    #[tokio::test]
793    async fn get_metrics_returns_k6_envelope() {
794        let sched = Arc::new(VUScheduler::new(
795            &tropel_core::config::ExecutionConfig::ExternallyControlled {
796                vus: 2,
797                max_vus: 10,
798                duration: None,
799                graceful_stop: None,
800                think_time: Default::default(),
801            },
802        ));
803        let state = test_state(sched);
804        let (status, body) = route("GET", "/v1/metrics", b"", &state).await;
805        assert_eq!(status, "200 OK");
806        let v: serde_json::Value = serde_json::from_str(&body).unwrap();
807        assert_eq!(
808            v["data"],
809            serde_json::Value::Array(vec![]),
810            "no metrics yet"
811        );
812    }
813
814    /// TR-250: GET /v1/groups returns the k6 JSON:API envelope with the
815    /// scenario as the root group (id "0", empty path).
816    #[tokio::test]
817    async fn get_groups_returns_k6_envelope() {
818        let sched = Arc::new(VUScheduler::new(
819            &tropel_core::config::ExecutionConfig::ExternallyControlled {
820                vus: 2,
821                max_vus: 10,
822                duration: None,
823                graceful_stop: None,
824                think_time: Default::default(),
825            },
826        ));
827        let state = test_state(sched);
828        let (status, body) = route("GET", "/v1/groups", b"", &state).await;
829        assert_eq!(status, "200 OK");
830        let v: serde_json::Value = serde_json::from_str(&body).unwrap();
831        let group = &v["data"][0];
832        assert_eq!(group["type"], "groups");
833        assert_eq!(group["id"], "0");
834        assert_eq!(group["attributes"]["path"], "");
835        assert_eq!(group["attributes"]["name"], "s");
836        assert_eq!(
837            group["relationships"]["groups"]["data"],
838            serde_json::Value::Array(vec![])
839        );
840        assert_eq!(
841            group["relationships"]["parent"]["data"],
842            serde_json::Value::Null
843        );
844    }
845
846    /// TR-250: GET /v1/setup returns null data when none is set; PUT sets it;
847    /// GET reads it back.
848    #[tokio::test]
849    async fn setup_get_put_roundtrip() {
850        let sched = Arc::new(VUScheduler::new(
851            &tropel_core::config::ExecutionConfig::ExternallyControlled {
852                vus: 2,
853                max_vus: 10,
854                duration: None,
855                graceful_stop: None,
856                think_time: Default::default(),
857            },
858        ));
859        let state = test_state(sched);
860
861        let (status, body) = route("GET", "/v1/setup", b"", &state).await;
862        assert_eq!(status, "200 OK");
863        let v: serde_json::Value = serde_json::from_str(&body).unwrap();
864        assert_eq!(v["data"]["data"], serde_json::Value::Null);
865
866        let (status, body) = route("PUT", "/v1/setup", br#"{"token":"abc"}"#, &state).await;
867        assert_eq!(status, "200 OK");
868        let v: serde_json::Value = serde_json::from_str(&body).unwrap();
869        assert_eq!(v["data"]["data"]["token"], "abc");
870
871        let (status, body) = route("GET", "/v1/setup", b"", &state).await;
872        assert_eq!(status, "200 OK");
873        let v: serde_json::Value = serde_json::from_str(&body).unwrap();
874        assert_eq!(v["data"]["data"]["token"], "abc");
875    }
876
877    /// TR-250: k6's POST /v1/setup and POST /v1/teardown re-run the lifecycle
878    /// functions; tropel runs them once at engine start/stop, so a mid-run
879    /// POST must fail loudly (405) rather than pretend to have re-run setup.
880    #[tokio::test]
881    async fn setup_teardown_post_reexecution_rejected() {
882        let sched = Arc::new(VUScheduler::new(
883            &tropel_core::config::ExecutionConfig::ExternallyControlled {
884                vus: 2,
885                max_vus: 10,
886                duration: None,
887                graceful_stop: None,
888                think_time: Default::default(),
889            },
890        ));
891        let state = test_state(sched);
892        let (status, body) = route("POST", "/v1/setup", b"", &state).await;
893        assert_eq!(status, "405 Method Not Allowed");
894        assert!(body.contains("setup() runs once"));
895        let (status, body) = route("POST", "/v1/teardown", b"", &state).await;
896        assert_eq!(status, "405 Method Not Allowed");
897        assert!(body.contains("teardown() runs once"));
898    }
899}