diff --git a/Cargo.lock b/Cargo.lock index 17e928f5..ac4d8164 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1557,6 +1557,15 @@ 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 new file mode 100644 index 00000000..f72ce27f --- /dev/null +++ b/crates/inventory/Cargo.toml @@ -0,0 +1,14 @@ +# Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "inventory" +version = "0.1.0" +edition.workspace = true +license.workspace = true +description = "Fleet Automation inventory reporter for the Serverless Compat" + +[dependencies] +libdd-common = { workspace = true } +libdd-trace-utils = { workspace = true } +serde_json = { version = "1.0", default-features = false, features = ["alloc"] } diff --git a/crates/inventory/src/lib.rs b/crates/inventory/src/lib.rs new file mode 100644 index 00000000..377653e3 --- /dev/null +++ b/crates/inventory/src/lib.rs @@ -0,0 +1,71 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +mod payload; +mod platform; + +use libdd_common::azure_app_services::QueryEnv; +use libdd_trace_utils::trace_utils::EnvironmentType; +use std::env; + +#[derive(Clone, Copy)] +struct ProcessEnv; + +impl QueryEnv for ProcessEnv { + fn get_var(&self, name: &str) -> Option { + env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + } +} + +/// A serialized inventory report and the identity used for operational logs. +pub struct InventoryReport { + pub body: Vec, + pub resource_id: String, + pub workload_type: &'static str, +} + +/// Builds an inventory report for a supported serverless environment. +/// +/// Returns `None` when the environment is unsupported or its required cloud +/// identity is unavailable. The async API leaves room for platforms such as +/// GCP that may need metadata-server lookups to complete their identity. +pub async fn build_inventory_report( + env_type: &EnvironmentType, + process_id: &str, + report_reason: &str, +) -> Result, serde_json::Error> { + let Some(platform) = platform::collect(env_type).await else { + return Ok(None); + }; + + let body = payload::build(process_id, report_reason, &platform)?; + Ok(Some(InventoryReport { + body, + resource_id: platform.resource_id, + workload_type: platform.workload_type, + })) +} + +#[cfg(test)] +mod test_env { + use libdd_common::azure_app_services::QueryEnv; + use std::collections::HashMap; + + #[derive(Clone, Default)] + pub(crate) struct FakeEnv(HashMap<&'static str, &'static str>); + + impl FakeEnv { + pub(crate) fn new(values: &[(&'static str, &'static str)]) -> Self { + Self(values.iter().copied().collect()) + } + } + + impl QueryEnv for FakeEnv { + fn get_var(&self, name: &str) -> Option { + self.0.get(name).map(|value| value.to_string()) + } + } +} diff --git a/crates/inventory/src/payload.rs b/crates/inventory/src/payload.rs new file mode 100644 index 00000000..b6ffc851 --- /dev/null +++ b/crates/inventory/src/payload.rs @@ -0,0 +1,174 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use crate::{ProcessEnv, platform::PlatformData}; +use libdd_common::azure_app_services::QueryEnv; +use serde_json::Value; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub(crate) fn build( + process_id: &str, + report_reason: &str, + platform: &PlatformData, +) -> Result, serde_json::Error> { + build_with_env( + process_id, + report_reason, + platform, + &ProcessEnv, + option_env!("DD_SERVERLESS_COMPAT_VERSION"), + ) +} + +fn build_with_env( + process_id: &str, + report_reason: &str, + platform: &PlatformData, + env: &impl QueryEnv, + embedded_version: Option<&str>, +) -> Result, serde_json::Error> { + // EPRW expects nanoseconds, matching time.Now().UnixNano(). + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos() as i64) + .unwrap_or(0); + + let compat_runtime_version = env + .get_var("DD_SERVERLESS_COMPAT_VERSION") + .filter(|value| !value.is_empty()); + let compat_binary_version = embedded_version + .map(str::trim) + .filter(|value| !value.is_empty()); + + // Required identity fields take precedence over platform-specific metadata. + let mut metadata = Value::Object(platform.metadata.clone()); + metadata["flavor"] = Value::String("serverless-compat".into()); + metadata["workload_type"] = Value::String(platform.workload_type.into()); + metadata["report_reason"] = Value::String(report_reason.into()); + metadata["resource_id"] = Value::String(platform.resource_id.clone()); + metadata["resource_name"] = Value::String(platform.resource_name.clone()); + if let Some(compat_runtime_version) = compat_runtime_version { + metadata["serverless_compat_runtime_version"] = Value::String(compat_runtime_version); + } + if let Some(compat_binary_version) = compat_binary_version { + metadata["serverless_compat_binary_version"] = + Value::String(compat_binary_version.to_string()); + } + + for (env_key, metadata_key) in [ + ("DD_ENV", "dd_env"), + ("DD_SERVICE", "dd_service"), + ("DD_VERSION", "dd_version"), + ("DD_SITE", "dd_site"), + ] { + if let Some(value) = env.get_var(env_key).filter(|value| !value.is_empty()) { + metadata[metadata_key] = Value::String(value); + } + } + + // Hostname is intentionally absent: setting it causes EPRW to attempt a + // host_id lookup that is invalid for serverless workloads. + serde_json::to_vec(&serde_json::json!({ + "uuid": process_id, + "timestamp": timestamp, + "agent_metadata": metadata, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_env::FakeEnv; + use serde_json::{Map, Value}; + + fn azure_platform() -> PlatformData { + let mut metadata = Map::new(); + metadata.insert("region".into(), Value::String("eastus".into())); + metadata.insert("runtime".into(), Value::String("python3.13".into())); + metadata.insert("flavor".into(), Value::String("incorrect".into())); + metadata.insert("resource_id".into(), Value::String("incorrect".into())); + PlatformData { + workload_type: "azure_function", + resource_id: "/subscriptions/sub/resourcegroups/rg/providers/microsoft.web/sites/app" + .into(), + resource_name: "app".into(), + metadata, + } + } + + #[test] + fn builds_azure_payload_shape() { + let body = build_with_env( + "process-id", + "startup", + &azure_platform(), + &FakeEnv::default(), + Some("1.2.3"), + ) + .unwrap(); + let payload: Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!(payload["uuid"], "process-id"); + assert!(payload["timestamp"].as_i64().unwrap() > 0); + assert_eq!(payload["agent_metadata"]["flavor"], "serverless-compat"); + assert_eq!( + payload["agent_metadata"]["resource_id"], + "/subscriptions/sub/resourcegroups/rg/providers/microsoft.web/sites/app" + ); + assert_eq!(payload["agent_metadata"]["workload_type"], "azure_function"); + assert_eq!(payload["agent_metadata"]["report_reason"], "startup"); + assert_eq!(payload["agent_metadata"]["region"], "eastus"); + assert_eq!( + payload["agent_metadata"]["serverless_compat_binary_version"], + "1.2.3" + ); + assert!( + payload["agent_metadata"] + .get("serverless_compat_runtime_version") + .is_none() + ); + assert!(payload.get("hostname").is_none()); + } + + #[test] + fn reports_runtime_compat_and_binary_versions_separately() { + let env = FakeEnv::new(&[("DD_SERVERLESS_COMPAT_VERSION", "0.18.0")]); + let body = + build_with_env("pid", "startup", &azure_platform(), &env, Some("0.28.0")).unwrap(); + let payload: Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!( + payload["agent_metadata"]["serverless_compat_runtime_version"], + "0.18.0" + ); + assert_eq!( + payload["agent_metadata"]["serverless_compat_binary_version"], + "0.28.0" + ); + assert_eq!(payload["agent_metadata"]["runtime"], "python3.13"); + } + + #[test] + fn omits_unknown_compat_version() { + let body = build_with_env( + "pid", + "startup", + &azure_platform(), + &FakeEnv::default(), + None, + ) + .unwrap(); + let payload: Value = serde_json::from_slice(&body).unwrap(); + + assert!( + payload["agent_metadata"] + .get("serverless_compat_runtime_version") + .is_none() + ); + assert!( + payload["agent_metadata"] + .get("serverless_compat_binary_version") + .is_none() + ); + } +} diff --git a/crates/inventory/src/platform/azure.rs b/crates/inventory/src/platform/azure.rs new file mode 100644 index 00000000..c5318bc4 --- /dev/null +++ b/crates/inventory/src/platform/azure.rs @@ -0,0 +1,197 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use super::PlatformData; +use crate::ProcessEnv; +use libdd_common::azure_app_services::{AzureMetadata, QueryEnv, UNKNOWN_VALUE}; +use serde_json::{Map, Value}; + +fn known_value(value: &str) -> Option<&str> { + (value != UNKNOWN_VALUE && !value.is_empty()).then_some(value) +} + +fn runtime_version(value: &str) -> Option<&str> { + known_value(value) + .map(|value| value.trim_start_matches('~')) + .filter(|value| !value.is_empty()) +} + +fn runtime_with_version(runtime: &str, version: &str) -> Option { + let runtime = known_value(runtime)?; + Some(match runtime_version(version) { + Some(version) => format!("{runtime}{version}"), + None => runtime.to_string(), + }) +} + +pub(super) fn collect() -> Option { + collect_from(ProcessEnv) +} + +fn collect_from(env: E) -> Option { + // These values are inventory-specific enrichments that are not exposed by + // the published libdd-common 5.2 API used by this repository. + let region = env + .get_var("REGION_NAME") + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let deployment_slot = env + .get_var("WEBSITE_SLOT_NAME") + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty() && !value.eq_ignore_ascii_case("production")); + + let azure = AzureMetadata::new_function(env)?; + let resource_name = known_value(azure.get_site_name())?.to_string(); + let base_resource_id = known_value(azure.get_resource_id())?; + let resource_id = match deployment_slot { + Some(slot) => format!("{base_resource_id}/slots/{}", slot.to_lowercase()), + None => base_resource_id.to_string(), + }; + + let mut metadata = Map::new(); + if let Some(region) = region { + metadata.insert("region".into(), Value::String(region)); + } + + for (field, value) in [ + ("azure_subscription_id", azure.get_subscription_id()), + ("azure_resource_group", azure.get_resource_group()), + ("os", azure.get_operating_system()), + ] { + if let Some(value) = known_value(value) { + metadata.insert(field.into(), Value::String(value.to_string())); + } + } + if let Some(runtime) = runtime_with_version(azure.get_runtime(), azure.get_runtime_version()) { + metadata.insert("runtime".into(), Value::String(runtime)); + } + + Some(PlatformData { + workload_type: "azure_function", + resource_id, + resource_name, + metadata, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_env::FakeEnv; + + fn base_env() -> FakeEnv { + FakeEnv::new(&[ + ("FUNCTIONS_WORKER_RUNTIME", "python"), + ("WEBSITE_OWNER_NAME", "abc123+my-rg-eastuswebspace"), + ("WEBSITE_RESOURCE_GROUP", "my-rg"), + ("WEBSITE_SITE_NAME", "my-func-app"), + ]) + } + + #[test] + fn builds_full_identity() { + let data = collect_from(base_env()).expect("Azure identity should be available"); + assert_eq!(data.resource_name, "my-func-app"); + assert_eq!( + data.resource_id, + "/subscriptions/abc123/resourcegroups/my-rg/providers/microsoft.web/sites/my-func-app" + ); + } + + #[test] + fn uses_flex_consumption_resource_group() { + let data = collect_from(FakeEnv::new(&[ + ("FUNCTIONS_WORKER_RUNTIME", "python"), + ("WEBSITE_OWNER_NAME", "abc123+flex-host"), + ("WEBSITE_SITE_NAME", "my-flex-func"), + ("WEBSITE_SKU", "FlexConsumption"), + ("DD_AZURE_RESOURCE_GROUP", "My-Flex-RG"), + ])) + .expect("Flex identity should be available"); + assert!(data.resource_id.contains("/my-flex-rg/")); + } + + #[test] + fn missing_required_identity_skips_inventory() { + let incomplete_environments = [ + FakeEnv::new(&[ + ("FUNCTIONS_WORKER_RUNTIME", "python"), + ("WEBSITE_OWNER_NAME", "abc123+my-rg-eastuswebspace"), + ("WEBSITE_RESOURCE_GROUP", "my-rg"), + ]), + FakeEnv::new(&[ + ("FUNCTIONS_WORKER_RUNTIME", "python"), + ("WEBSITE_RESOURCE_GROUP", "my-rg"), + ("WEBSITE_SITE_NAME", "my-func-app"), + ]), + FakeEnv::new(&[ + ("FUNCTIONS_WORKER_RUNTIME", "python"), + ("WEBSITE_OWNER_NAME", "abc123+flex-host"), + ("WEBSITE_SITE_NAME", "my-flex-func"), + ("WEBSITE_SKU", "FlexConsumption"), + ]), + ]; + + for environment in incomplete_environments { + assert!(collect_from(environment).is_none()); + } + } + + #[test] + fn appends_non_production_slot() { + let data = collect_from(FakeEnv::new(&[ + ("FUNCTIONS_WORKER_RUNTIME", "python"), + ("WEBSITE_OWNER_NAME", "abc123+my-rg-eastuswebspace"), + ("WEBSITE_RESOURCE_GROUP", "my-rg"), + ("WEBSITE_SITE_NAME", "my-func-app"), + ("WEBSITE_SLOT_NAME", "Staging"), + ])) + .expect("slot identity should be available"); + assert!(data.resource_id.ends_with("/slots/staging")); + } + + #[test] + fn omits_production_slot() { + let data = collect_from(FakeEnv::new(&[ + ("FUNCTIONS_WORKER_RUNTIME", "python"), + ("WEBSITE_OWNER_NAME", "abc123+my-rg-eastuswebspace"), + ("WEBSITE_RESOURCE_GROUP", "my-rg"), + ("WEBSITE_SITE_NAME", "my-func-app"), + ("WEBSITE_SLOT_NAME", "Production"), + ])) + .expect("production identity should be available"); + assert!(!data.resource_id.contains("/slots/")); + } + + #[test] + fn enriches_platform_fields() { + let data = collect_from(FakeEnv::new(&[ + ("FUNCTIONS_WORKER_RUNTIME", "dotnet-isolated"), + ("FUNCTIONS_WORKER_RUNTIME_VERSION", "~8.0"), + ("WEBSITE_OS", "windows"), + ("REGION_NAME", "East US 2"), + ("WEBSITE_OWNER_NAME", "abc123+my-rg-eastuswebspace"), + ("WEBSITE_RESOURCE_GROUP", "my-rg"), + ("WEBSITE_SITE_NAME", "my-func-app"), + ])) + .expect("Azure metadata should be available"); + assert_eq!(data.metadata["runtime"], "dotnet-isolated8.0"); + assert_eq!(data.metadata["os"], "windows"); + assert_eq!(data.metadata["region"], "East US 2"); + assert_eq!(data.metadata["azure_subscription_id"], "abc123"); + assert_eq!(data.metadata["azure_resource_group"], "my-rg"); + } + + #[test] + fn uses_runtime_without_unknown_version() { + let data = collect_from(FakeEnv::new(&[ + ("FUNCTIONS_WORKER_RUNTIME", "dotnet"), + ("FUNCTIONS_WORKER_RUNTIME_VERSION", "unknown"), + ("WEBSITE_OWNER_NAME", "abc123+my-rg-eastuswebspace"), + ("WEBSITE_RESOURCE_GROUP", "my-rg"), + ("WEBSITE_SITE_NAME", "my-func-app"), + ])) + .expect("Azure metadata should be available"); + assert_eq!(data.metadata["runtime"], "dotnet"); + } +} diff --git a/crates/inventory/src/platform/mod.rs b/crates/inventory/src/platform/mod.rs new file mode 100644 index 00000000..f424e5c7 --- /dev/null +++ b/crates/inventory/src/platform/mod.rs @@ -0,0 +1,23 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +mod azure; + +use libdd_trace_utils::trace_utils::EnvironmentType; +use serde_json::{Map, Value}; + +pub(crate) struct PlatformData { + pub workload_type: &'static str, + pub resource_id: String, + pub resource_name: String, + pub metadata: Map, +} + +pub(crate) async fn collect(env_type: &EnvironmentType) -> Option { + match env_type { + EnvironmentType::AzureFunction => azure::collect(), + EnvironmentType::CloudFunction + | EnvironmentType::LambdaFunction + | EnvironmentType::AzureSpringApp => None, + } +}