diff --git a/Cargo.lock b/Cargo.lock index aa7db151bbd..ee961e26d95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8622,6 +8622,7 @@ dependencies = [ "ereport-types", "expectorate", "fatfs", + "flate2", "futures", "gateway-client", "gateway-messages", diff --git a/nexus/Cargo.toml b/nexus/Cargo.toml index 0a24e5eb3e2..f22f45831e4 100644 --- a/nexus/Cargo.toml +++ b/nexus/Cargo.toml @@ -171,6 +171,7 @@ camino-tempfile.workspace = true criterion.workspace = true diesel.workspace = true dns-server.workspace = true +flate2.workspace = true expectorate.workspace = true gateway-messages.workspace = true gateway-test-utils.workspace = true diff --git a/nexus/examples/config-second.toml b/nexus/examples/config-second.toml index 3c1a1a3700a..5d97ecc36b1 100644 --- a/nexus/examples/config-second.toml +++ b/nexus/examples/config-second.toml @@ -55,6 +55,7 @@ external_dns_servers = ["1.1.1.1", "9.9.9.9"] # used by `omicron-dev run-all` bind_address = "127.0.0.1:12222" default_request_body_max_bytes = 1048576 +compression = "gzip" # To have Nexus's external HTTP endpoint use TLS, uncomment the line below. You # will also need to provide an initial TLS certificate during rack # initialization. If you're using this config file, you're probably running a diff --git a/nexus/examples/config.toml b/nexus/examples/config.toml index b4026bfb1de..4fe39a9f5a8 100644 --- a/nexus/examples/config.toml +++ b/nexus/examples/config.toml @@ -41,6 +41,7 @@ external_dns_servers = ["1.1.1.1", "9.9.9.9"] # IP Address and TCP port on which to listen for the external API bind_address = "127.0.0.1:12220" default_request_body_max_bytes = 1048576 +compression = "gzip" # To have Nexus's external HTTP endpoint use TLS, uncomment the line below. You # will also need to provide an initial TLS certificate during rack # initialization. If you're using this config file, you're probably running a diff --git a/nexus/test-utils/src/http_testing.rs b/nexus/test-utils/src/http_testing.rs index 50a9ccdcb83..8c622348e23 100644 --- a/nexus/test-utils/src/http_testing.rs +++ b/nexus/test-utils/src/http_testing.rs @@ -97,6 +97,8 @@ impl<'a> RequestBuilder<'a> { http::header::DATE, http::header::LOCATION, http::header::SET_COOKIE, + http::header::TRANSFER_ENCODING, + http::header::VARY, http::header::HeaderName::from_static("x-request-id"), ]), expected_response_headers: http::HeaderMap::default(), diff --git a/nexus/tests/config.test.toml b/nexus/tests/config.test.toml index a32a9b86081..9a603d438fc 100644 --- a/nexus/tests/config.test.toml +++ b/nexus/tests/config.test.toml @@ -51,6 +51,7 @@ external_dns_servers = ["1.1.1.1", "9.9.9.9"] # concurrently. bind_address = "127.0.0.1:0" default_request_body_max_bytes = 1048576 +compression = "gzip" [deployment.dropshot_internal] bind_address = "127.0.0.1:0" diff --git a/nexus/tests/integration_tests/basic.rs b/nexus/tests/integration_tests/basic.rs index 87c65816060..3cac5724ea3 100644 --- a/nexus/tests/integration_tests/basic.rs +++ b/nexus/tests/integration_tests/basic.rs @@ -557,3 +557,97 @@ async fn test_ping(cptestctx: &ControlPlaneTestContext) { .await; assert_eq!(health.status, system::PingStatus::Ok); } + +/// Test that the external API returns gzip-compressed responses when the +/// client sends Accept-Encoding: gzip. +#[nexus_test] +async fn test_gzip_compression(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + + // Create several projects so the response body exceeds the minimum + // compression threshold (512 bytes). + for i in 0..10 { + create_project(&client, &format!("project-{i}")).await; + } + + // With Accept-Encoding: gzip, response should be compressed. + let response = NexusRequest::new( + RequestBuilder::new(client, Method::GET, "/v1/projects") + .header(http::header::ACCEPT_ENCODING, "gzip") + .expect_status(Some(StatusCode::OK)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap(); + + assert_eq!( + response.headers.get(http::header::CONTENT_ENCODING).unwrap(), + "gzip", + ); + + // Decompress and verify the body is valid JSON. + let compressed_len = response.body.len(); + let mut decoder = flate2::read::GzDecoder::new(&response.body[..]); + let mut decompressed = String::new(); + std::io::Read::read_to_string(&mut decoder, &mut decompressed).unwrap(); + let page: dropshot::ResultsPage = + serde_json::from_str(&decompressed).unwrap(); + assert_eq!(page.items.len(), 10); + + // Without Accept-Encoding: gzip, response should not be compressed. + let response = NexusRequest::object_get(client, "/v1/projects") + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap(); + assert!(response.headers.get(http::header::CONTENT_ENCODING).is_none()); + + let uncompressed_len = response.body.len(); + assert!( + compressed_len < uncompressed_len, + "compressed body ({compressed_len} bytes) should be smaller \ + than uncompressed body ({uncompressed_len} bytes)" + ); + + // Accept-Encoding: gzip;q=0 explicitly refuses gzip, so the response + // should not be compressed even though the client mentions gzip. This + // verifies that q-value parsing is wired up correctly through the + // dropshot config. + let response = NexusRequest::new( + RequestBuilder::new(client, Method::GET, "/v1/projects") + .header(http::header::ACCEPT_ENCODING, "gzip;q=0") + .expect_status(Some(StatusCode::OK)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap(); + assert!(response.headers.get(http::header::CONTENT_ENCODING).is_none()); + + // Even when a response is too small to compress, dropshot should still + // set Vary: Accept-Encoding on a compressible content type so caches + // behave correctly. /v1/ping returns a tiny JSON body well under the + // 512-byte compression threshold. + let response = NexusRequest::new( + RequestBuilder::new(client, Method::GET, "/v1/ping") + .header(http::header::ACCEPT_ENCODING, "gzip") + .expect_status(Some(StatusCode::OK)), + ) + .execute() + .await + .unwrap(); + assert!(response.headers.get(http::header::CONTENT_ENCODING).is_none()); + let vary_values: Vec<_> = response + .headers + .get_all(http::header::VARY) + .iter() + .filter_map(|v| v.to_str().ok()) + .collect(); + assert!( + vary_values.iter().any(|v| v + .split(',') + .any(|entry| entry.trim().eq_ignore_ascii_case("accept-encoding"))), + "expected Vary: Accept-Encoding on small uncompressed response, got {vary_values:?}", + ); +} diff --git a/sled-agent/src/services.rs b/sled-agent/src/services.rs index 2bbf2e5d244..000e5104ba2 100644 --- a/sled-agent/src/services.rs +++ b/sled-agent/src/services.rs @@ -2409,7 +2409,7 @@ impl ServiceManager { default_handler_task_mode: HandlerTaskMode::Detached, log_headers: vec![], - compression: dropshot::CompressionConfig::None, + compression: dropshot::CompressionConfig::Gzip, }, }, dropshot_internal: dropshot::ConfigDropshot {