Skip to content

Commit 9bd072f

Browse files
blttobz
authored andcommitted
enhancement(antithesis): Introduce rig intake API (#1826)
This commit introduces an intake API for antithesis tests. It's a new implementation compared to the pre-existing intake in the project as this intake has different concerns. It will rapidly diverge from the other intake, being focused on allowing driver and check claims when running under Antithesis. - [ ] Bug fix - [ ] New feature - [x] Non-functional (chore, refactoring, docs) - [ ] Performance
1 parent 2429995 commit 9bd072f

11 files changed

Lines changed: 313 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,10 @@ members = [
3535
"lib/stringtheory",
3636
"test/antithesis/harness",
3737
"test/antithesis/intake",
38+
<<<<<<< HEAD
3839
"test/antithesis/scenarios/general",
40+
=======
41+
>>>>>>> 9c1abdeb85 (enhancement(antithesis): Introduce rig intake API (#1826))
3942
]
4043
resolver = "2"
4144

test/antithesis/intake/Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ datadog-protos = { workspace = true }
2222
headers = { workspace = true }
2323
mime = { workspace = true }
2424
protobuf = { workspace = true }
25+
<<<<<<< HEAD
2526
serde = { workspace = true, features = ["derive"] }
27+
=======
28+
>>>>>>> 9c1abdeb85 (enhancement(antithesis): Introduce rig intake API (#1826))
2629
serde_json = { workspace = true }
2730
tokio = { workspace = true, features = [
2831
"macros",
@@ -36,7 +39,10 @@ tower-http = { workspace = true, features = [
3639
"decompression-deflate",
3740
"decompression-gzip",
3841
"decompression-zstd",
42+
<<<<<<< HEAD
3943
"limit",
44+
=======
45+
>>>>>>> 9c1abdeb85 (enhancement(antithesis): Introduce rig intake API (#1826))
4046
] }
4147
tracing = { workspace = true }
4248
tracing-subscriber = { workspace = true, features = [

test/antithesis/intake/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ envelope wrap around the compressed bytes of a
2828

2929
Some properties reference rig-controlled parameters. `MaxTags(orgID)` and
3030
`MaxResources(orgID)` are per-org caps with defaults 100 and 500 respectively.
31+
<<<<<<< HEAD
32+
=======
33+
`hostname` is the value the rig passes to the Agent via the `DD_HOSTNAME`
34+
environment variable. The Agent's hostname provider chain resolves this first,
35+
short-circuiting cloud-metadata and OS fallbacks.
36+
>>>>>>> 9c1abdeb85 (enhancement(antithesis): Introduce rig intake API (#1826))
3137
3238
| Number | Category | Name | Description |
3339
|--------|---------------|------------------------|----------------------------------------------------------------|
@@ -46,7 +52,11 @@ Some properties reference rig-controlled parameters. `MaxTags(orgID)` and
4652
| Pyld14 | MetricSeries | Tag Prefix Reserved | no tag starts with `device:` or `dd.internal.resource:` |
4753
| Pyld15 | MetricSeries | Per-Series Point Count | `len(points) <=` configured `serializer_max_series_points_per_payload` |
4854
| Pyld16 | MetricSeries | Origin Populated | `origin.{product, category, service}` enum-valid |
55+
<<<<<<< HEAD
4956
| Pyld17 | Resource | Host Resource Resolved | every series resolves a non-empty `(type="host")` resource and all series in a payload share one host |
57+
=======
58+
| Pyld17 | Resource | Host Resource Resolved | intake's `Host()` scan resolves a `(type="host", name=hostname)` resource |
59+
>>>>>>> 9c1abdeb85 (enhancement(antithesis): Introduce rig intake API (#1826))
5060
| Pyld18 | Resource | Resource Count | `len(resources) <= MaxResources(orgID)` |
5161
| Pyld19 | Resource | Host Name Length | host `name <= 255` bytes |
5262
| Pyld20 | MetricPoint | Value Not-NaN | `value` is not NaN |

test/antithesis/intake/src/bin/intake.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44

55
#[cfg(unix)]
66
mod unix_intake {
7+
<<<<<<< HEAD
8+
=======
9+
use std::sync::Arc;
10+
11+
>>>>>>> 9c1abdeb85 (enhancement(antithesis): Introduce rig intake API (#1826))
712
use antithesis_intake::http::build_router;
813
use antithesis_sdk::prelude::*;
914
use anyhow::{Context, Result};
@@ -19,6 +24,13 @@ mod unix_intake {
1924
/// Address the HTTP intake binds and serves on.
2025
#[arg(long = "listen-addr", env = "LISTEN_ADDR", default_value = "0.0.0.0:2049")]
2126
listen_addr: String,
27+
<<<<<<< HEAD
28+
=======
29+
30+
/// Agent hostname, resolved by Pyld17 (keep in sync with `hostname` in `datadog.yaml`)
31+
#[arg(long = "hostname", env = "DD_HOSTNAME", default_value = "antithesis-adp")]
32+
hostname: String,
33+
>>>>>>> 9c1abdeb85 (enhancement(antithesis): Introduce rig intake API (#1826))
2234
}
2335

2436
#[tokio::main]
@@ -45,6 +57,11 @@ mod unix_intake {
4557

4658
/// Build the intake app, bind the listener, and serve until a shutdown signal.
4759
async fn serve(config: Config) -> Result<()> {
60+
<<<<<<< HEAD
61+
=======
62+
info!(hostname = %config.hostname, "Pyld17 resolves each series host against this hostname.");
63+
64+
>>>>>>> 9c1abdeb85 (enhancement(antithesis): Introduce rig intake API (#1826))
4865
let (shutdown_tx, mut shutdown_rx) = mpsc::channel(1);
4966
spawn_signal_handlers(shutdown_tx).context("Failed to configure signal handlers.")?;
5067

@@ -53,7 +70,11 @@ mod unix_intake {
5370
.context("Failed to bind HTTP intake listener.")?;
5471
info!("antithesis-intake started: listening on {}.", config.listen_addr);
5572

73+
<<<<<<< HEAD
5674
axum::serve(listener, build_router())
75+
=======
76+
axum::serve(listener, build_router(Arc::from(config.hostname.as_str())))
77+
>>>>>>> 9c1abdeb85 (enhancement(antithesis): Introduce rig intake API (#1826))
5778
.with_graceful_shutdown(async move { shutdown_rx.recv().await.unwrap_or(()) })
5879
.await
5980
.map_err(Into::into)

test/antithesis/intake/src/http.rs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
//! Axum HTTP surface for the intake.
22
//!
3+
<<<<<<< HEAD
34
//! This module composes the intake router while submodules keep protocol groups
45
//! and middleware separate.
56
@@ -24,4 +25,80 @@ pub fn build_router() -> Router {
2425
.merge(datadog::routes())
2526
.fallback(|| async { StatusCode::OK })
2627
.with_state(AppState::default())
28+
=======
29+
//! The `/api/v2/series` route stacks measurement middleware ahead of the
30+
//! decompression layer so Pyld05 (compressed size), Pyld06 (uncompressed size),
31+
//! and Pyld22 (content-length) can read both the on-the-wire and decompressed
32+
//! body lengths, recorded as request extensions before `RequestDecompressionLayer`
33+
//! consumes the encoding headers.
34+
35+
use std::sync::Arc;
36+
37+
use axum::{
38+
body::Body,
39+
extract::{DefaultBodyLimit, Request},
40+
http::StatusCode,
41+
middleware::{from_fn, Next},
42+
response::{IntoResponse, Response},
43+
routing::post,
44+
Router,
45+
};
46+
use headers::{ContentEncoding, ContentLength, HeaderMapExt};
47+
use tower::ServiceBuilder;
48+
use tower_http::decompression::RequestDecompressionLayer;
49+
50+
use crate::intake;
51+
52+
/// Memory backstop on the compressed body buffered before decompression, above any Pyld05 spec limit
53+
const MAX_COMPRESSED_BODY_BYTES: usize = 64 * 1024 * 1024;
54+
55+
/// Wire measurements recorded before decompression, attached as a request extension for Pyld05/Pyld06/Pyld22
56+
#[derive(Clone, Copy, Debug)]
57+
pub(crate) struct Measurements {
58+
/// Compressed, on-the-wire body length, read before decompression.
59+
pub(crate) compressed_len: u64,
60+
/// Whether the request entered the decompression path.
61+
pub(crate) decompression_applied: bool,
62+
/// The declared `Content-Length`, or `None` when the header was absent.
63+
pub(crate) declared_content_length: Option<u64>,
64+
}
65+
66+
/// Build the intake router, `/api/v2/series` for payload assertions, others return 200 OK
67+
pub fn build_router(hostname: Arc<str>) -> Router {
68+
// Pyld01-Pyld06 and Pyld22 need the compressed body and raw headers, so the series
69+
// route runs `measure_compressed_size` outermost, then decompresses, then
70+
// lifts the body limit (the middleware's own cap is the backstop).
71+
let series = post(intake::handle_series).layer(
72+
ServiceBuilder::new()
73+
.layer(from_fn(measure_compressed_size))
74+
.layer(RequestDecompressionLayer::new().pass_through_unaccepted(true))
75+
.layer(DefaultBodyLimit::disable()),
76+
);
77+
78+
Router::new()
79+
.route("/api/v2/series", series)
80+
.fallback(|| async { StatusCode::OK })
81+
.with_state(hostname)
82+
}
83+
84+
/// Buffer the body and record compressed size, encoding, and content-length before decompression
85+
async fn measure_compressed_size(req: Request, next: Next) -> Response {
86+
let (parts, body) = req.into_parts();
87+
let Ok(bytes) = axum::body::to_bytes(body, MAX_COMPRESSED_BODY_BYTES).await else {
88+
return StatusCode::PAYLOAD_TOO_LARGE.into_response();
89+
};
90+
let len = bytes.len() as u64;
91+
let applied = parts
92+
.headers
93+
.typed_get::<ContentEncoding>()
94+
.is_some_and(|enc| enc.contains("deflate") || enc.contains("gzip") || enc.contains("zstd"));
95+
let declared = parts.headers.typed_get::<ContentLength>().map(|cl| cl.0);
96+
let mut req = Request::from_parts(parts, Body::from(bytes));
97+
req.extensions_mut().insert(Measurements {
98+
compressed_len: len,
99+
decompression_applied: applied,
100+
declared_content_length: declared,
101+
});
102+
next.run(req).await
103+
>>>>>>> 9c1abdeb85 (enhancement(antithesis): Introduce rig intake API (#1826))
27104
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
//! `/api/v2/series` handler and validation pipeline.
2+
//!
3+
//! `handle_series` fires every payload property's assertion, walks the envelope,
4+
//! byte-size, and decode checks in order, then returns the first failure status or
5+
//! `202 Accepted`.
6+
7+
use std::sync::Arc;
8+
use std::time::{SystemTime, UNIX_EPOCH};
9+
10+
use axum::{
11+
body::to_bytes,
12+
extract::{Request, State},
13+
http::StatusCode,
14+
response::{IntoResponse, Response},
15+
};
16+
use tracing::{debug, error};
17+
18+
use crate::http::Measurements;
19+
use crate::properties::payload::{bytes, envelope};
20+
use crate::series_observation::SeriesObservation;
21+
22+
/// Memory backstop on the decompressed body buffered in the handler, above the Pyld06 5 MiB spec limit
23+
const MAX_DECOMPRESSED_BODY_BYTES: usize = 64 * 1024 * 1024;
24+
25+
/// Reasons `handle_series` cannot evaluate a request.
26+
#[derive(Debug)]
27+
pub(crate) enum SeriesError {
28+
/// The measurement middleware did not record `Measurements` on the route.
29+
MissingMeasurements,
30+
/// The system clock predates the Unix epoch or overflows i64 seconds.
31+
Clock,
32+
/// Reading the request body failed, or the body overran the decompressed cap.
33+
Body(axum::Error),
34+
}
35+
36+
impl IntoResponse for SeriesError {
37+
fn into_response(self) -> Response {
38+
match self {
39+
Self::MissingMeasurements => {
40+
error!("Missing Measurements extension on /api/v2/series, measurement middleware is misconfigured.");
41+
StatusCode::INTERNAL_SERVER_ERROR
42+
}
43+
Self::Clock => {
44+
error!("System clock is not readable as seconds since the Unix epoch.");
45+
StatusCode::INTERNAL_SERVER_ERROR
46+
}
47+
Self::Body(e) => {
48+
// `to_bytes` errors on the size cap and on a read failure. Treat both
49+
// as oversized, matching the wire-side measurement middleware.
50+
error!(error = %e, cap = MAX_DECOMPRESSED_BODY_BYTES, "Rejected /api/v2/series body at the decompressed cap.");
51+
StatusCode::PAYLOAD_TOO_LARGE
52+
}
53+
}
54+
.into_response()
55+
}
56+
}
57+
58+
/// Handler for `POST /api/v2/series`.
59+
pub(crate) async fn handle_series(
60+
State(expected_hostname): State<Arc<str>>, request: Request,
61+
) -> Result<StatusCode, SeriesError> {
62+
// Pyld21 bounds points' timestamps against the intake wall clock at request receipt
63+
let now_secs = now_epoch_secs()?;
64+
let (parts, body) = request.into_parts();
65+
let &Measurements {
66+
compressed_len,
67+
decompression_applied,
68+
declared_content_length,
69+
} = parts
70+
.extensions
71+
.get::<Measurements>()
72+
.ok_or(SeriesError::MissingMeasurements)?;
73+
74+
let body_bytes = to_bytes(body, MAX_DECOMPRESSED_BODY_BYTES)
75+
.await
76+
.map_err(SeriesError::Body)?;
77+
78+
// Datadog Agent sends `{}` to probe connectivity, not a metric payload. The real
79+
// intake accepts the probe with 202. Match it rather than 200.
80+
if body_bytes.as_ref() == b"{}" {
81+
debug!("Received connectivity probe for /api/v2/series, returning 202 Accepted.");
82+
return Ok(StatusCode::ACCEPTED);
83+
}
84+
85+
let headers = parts.headers;
86+
let uncompressed_len = body_bytes.len() as u64;
87+
88+
// Envelope and byte-size properties.
89+
let api_key_ok = envelope::api_key(&headers);
90+
let content_type_ok = envelope::content_type(&headers);
91+
envelope::content_encoding(&headers);
92+
let compressed_ok = bytes::compressed_size(compressed_len);
93+
let uncompressed_ok = bytes::uncompressed_size(uncompressed_len, decompression_applied);
94+
bytes::content_length(declared_content_length, compressed_len);
95+
96+
let (observation, decode_ok) = SeriesObservation::decode(&body_bytes, decompression_applied);
97+
98+
if let Some(observation) = observation.as_ref() {
99+
observation.assert_payload_properties(now_secs, &expected_hostname);
100+
debug!(
101+
bytes = body_bytes.len(),
102+
series = observation.series_len(),
103+
"received /api/v2/series"
104+
);
105+
}
106+
107+
// Return the first failure status in pipeline order, or 202 Accepted.
108+
let failure = first_status_failure(&[
109+
(api_key_ok, StatusCode::FORBIDDEN),
110+
(content_type_ok, StatusCode::BAD_REQUEST),
111+
(compressed_ok, StatusCode::PAYLOAD_TOO_LARGE),
112+
(uncompressed_ok, StatusCode::PAYLOAD_TOO_LARGE),
113+
(decode_ok, StatusCode::BAD_REQUEST),
114+
]);
115+
Ok(failure.unwrap_or(StatusCode::ACCEPTED))
116+
}
117+
118+
/// Return the first failed status check, in the given pipeline order, or `None`
119+
/// when every check holds.
120+
fn first_status_failure(checks: &[(bool, StatusCode)]) -> Option<StatusCode> {
121+
checks.iter().find(|(ok, _)| !ok).map(|&(_, status)| status)
122+
}
123+
124+
/// Return the current time as whole seconds since the Unix epoch.
125+
///
126+
/// Returns `SeriesError::Clock` when the system clock predates the epoch or the second count
127+
/// overflows `i64`.
128+
fn now_epoch_secs() -> Result<i64, SeriesError> {
129+
let secs = SystemTime::now()
130+
.duration_since(UNIX_EPOCH)
131+
.map_err(|_| SeriesError::Clock)?
132+
.as_secs();
133+
i64::try_from(secs).map_err(|_| SeriesError::Clock)
134+
}

test/antithesis/intake/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,5 +37,9 @@
3737

3838
pub mod http;
3939

40+
<<<<<<< HEAD
41+
=======
42+
mod intake;
43+
>>>>>>> 9c1abdeb85 (enhancement(antithesis): Introduce rig intake API (#1826))
4044
mod properties;
4145
mod series_observation;

0 commit comments

Comments
 (0)