From db982caca6aa9e7c1773b100570188bc8998e0c1 Mon Sep 17 00:00:00 2001 From: Nina Rei Date: Tue, 29 Sep 2026 09:56:33 -0400 Subject: [PATCH 1/6] feat(compat): add GCP inventory collection --- crates/inventory/Cargo.toml | 4 + crates/inventory/src/platform/gcp.rs | 295 +++++++++++++++++++++++++++ crates/inventory/src/platform/mod.rs | 10 +- 3 files changed, 306 insertions(+), 3 deletions(-) create mode 100644 crates/inventory/src/platform/gcp.rs diff --git a/crates/inventory/Cargo.toml b/crates/inventory/Cargo.toml index f72ce27f..0c0ca780 100644 --- a/crates/inventory/Cargo.toml +++ b/crates/inventory/Cargo.toml @@ -9,6 +9,10 @@ license.workspace = true description = "Fleet Automation inventory reporter for the Serverless Compat" [dependencies] +datadog-fips = { path = "../datadog-fips" } libdd-common = { workspace = true } libdd-trace-utils = { workspace = true } +reqwest = { version = "0.12", default-features = false } serde_json = { version = "1.0", default-features = false, features = ["alloc"] } +tokio = { version = "1", features = ["macros", "rt"] } +tracing = "0.1" diff --git a/crates/inventory/src/platform/gcp.rs b/crates/inventory/src/platform/gcp.rs new file mode 100644 index 00000000..9acbdb03 --- /dev/null +++ b/crates/inventory/src/platform/gcp.rs @@ -0,0 +1,295 @@ +// Copyright 2023-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use super::PlatformData; +use datadog_fips::reqwest_adapter::create_reqwest_client_builder; +use serde_json::{Map, Value}; +use std::{env, time::Duration}; +use tracing::{debug, warn}; + +const METADATA_BASE_URL: &str = "http://metadata.google.internal/computeMetadata/v1"; + +struct Identity { + name: String, + region: Option, + project: Option, +} + +pub(super) async fn collect() -> Option { + let mut identity = identity_from_env()?; + + match (&identity.region, &identity.project) { + (None, None) => { + let (region, project) = tokio::join!(fetch_region(), fetch_project()); + identity.region = region; + identity.project = project; + } + (None, Some(_)) => identity.region = fetch_region().await, + (Some(_), None) => identity.project = fetch_project().await, + (Some(_), Some(_)) => {} + } + + build_platform_data(identity) +} + +fn build_platform_data(identity: Identity) -> Option { + let region = identity.region?; + let project = identity.project?; + let resource_id = format!( + "//cloudfunctions.googleapis.com/projects/{project}/locations/{region}/functions/{}", + identity.name + ); + + let mut metadata = Map::new(); + metadata.insert("region".into(), Value::String(region)); + metadata.insert("gcp_project_id".into(), Value::String(project)); + + if let Some((runtime, version)) = detect_runtime() { + metadata.insert("runtime".into(), Value::String(runtime.into())); + metadata.insert( + "serverless_compat_runtime_version".into(), + Value::String(version), + ); + } + + Some(PlatformData { + workload_type: "cloud_function", + resource_id, + resource_name: identity.name, + metadata, + }) +} + +fn identity_from_env() -> Option { + // The compat binary is only installed in Gen1 Cloud Functions. Newer Gen1 + // runtimes can expose FUNCTION_TARGET and K_SERVICE because they run on + // Cloud Run infrastructure, so FUNCTION_TARGET cannot distinguish Gen2. + let name = first_env(&["FUNCTION_NAME", "K_SERVICE"])?; + + Some(Identity { + name, + region: first_env(&["FUNCTION_REGION", "GOOGLE_CLOUD_REGION", "REGION_NAME"]), + project: first_env(&["GCP_PROJECT", "GCLOUD_PROJECT", "GOOGLE_CLOUD_PROJECT"]), + }) +} + +fn first_env(names: &[&str]) -> Option { + names.iter().find_map(|name| { + env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + }) +} + +fn detect_runtime() -> Option<(&'static str, String)> { + for (runtime, variable) in [ + ("node", "NODE_VERSION"), + ("python", "PYTHON_VERSION"), + ("java", "JAVA_VERSION"), + ("go", "GO_VERSION"), + ] { + if let Some(version) = first_env(&[variable]) { + return Some((runtime, version)); + } + } + None +} + +async fn fetch_metadata_value( + path: &str, + label: &str, + parse: impl FnOnce(&str) -> Option, +) -> Option { + let client = match create_reqwest_client_builder().and_then(|builder| { + builder + .timeout(Duration::from_secs(2)) + .build() + .map_err(Into::into) + }) { + Ok(client) => client, + Err(error) => { + warn!("inventory: failed to create GCP metadata client for {label}: {error}"); + return None; + } + }; + + let response = match client + .get(format!("{METADATA_BASE_URL}/{path}")) + .header("Metadata-Flavor", "Google") + .send() + .await + { + Ok(response) => response, + Err(error) => { + warn!("inventory: failed to fetch GCP metadata {label}: {error}"); + return None; + } + }; + + if !response.status().is_success() { + warn!( + "inventory: GCP metadata server returned {} for {label}", + response.status() + ); + return None; + } + + let body = match response.text().await { + Ok(body) => body, + Err(error) => { + warn!("inventory: failed to read GCP metadata {label}: {error}"); + return None; + } + }; + let value = parse(body.trim()); + debug!("inventory: GCP metadata server {label}: {value:?}"); + value +} + +async fn fetch_region() -> Option { + // Response: projects//regions/ + fetch_metadata_value("instance/region", "region", |body| { + body.split('/') + .next_back() + .filter(|value| !value.is_empty()) + .map(str::to_string) + }) + .await +} + +async fn fetch_project() -> Option { + fetch_metadata_value("project/project-id", "project-id", |body| { + (!body.is_empty()).then(|| body.to_string()) + }) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::platform::ENV_LOCK; + use std::sync::MutexGuard; + + const GCP_ENV: &[&str] = &[ + "FUNCTION_NAME", + "K_SERVICE", + "FUNCTION_TARGET", + "FUNCTION_REGION", + "GOOGLE_CLOUD_REGION", + "REGION_NAME", + "GCP_PROJECT", + "GCLOUD_PROJECT", + "GOOGLE_CLOUD_PROJECT", + "NODE_VERSION", + "PYTHON_VERSION", + "JAVA_VERSION", + "GO_VERSION", + ]; + + fn clean_env() -> MutexGuard<'static, ()> { + let lock = ENV_LOCK.lock().unwrap(); + for variable in GCP_ENV { + unsafe { env::remove_var(variable) }; + } + lock + } + + #[test] + fn reads_complete_gen1_identity() { + let _lock = clean_env(); + unsafe { + env::set_var("FUNCTION_NAME", "my-fn"); + env::set_var("FUNCTION_REGION", "us-central1"); + env::set_var("GCP_PROJECT", "my-project"); + } + + let identity = identity_from_env().unwrap(); + assert_eq!(identity.name, "my-fn"); + assert_eq!(identity.region.as_deref(), Some("us-central1")); + assert_eq!(identity.project.as_deref(), Some("my-project")); + } + + #[test] + fn builds_complete_inventory_data() { + let _lock = clean_env(); + unsafe { + env::set_var("FUNCTION_NAME", "my-fn"); + env::set_var("FUNCTION_REGION", "us-central1"); + env::set_var("GCP_PROJECT", "my-project"); + env::set_var("PYTHON_VERSION", "3.12.7"); + } + + let data = build_platform_data(identity_from_env().unwrap()).unwrap(); + assert_eq!(data.workload_type, "cloud_function"); + assert_eq!( + data.resource_id, + "//cloudfunctions.googleapis.com/projects/my-project/locations/us-central1/functions/my-fn" + ); + assert_eq!(data.metadata["runtime"], "python"); + assert_eq!(data.metadata["serverless_compat_runtime_version"], "3.12.7"); + } + + #[test] + fn uses_region_and_project_fallbacks() { + let _lock = clean_env(); + unsafe { + env::set_var("FUNCTION_NAME", "my-fn"); + env::set_var("GOOGLE_CLOUD_REGION", "europe-west1"); + env::set_var("GOOGLE_CLOUD_PROJECT", "my-project"); + } + + let identity = identity_from_env().unwrap(); + assert_eq!(identity.region.as_deref(), Some("europe-west1")); + assert_eq!(identity.project.as_deref(), Some("my-project")); + } + + #[test] + fn newer_gen1_runtime_uses_k_service() { + let _lock = clean_env(); + unsafe { + env::set_var("K_SERVICE", "my-service"); + env::set_var("FUNCTION_TARGET", "my-handler"); + } + + let identity = identity_from_env().unwrap(); + assert_eq!(identity.name, "my-service"); + } + + #[test] + fn missing_name_is_not_an_inventory_workload() { + let _lock = clean_env(); + unsafe { + env::set_var("FUNCTION_REGION", "us-central1"); + env::set_var("GCP_PROJECT", "my-project"); + } + + assert!(identity_from_env().is_none()); + } + + #[test] + fn incomplete_identity_is_retained_for_metadata_lookup() { + let _lock = clean_env(); + unsafe { env::set_var("FUNCTION_NAME", "my-fn") }; + + let identity = identity_from_env().unwrap(); + assert_eq!(identity.name, "my-fn"); + assert!(identity.region.is_none()); + assert!(identity.project.is_none()); + } + + #[test] + fn detects_runtime_from_platform_environment() { + let _lock = clean_env(); + unsafe { env::set_var("PYTHON_VERSION", "3.12.7") }; + + assert_eq!(detect_runtime(), Some(("python", "3.12.7".into()))); + } + + #[test] + fn parses_metadata_region() { + let body = "projects/123456/regions/us-central1"; + let region = body.split('/').next_back().map(str::to_string); + assert_eq!(region.as_deref(), Some("us-central1")); + } +} diff --git a/crates/inventory/src/platform/mod.rs b/crates/inventory/src/platform/mod.rs index f424e5c7..946de83b 100644 --- a/crates/inventory/src/platform/mod.rs +++ b/crates/inventory/src/platform/mod.rs @@ -2,10 +2,15 @@ // SPDX-License-Identifier: Apache-2.0 mod azure; +mod gcp; use libdd_trace_utils::trace_utils::EnvironmentType; use serde_json::{Map, Value}; +#[cfg(test)] +pub(crate) static ENV_LOCK: std::sync::LazyLock> = + std::sync::LazyLock::new(|| std::sync::Mutex::new(())); + pub(crate) struct PlatformData { pub workload_type: &'static str, pub resource_id: String, @@ -16,8 +21,7 @@ pub(crate) struct PlatformData { pub(crate) async fn collect(env_type: &EnvironmentType) -> Option { match env_type { EnvironmentType::AzureFunction => azure::collect(), - EnvironmentType::CloudFunction - | EnvironmentType::LambdaFunction - | EnvironmentType::AzureSpringApp => None, + EnvironmentType::CloudFunction => gcp::collect().await, + EnvironmentType::LambdaFunction | EnvironmentType::AzureSpringApp => None, } } From 9ba7e9aa5bad40bfb002300c3da4555248565378 Mon Sep 17 00:00:00 2001 From: Nina Rei Date: Tue, 29 Sep 2026 15:48:53 -0400 Subject: [PATCH 2/6] fix(compat): harden GCP inventory metadata collection --- Cargo.lock | 23 +- crates/inventory/Cargo.toml | 3 + crates/inventory/src/platform/gcp.rs | 324 ++++++++++++++++++--------- 3 files changed, 235 insertions(+), 115 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 835a579b..767b4d57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -656,6 +656,20 @@ dependencies = [ "zstd", ] +[[package]] +name = "datadog-serverless-compat-inventory" +version = "0.1.0" +dependencies = [ + "datadog-fips", + "httpmock", + "libdd-common", + "libdd-trace-utils", + "reqwest", + "serde_json", + "tokio", + "tracing", +] + [[package]] name = "datadog-trace-agent" version = "0.1.0" @@ -1550,15 +1564,6 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" -[[package]] -name = "inventory" -version = "0.1.0" -dependencies = [ - "libdd-common", - "libdd-trace-utils", - "serde_json", -] - [[package]] name = "ipnet" version = "2.12.0" diff --git a/crates/inventory/Cargo.toml b/crates/inventory/Cargo.toml index 0c0ca780..75720554 100644 --- a/crates/inventory/Cargo.toml +++ b/crates/inventory/Cargo.toml @@ -16,3 +16,6 @@ reqwest = { version = "0.12", default-features = false } serde_json = { version = "1.0", default-features = false, features = ["alloc"] } tokio = { version = "1", features = ["macros", "rt"] } tracing = "0.1" + +[dev-dependencies] +httpmock = "0.8" diff --git a/crates/inventory/src/platform/gcp.rs b/crates/inventory/src/platform/gcp.rs index 9acbdb03..0ba90caf 100644 --- a/crates/inventory/src/platform/gcp.rs +++ b/crates/inventory/src/platform/gcp.rs @@ -2,12 +2,15 @@ // SPDX-License-Identifier: Apache-2.0 use super::PlatformData; +use crate::ProcessEnv; use datadog_fips::reqwest_adapter::create_reqwest_client_builder; +use libdd_common::azure_app_services::QueryEnv; use serde_json::{Map, Value}; -use std::{env, time::Duration}; +use std::time::Duration; use tracing::{debug, warn}; const METADATA_BASE_URL: &str = "http://metadata.google.internal/computeMetadata/v1"; +const MAX_METADATA_RESPONSE_BYTES: usize = 1024; struct Identity { name: String, @@ -16,23 +19,37 @@ struct Identity { } pub(super) async fn collect() -> Option { - let mut identity = identity_from_env()?; + collect_from(ProcessEnv).await +} + +async fn collect_from(env: E) -> Option { + collect_from_with_metadata_base(env, METADATA_BASE_URL).await +} + +async fn collect_from_with_metadata_base( + env: E, + metadata_base_url: &str, +) -> Option { + let mut identity = identity_from_env(&env)?; match (&identity.region, &identity.project) { (None, None) => { - let (region, project) = tokio::join!(fetch_region(), fetch_project()); + let (region, project) = tokio::join!( + fetch_region(metadata_base_url), + fetch_project(metadata_base_url) + ); identity.region = region; identity.project = project; } - (None, Some(_)) => identity.region = fetch_region().await, - (Some(_), None) => identity.project = fetch_project().await, + (None, Some(_)) => identity.region = fetch_region(metadata_base_url).await, + (Some(_), None) => identity.project = fetch_project(metadata_base_url).await, (Some(_), Some(_)) => {} } - build_platform_data(identity) + build_platform_data(identity, &env) } -fn build_platform_data(identity: Identity) -> Option { +fn build_platform_data(identity: Identity, env: &impl QueryEnv) -> Option { let region = identity.region?; let project = identity.project?; let resource_id = format!( @@ -44,7 +61,7 @@ fn build_platform_data(identity: Identity) -> Option { metadata.insert("region".into(), Value::String(region)); metadata.insert("gcp_project_id".into(), Value::String(project)); - if let Some((runtime, version)) = detect_runtime() { + if let Some((runtime, version)) = detect_runtime(env) { metadata.insert("runtime".into(), Value::String(runtime.into())); metadata.insert( "serverless_compat_runtime_version".into(), @@ -60,36 +77,39 @@ fn build_platform_data(identity: Identity) -> Option { }) } -fn identity_from_env() -> Option { +fn identity_from_env(env: &impl QueryEnv) -> Option { // The compat binary is only installed in Gen1 Cloud Functions. Newer Gen1 // runtimes can expose FUNCTION_TARGET and K_SERVICE because they run on // Cloud Run infrastructure, so FUNCTION_TARGET cannot distinguish Gen2. - let name = first_env(&["FUNCTION_NAME", "K_SERVICE"])?; + let name = first_env(env, &["FUNCTION_NAME", "K_SERVICE"])?; Some(Identity { name, - region: first_env(&["FUNCTION_REGION", "GOOGLE_CLOUD_REGION", "REGION_NAME"]), - project: first_env(&["GCP_PROJECT", "GCLOUD_PROJECT", "GOOGLE_CLOUD_PROJECT"]), + region: first_env( + env, + &["FUNCTION_REGION", "GOOGLE_CLOUD_REGION", "REGION_NAME"], + ), + project: first_env( + env, + &["GCP_PROJECT", "GCLOUD_PROJECT", "GOOGLE_CLOUD_PROJECT"], + ), }) } -fn first_env(names: &[&str]) -> Option { - names.iter().find_map(|name| { - env::var(name) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - }) +fn first_env(env: &impl QueryEnv, names: &[&str]) -> Option { + names + .iter() + .find_map(|name| env.get_var(name).filter(|value| !value.is_empty())) } -fn detect_runtime() -> Option<(&'static str, String)> { +fn detect_runtime(env: &impl QueryEnv) -> Option<(&'static str, String)> { for (runtime, variable) in [ ("node", "NODE_VERSION"), ("python", "PYTHON_VERSION"), ("java", "JAVA_VERSION"), ("go", "GO_VERSION"), ] { - if let Some(version) = first_env(&[variable]) { + if let Some(version) = first_env(env, &[variable]) { return Some((runtime, version)); } } @@ -97,12 +117,15 @@ fn detect_runtime() -> Option<(&'static str, String)> { } async fn fetch_metadata_value( + base_url: &str, path: &str, label: &str, parse: impl FnOnce(&str) -> Option, ) -> Option { let client = match create_reqwest_client_builder().and_then(|builder| { builder + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) .timeout(Duration::from_secs(2)) .build() .map_err(Into::into) @@ -114,8 +137,8 @@ async fn fetch_metadata_value( } }; - let response = match client - .get(format!("{METADATA_BASE_URL}/{path}")) + let mut response = match client + .get(format!("{}/{path}", base_url.trim_end_matches('/'))) .header("Metadata-Flavor", "Google") .send() .await @@ -135,10 +158,45 @@ async fn fetch_metadata_value( return None; } - let body = match response.text().await { + let response_flavor = response + .headers() + .get("Metadata-Flavor") + .and_then(|value| value.to_str().ok()); + if response_flavor != Some("Google") { + warn!("inventory: GCP metadata response missing Metadata-Flavor header for {label}"); + return None; + } + + if response + .content_length() + .is_some_and(|length| length > MAX_METADATA_RESPONSE_BYTES as u64) + { + warn!("inventory: GCP metadata response too large for {label}"); + return None; + } + + let mut body = Vec::new(); + loop { + match response.chunk().await { + Ok(Some(chunk)) if body.len() + chunk.len() <= MAX_METADATA_RESPONSE_BYTES => { + body.extend_from_slice(&chunk); + } + Ok(Some(_)) => { + warn!("inventory: GCP metadata response too large for {label}"); + return None; + } + Ok(None) => break, + Err(error) => { + warn!("inventory: failed to read GCP metadata {label}: {error}"); + return None; + } + } + } + + let body = match std::str::from_utf8(&body) { Ok(body) => body, Err(error) => { - warn!("inventory: failed to read GCP metadata {label}: {error}"); + warn!("inventory: GCP metadata {label} was not UTF-8: {error}"); return None; } }; @@ -147,9 +205,9 @@ async fn fetch_metadata_value( value } -async fn fetch_region() -> Option { +async fn fetch_region(base_url: &str) -> Option { // Response: projects//regions/ - fetch_metadata_value("instance/region", "region", |body| { + fetch_metadata_value(base_url, "instance/region", "region", |body| { body.split('/') .next_back() .filter(|value| !value.is_empty()) @@ -158,8 +216,8 @@ async fn fetch_region() -> Option { .await } -async fn fetch_project() -> Option { - fetch_metadata_value("project/project-id", "project-id", |body| { +async fn fetch_project(base_url: &str) -> Option { + fetch_metadata_value(base_url, "project/project-id", "project-id", |body| { (!body.is_empty()).then(|| body.to_string()) }) .await @@ -168,43 +226,17 @@ async fn fetch_project() -> Option { #[cfg(test)] mod tests { use super::*; - use crate::platform::ENV_LOCK; - use std::sync::MutexGuard; - - const GCP_ENV: &[&str] = &[ - "FUNCTION_NAME", - "K_SERVICE", - "FUNCTION_TARGET", - "FUNCTION_REGION", - "GOOGLE_CLOUD_REGION", - "REGION_NAME", - "GCP_PROJECT", - "GCLOUD_PROJECT", - "GOOGLE_CLOUD_PROJECT", - "NODE_VERSION", - "PYTHON_VERSION", - "JAVA_VERSION", - "GO_VERSION", - ]; - - fn clean_env() -> MutexGuard<'static, ()> { - let lock = ENV_LOCK.lock().unwrap(); - for variable in GCP_ENV { - unsafe { env::remove_var(variable) }; - } - lock - } + use crate::test_env::FakeEnv; + use httpmock::prelude::*; #[test] fn reads_complete_gen1_identity() { - let _lock = clean_env(); - unsafe { - env::set_var("FUNCTION_NAME", "my-fn"); - env::set_var("FUNCTION_REGION", "us-central1"); - env::set_var("GCP_PROJECT", "my-project"); - } - - let identity = identity_from_env().unwrap(); + let env = FakeEnv::new(&[ + ("FUNCTION_NAME", "my-fn"), + ("FUNCTION_REGION", "us-central1"), + ("GCP_PROJECT", "my-project"), + ]); + let identity = identity_from_env(&env).unwrap(); assert_eq!(identity.name, "my-fn"); assert_eq!(identity.region.as_deref(), Some("us-central1")); assert_eq!(identity.project.as_deref(), Some("my-project")); @@ -212,15 +244,13 @@ mod tests { #[test] fn builds_complete_inventory_data() { - let _lock = clean_env(); - unsafe { - env::set_var("FUNCTION_NAME", "my-fn"); - env::set_var("FUNCTION_REGION", "us-central1"); - env::set_var("GCP_PROJECT", "my-project"); - env::set_var("PYTHON_VERSION", "3.12.7"); - } - - let data = build_platform_data(identity_from_env().unwrap()).unwrap(); + let env = FakeEnv::new(&[ + ("FUNCTION_NAME", "my-fn"), + ("FUNCTION_REGION", "us-central1"), + ("GCP_PROJECT", "my-project"), + ("PYTHON_VERSION", "3.12.7"), + ]); + let data = build_platform_data(identity_from_env(&env).unwrap(), &env).unwrap(); assert_eq!(data.workload_type, "cloud_function"); assert_eq!( data.resource_id, @@ -232,47 +262,39 @@ mod tests { #[test] fn uses_region_and_project_fallbacks() { - let _lock = clean_env(); - unsafe { - env::set_var("FUNCTION_NAME", "my-fn"); - env::set_var("GOOGLE_CLOUD_REGION", "europe-west1"); - env::set_var("GOOGLE_CLOUD_PROJECT", "my-project"); - } - - let identity = identity_from_env().unwrap(); + let env = FakeEnv::new(&[ + ("FUNCTION_NAME", "my-fn"), + ("GOOGLE_CLOUD_REGION", "europe-west1"), + ("GOOGLE_CLOUD_PROJECT", "my-project"), + ]); + let identity = identity_from_env(&env).unwrap(); assert_eq!(identity.region.as_deref(), Some("europe-west1")); assert_eq!(identity.project.as_deref(), Some("my-project")); } #[test] fn newer_gen1_runtime_uses_k_service() { - let _lock = clean_env(); - unsafe { - env::set_var("K_SERVICE", "my-service"); - env::set_var("FUNCTION_TARGET", "my-handler"); - } - - let identity = identity_from_env().unwrap(); + let env = FakeEnv::new(&[ + ("K_SERVICE", "my-service"), + ("FUNCTION_TARGET", "my-handler"), + ]); + let identity = identity_from_env(&env).unwrap(); assert_eq!(identity.name, "my-service"); } #[test] fn missing_name_is_not_an_inventory_workload() { - let _lock = clean_env(); - unsafe { - env::set_var("FUNCTION_REGION", "us-central1"); - env::set_var("GCP_PROJECT", "my-project"); - } - - assert!(identity_from_env().is_none()); + let env = FakeEnv::new(&[ + ("FUNCTION_REGION", "us-central1"), + ("GCP_PROJECT", "my-project"), + ]); + assert!(identity_from_env(&env).is_none()); } #[test] fn incomplete_identity_is_retained_for_metadata_lookup() { - let _lock = clean_env(); - unsafe { env::set_var("FUNCTION_NAME", "my-fn") }; - - let identity = identity_from_env().unwrap(); + let env = FakeEnv::new(&[("FUNCTION_NAME", "my-fn")]); + let identity = identity_from_env(&env).unwrap(); assert_eq!(identity.name, "my-fn"); assert!(identity.region.is_none()); assert!(identity.project.is_none()); @@ -280,16 +302,106 @@ mod tests { #[test] fn detects_runtime_from_platform_environment() { - let _lock = clean_env(); - unsafe { env::set_var("PYTHON_VERSION", "3.12.7") }; + let env = FakeEnv::new(&[("PYTHON_VERSION", "3.12.7")]); + assert_eq!(detect_runtime(&env), Some(("python", "3.12.7".into()))); + } + + #[tokio::test] + async fn completes_identity_from_metadata_server() { + let server = MockServer::start_async().await; + let region = server + .mock_async(|when, then| { + when.method(GET) + .path("/instance/region") + .header("Metadata-Flavor", "Google"); + then.status(200) + .header("Metadata-Flavor", "Google") + .body("projects/123/regions/us-central1\n"); + }) + .await; + let project = server + .mock_async(|when, then| { + when.method(GET) + .path("/project/project-id") + .header("Metadata-Flavor", "Google"); + then.status(200) + .header("Metadata-Flavor", "Google") + .body("my-project\n"); + }) + .await; + + let data = collect_from_with_metadata_base( + FakeEnv::new(&[("FUNCTION_NAME", "my-fn")]), + &server.base_url(), + ) + .await + .expect("metadata should complete the identity"); - assert_eq!(detect_runtime(), Some(("python", "3.12.7".into()))); + assert_eq!(data.metadata["region"], "us-central1"); + assert_eq!(data.metadata["gcp_project_id"], "my-project"); + region.assert_async().await; + project.assert_async().await; } - #[test] - fn parses_metadata_region() { - let body = "projects/123456/regions/us-central1"; - let region = body.split('/').next_back().map(str::to_string); - assert_eq!(region.as_deref(), Some("us-central1")); + #[tokio::test] + async fn rejects_untrusted_or_oversized_metadata_responses() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.path("/missing-header"); + then.status(200).body("my-project"); + }) + .await; + server + .mock_async(|when, then| { + when.path("/oversized"); + then.status(200) + .header("Metadata-Flavor", "Google") + .body("x".repeat(MAX_METADATA_RESPONSE_BYTES + 1)); + }) + .await; + + assert!( + fetch_metadata_value(&server.base_url(), "missing-header", "test", |body| { + Some(body.to_string()) + }) + .await + .is_none() + ); + assert!( + fetch_metadata_value(&server.base_url(), "oversized", "test", |body| { + Some(body.to_string()) + }) + .await + .is_none() + ); + } + + #[tokio::test] + async fn does_not_follow_metadata_redirects() { + let server = MockServer::start_async().await; + let target = server + .mock_async(|when, then| { + when.path("/target"); + then.status(200) + .header("Metadata-Flavor", "Google") + .body("my-project"); + }) + .await; + server + .mock_async(|when, then| { + when.path("/redirect"); + then.status(307).header("Location", server.url("/target")); + }) + .await; + + assert!( + fetch_metadata_value(&server.base_url(), "redirect", "test", |body| { + Some(body.to_string()) + }) + .await + .is_none() + ); + target.assert_calls_async(0).await; } } From 6825acebd3953f9375d735fd76bb5e317d3e1358 Mon Sep 17 00:00:00 2001 From: Nina Rei Date: Mon, 5 Oct 2026 16:10:12 -0400 Subject: [PATCH 3/6] fix(compat): address remaining GCP inventory feedback --- Cargo.lock | 27 +++++++++++++-------------- crates/inventory/Cargo.toml | 1 - crates/inventory/src/platform/gcp.rs | 20 ++++++++++---------- crates/inventory/src/platform/mod.rs | 4 ---- 4 files changed, 23 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 767b4d57..0782b8f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -656,20 +656,6 @@ dependencies = [ "zstd", ] -[[package]] -name = "datadog-serverless-compat-inventory" -version = "0.1.0" -dependencies = [ - "datadog-fips", - "httpmock", - "libdd-common", - "libdd-trace-utils", - "reqwest", - "serde_json", - "tokio", - "tracing", -] - [[package]] name = "datadog-trace-agent" version = "0.1.0" @@ -1564,6 +1550,19 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" +[[package]] +name = "inventory" +version = "0.1.0" +dependencies = [ + "httpmock", + "libdd-common", + "libdd-trace-utils", + "reqwest", + "serde_json", + "tokio", + "tracing", +] + [[package]] name = "ipnet" version = "2.12.0" diff --git a/crates/inventory/Cargo.toml b/crates/inventory/Cargo.toml index 75720554..c6a9dd85 100644 --- a/crates/inventory/Cargo.toml +++ b/crates/inventory/Cargo.toml @@ -9,7 +9,6 @@ license.workspace = true description = "Fleet Automation inventory reporter for the Serverless Compat" [dependencies] -datadog-fips = { path = "../datadog-fips" } libdd-common = { workspace = true } libdd-trace-utils = { workspace = true } reqwest = { version = "0.12", default-features = false } diff --git a/crates/inventory/src/platform/gcp.rs b/crates/inventory/src/platform/gcp.rs index 0ba90caf..d8b85212 100644 --- a/crates/inventory/src/platform/gcp.rs +++ b/crates/inventory/src/platform/gcp.rs @@ -1,9 +1,8 @@ -// Copyright 2023-Present Datadog, Inc. https://www.datadoghq.com/ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ // SPDX-License-Identifier: Apache-2.0 use super::PlatformData; use crate::ProcessEnv; -use datadog_fips::reqwest_adapter::create_reqwest_client_builder; use libdd_common::azure_app_services::QueryEnv; use serde_json::{Map, Value}; use std::time::Duration; @@ -122,14 +121,15 @@ async fn fetch_metadata_value( label: &str, parse: impl FnOnce(&str) -> Option, ) -> Option { - let client = match create_reqwest_client_builder().and_then(|builder| { - builder - .no_proxy() - .redirect(reqwest::redirect::Policy::none()) - .timeout(Duration::from_secs(2)) - .build() - .map_err(Into::into) - }) { + // This client is scoped to GCP's fixed, plain-HTTP metadata endpoint, so it + // neither needs nor should configure a TLS provider. + #[allow(clippy::disallowed_methods)] + let client = match reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(2)) + .build() + { Ok(client) => client, Err(error) => { warn!("inventory: failed to create GCP metadata client for {label}: {error}"); diff --git a/crates/inventory/src/platform/mod.rs b/crates/inventory/src/platform/mod.rs index 946de83b..e23c2c46 100644 --- a/crates/inventory/src/platform/mod.rs +++ b/crates/inventory/src/platform/mod.rs @@ -7,10 +7,6 @@ mod gcp; use libdd_trace_utils::trace_utils::EnvironmentType; use serde_json::{Map, Value}; -#[cfg(test)] -pub(crate) static ENV_LOCK: std::sync::LazyLock> = - std::sync::LazyLock::new(|| std::sync::Mutex::new(())); - pub(crate) struct PlatformData { pub workload_type: &'static str, pub resource_id: String, From f6cee5f88726ceb395b820961b5027c1edcbef05 Mon Sep 17 00:00:00 2001 From: Nina Rei Date: Mon, 5 Oct 2026 20:03:30 -0400 Subject: [PATCH 4/6] refactor(compat): reuse GCP metadata client --- crates/inventory/src/lib.rs | 2 +- crates/inventory/src/platform/gcp.rs | 94 +++++++++++++++++----------- 2 files changed, 58 insertions(+), 38 deletions(-) diff --git a/crates/inventory/src/lib.rs b/crates/inventory/src/lib.rs index 377653e3..8d7836f3 100644 --- a/crates/inventory/src/lib.rs +++ b/crates/inventory/src/lib.rs @@ -4,7 +4,7 @@ mod payload; mod platform; -use libdd_common::azure_app_services::QueryEnv; +pub(crate) use libdd_common::azure_app_services::QueryEnv; use libdd_trace_utils::trace_utils::EnvironmentType; use std::env; diff --git a/crates/inventory/src/platform/gcp.rs b/crates/inventory/src/platform/gcp.rs index d8b85212..4e8b816b 100644 --- a/crates/inventory/src/platform/gcp.rs +++ b/crates/inventory/src/platform/gcp.rs @@ -2,8 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use super::PlatformData; -use crate::ProcessEnv; -use libdd_common::azure_app_services::QueryEnv; +use crate::{ProcessEnv, QueryEnv}; use serde_json::{Map, Value}; use std::time::Duration; use tracing::{debug, warn}; @@ -31,18 +30,22 @@ async fn collect_from_with_metadata_base( ) -> Option { let mut identity = identity_from_env(&env)?; - match (&identity.region, &identity.project) { - (None, None) => { - let (region, project) = tokio::join!( - fetch_region(metadata_base_url), - fetch_project(metadata_base_url) - ); - identity.region = region; - identity.project = project; + if identity.region.is_none() || identity.project.is_none() { + let client = build_metadata_client()?; + + match (&identity.region, &identity.project) { + (None, None) => { + let (region, project) = tokio::join!( + fetch_region(&client, metadata_base_url), + fetch_project(&client, metadata_base_url) + ); + identity.region = region; + identity.project = project; + } + (None, Some(_)) => identity.region = fetch_region(&client, metadata_base_url).await, + (Some(_), None) => identity.project = fetch_project(&client, metadata_base_url).await, + (Some(_), Some(_)) => {} } - (None, Some(_)) => identity.region = fetch_region(metadata_base_url).await, - (Some(_), None) => identity.project = fetch_project(metadata_base_url).await, - (Some(_), Some(_)) => {} } build_platform_data(identity, &env) @@ -115,28 +118,31 @@ fn detect_runtime(env: &impl QueryEnv) -> Option<(&'static str, String)> { None } -async fn fetch_metadata_value( - base_url: &str, - path: &str, - label: &str, - parse: impl FnOnce(&str) -> Option, -) -> Option { +fn build_metadata_client() -> Option { // This client is scoped to GCP's fixed, plain-HTTP metadata endpoint, so it // neither needs nor should configure a TLS provider. #[allow(clippy::disallowed_methods)] - let client = match reqwest::Client::builder() + match reqwest::Client::builder() .no_proxy() .redirect(reqwest::redirect::Policy::none()) .timeout(Duration::from_secs(2)) .build() { - Ok(client) => client, + Ok(client) => Some(client), Err(error) => { - warn!("inventory: failed to create GCP metadata client for {label}: {error}"); - return None; + warn!("inventory: failed to create GCP metadata client: {error}"); + None } - }; + } +} +async fn fetch_metadata_value( + client: &reqwest::Client, + base_url: &str, + path: &str, + label: &str, + parse: impl FnOnce(&str) -> Option, +) -> Option { let mut response = match client .get(format!("{}/{path}", base_url.trim_end_matches('/'))) .header("Metadata-Flavor", "Google") @@ -205,9 +211,9 @@ async fn fetch_metadata_value( value } -async fn fetch_region(base_url: &str) -> Option { +async fn fetch_region(client: &reqwest::Client, base_url: &str) -> Option { // Response: projects//regions/ - fetch_metadata_value(base_url, "instance/region", "region", |body| { + fetch_metadata_value(client, base_url, "instance/region", "region", |body| { body.split('/') .next_back() .filter(|value| !value.is_empty()) @@ -216,10 +222,14 @@ async fn fetch_region(base_url: &str) -> Option { .await } -async fn fetch_project(base_url: &str) -> Option { - fetch_metadata_value(base_url, "project/project-id", "project-id", |body| { - (!body.is_empty()).then(|| body.to_string()) - }) +async fn fetch_project(client: &reqwest::Client, base_url: &str) -> Option { + fetch_metadata_value( + client, + base_url, + "project/project-id", + "project-id", + |body| (!body.is_empty()).then(|| body.to_string()), + ) .await } @@ -346,6 +356,7 @@ mod tests { #[tokio::test] async fn rejects_untrusted_or_oversized_metadata_responses() { let server = MockServer::start_async().await; + let client = build_metadata_client().expect("metadata client should build"); server .mock_async(|when, then| { when.path("/missing-header"); @@ -362,14 +373,18 @@ mod tests { .await; assert!( - fetch_metadata_value(&server.base_url(), "missing-header", "test", |body| { - Some(body.to_string()) - }) + fetch_metadata_value( + &client, + &server.base_url(), + "missing-header", + "test", + |body| Some(body.to_string()) + ) .await .is_none() ); assert!( - fetch_metadata_value(&server.base_url(), "oversized", "test", |body| { + fetch_metadata_value(&client, &server.base_url(), "oversized", "test", |body| { Some(body.to_string()) }) .await @@ -380,6 +395,7 @@ mod tests { #[tokio::test] async fn does_not_follow_metadata_redirects() { let server = MockServer::start_async().await; + let client = build_metadata_client().expect("metadata client should build"); let target = server .mock_async(|when, then| { when.path("/target"); @@ -396,9 +412,13 @@ mod tests { .await; assert!( - fetch_metadata_value(&server.base_url(), "redirect", "test", |body| { - Some(body.to_string()) - }) + fetch_metadata_value( + &client, + &server.base_url(), + "redirect", + "test", + |body| Some(body.to_string()) + ) .await .is_none() ); From 40ae3fffb1a53eef0f1277dc1740fab5d02d6cde Mon Sep 17 00:00:00 2001 From: Nina Rei Date: Tue, 6 Oct 2026 19:25:55 -0400 Subject: [PATCH 5/6] fix(compat): address final GCP inventory feedback --- crates/inventory/Cargo.toml | 3 ++- crates/inventory/src/lib.rs | 2 +- crates/inventory/src/platform/gcp.rs | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/inventory/Cargo.toml b/crates/inventory/Cargo.toml index c6a9dd85..a86d35d2 100644 --- a/crates/inventory/Cargo.toml +++ b/crates/inventory/Cargo.toml @@ -13,8 +13,9 @@ libdd-common = { workspace = true } libdd-trace-utils = { workspace = true } reqwest = { version = "0.12", default-features = false } serde_json = { version = "1.0", default-features = false, features = ["alloc"] } -tokio = { version = "1", features = ["macros", "rt"] } +tokio = { version = "1", features = ["macros"] } tracing = "0.1" [dev-dependencies] httpmock = "0.8" +tokio = { version = "1", features = ["macros", "rt"] } diff --git a/crates/inventory/src/lib.rs b/crates/inventory/src/lib.rs index 8d7836f3..377653e3 100644 --- a/crates/inventory/src/lib.rs +++ b/crates/inventory/src/lib.rs @@ -4,7 +4,7 @@ mod payload; mod platform; -pub(crate) use libdd_common::azure_app_services::QueryEnv; +use libdd_common::azure_app_services::QueryEnv; use libdd_trace_utils::trace_utils::EnvironmentType; use std::env; diff --git a/crates/inventory/src/platform/gcp.rs b/crates/inventory/src/platform/gcp.rs index 4e8b816b..ca244e04 100644 --- a/crates/inventory/src/platform/gcp.rs +++ b/crates/inventory/src/platform/gcp.rs @@ -2,7 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 use super::PlatformData; -use crate::{ProcessEnv, QueryEnv}; +use crate::ProcessEnv; +use libdd_common::azure_app_services::QueryEnv; use serde_json::{Map, Value}; use std::time::Duration; use tracing::{debug, warn}; From 534e0661e39f597f31b9f0a040bb8f22bf4cc61a Mon Sep 17 00:00:00 2001 From: Nina Rei Date: Wed, 7 Oct 2026 22:38:41 -0400 Subject: [PATCH 6/6] fix(compat): consolidate GCP runtime metadata --- crates/inventory/src/platform/gcp.rs | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/crates/inventory/src/platform/gcp.rs b/crates/inventory/src/platform/gcp.rs index ca244e04..4c196ef4 100644 --- a/crates/inventory/src/platform/gcp.rs +++ b/crates/inventory/src/platform/gcp.rs @@ -64,12 +64,8 @@ fn build_platform_data(identity: Identity, env: &impl QueryEnv) -> Option Option { .find_map(|name| env.get_var(name).filter(|value| !value.is_empty())) } -fn detect_runtime(env: &impl QueryEnv) -> Option<(&'static str, String)> { +fn detect_runtime(env: &impl QueryEnv) -> Option { for (runtime, variable) in [ ("node", "NODE_VERSION"), ("python", "PYTHON_VERSION"), @@ -113,7 +109,7 @@ fn detect_runtime(env: &impl QueryEnv) -> Option<(&'static str, String)> { ("go", "GO_VERSION"), ] { if let Some(version) = first_env(env, &[variable]) { - return Some((runtime, version)); + return Some(format!("{runtime}{version}")); } } None @@ -267,8 +263,7 @@ mod tests { data.resource_id, "//cloudfunctions.googleapis.com/projects/my-project/locations/us-central1/functions/my-fn" ); - assert_eq!(data.metadata["runtime"], "python"); - assert_eq!(data.metadata["serverless_compat_runtime_version"], "3.12.7"); + assert_eq!(data.metadata["runtime"], "python3.12.7"); } #[test] @@ -314,7 +309,7 @@ mod tests { #[test] fn detects_runtime_from_platform_environment() { let env = FakeEnv::new(&[("PYTHON_VERSION", "3.12.7")]); - assert_eq!(detect_runtime(&env), Some(("python", "3.12.7".into()))); + assert_eq!(detect_runtime(&env), Some("python3.12.7".into())); } #[tokio::test]