Skip to main content

libdd_common/
azure_app_services.rs

1// Copyright 2021-Present Datadog, Inc. https://www.datadoghq.com/
2// SPDX-License-Identifier: Apache-2.0
3
4use crate::regex_engine::Regex;
5#[cfg(target_os = "linux")]
6use std::collections::HashMap;
7use std::sync::LazyLock;
8
9const WEBSITE_OWNER_NAME: &str = "WEBSITE_OWNER_NAME";
10const WEBSITE_SITE_NAME: &str = "WEBSITE_SITE_NAME";
11const WEBSITE_RESOURCE_GROUP: &str = "WEBSITE_RESOURCE_GROUP";
12const SITE_EXTENSION_VERSION: &str = "DD_AAS_DOTNET_EXTENSION_VERSION";
13const WEBSITE_OS: &str = "WEBSITE_OS";
14const COMPUTERNAME: &str = "COMPUTERNAME";
15const CONTAINER_NAME: &str = "CONTAINER_NAME";
16const WEBSITE_POD_NAME: &str = "WEBSITE_POD_NAME";
17const INSTANCE_ID: &str = "WEBSITE_INSTANCE_ID";
18const SERVICE_CONTEXT: &str = "DD_AZURE_APP_SERVICES";
19const FUNCTIONS_WORKER_RUNTIME: &str = "FUNCTIONS_WORKER_RUNTIME";
20const FUNCTIONS_WORKER_RUNTIME_VERSION: &str = "FUNCTIONS_WORKER_RUNTIME_VERSION";
21const FUNCTIONS_EXTENSION_VERSION: &str = "FUNCTIONS_EXTENSION_VERSION";
22const DD_AZURE_RESOURCE_GROUP: &str = "DD_AZURE_RESOURCE_GROUP";
23pub const WEBSITE_SKU: &str = "WEBSITE_SKU";
24pub const REGION_NAME: &str = "REGION_NAME";
25
26pub const UNKNOWN_VALUE: &str = "unknown";
27
28enum AzureContext {
29    AzureFunctions,
30    AzureAppService,
31}
32
33/// Snapshot of the AAS-relevant subset of the process environment, captured at
34/// exec time.
35///
36/// On Linux we parse `/proc/self/environ` (a kernel-managed snapshot of the
37/// initial `envp` block) instead of calling `getenv`. This avoids the
38/// well-known race between libc's `getenv(3)` and `setenv(3)` from another
39/// thread, which has been observed to crash `ddog_prof_Exporter_new` when an
40/// embedder (e.g. dd-trace-py via `os.environ[...] = ...`) mutates the
41/// environment concurrently with our AAS detection.
42///
43/// `/proc/self/environ` only contains variables present at `execve()` time;
44/// later `setenv` calls are not reflected. That is exactly what AAS detection
45/// needs — every Azure App Services / Functions variable we inspect is set by
46/// the platform before the process starts.
47///
48/// **Only the AAS variable names listed in [`AAS_VAR_NAMES`] are retained**;
49/// all other entries (which on real AAS deployments commonly include unrelated
50/// app secrets, API keys, connection strings, etc.) are dropped at parse time
51/// so we don't keep a long-lived in-memory copy of them.
52///
53/// On non-Linux platforms we fall back to `std::env::var`. AAS deployments are
54/// almost exclusively Linux containers, and Windows' `GetEnvironmentVariableW`
55/// (which `std::env::var` uses) is itself thread-safe.
56#[cfg(target_os = "linux")]
57static PROC_ENVIRON: LazyLock<HashMap<String, String>> = LazyLock::new(read_proc_self_environ);
58
59/// AAS-related variable names this module reads. Only entries with one of
60/// these names are kept in [`PROC_ENVIRON`]; everything else from
61/// `/proc/self/environ` is dropped at parse time.
62#[cfg(target_os = "linux")]
63const AAS_VAR_NAMES: &[&str] = &[
64    WEBSITE_OWNER_NAME,
65    WEBSITE_SITE_NAME,
66    WEBSITE_RESOURCE_GROUP,
67    SITE_EXTENSION_VERSION,
68    WEBSITE_OS,
69    COMPUTERNAME,
70    CONTAINER_NAME,
71    WEBSITE_POD_NAME,
72    INSTANCE_ID,
73    SERVICE_CONTEXT,
74    FUNCTIONS_WORKER_RUNTIME,
75    FUNCTIONS_WORKER_RUNTIME_VERSION,
76    FUNCTIONS_EXTENSION_VERSION,
77    DD_AZURE_RESOURCE_GROUP,
78    WEBSITE_SKU,
79];
80
81#[cfg(target_os = "linux")]
82fn read_proc_self_environ() -> HashMap<String, String> {
83    let bytes = match std::fs::read("/proc/self/environ") {
84        Ok(b) => b,
85        Err(_) => return HashMap::new(),
86    };
87    let mut map = HashMap::new();
88    for entry in bytes.split(|&b| b == 0) {
89        if entry.is_empty() {
90            continue;
91        }
92        let s = match core::str::from_utf8(entry) {
93            Ok(s) => s,
94            Err(_) => continue,
95        };
96        if let Some(eq) = s.find('=') {
97            let key = &s[..eq];
98            if AAS_VAR_NAMES.contains(&key) {
99                map.insert(key.to_string(), s[eq + 1..].to_string());
100            }
101        }
102    }
103    map
104}
105
106fn read_aas_var(name: &str) -> Option<String> {
107    #[cfg(target_os = "linux")]
108    {
109        PROC_ENVIRON.get(name).cloned()
110    }
111    #[cfg(not(target_os = "linux"))]
112    {
113        std::env::var(name).ok()
114    }
115}
116
117macro_rules! get_trimmed_env_var {
118    ($name:expr) => {
119        crate::azure_app_services::read_aas_var($name)
120            .map(|v| v.trim().to_string())
121            .filter(|s| !s.is_empty())
122    };
123}
124
125macro_rules! get_value_or_unknown {
126    ($name:expr) => {
127        $name.as_ref().map(|s| s.as_str()).unwrap_or(UNKNOWN_VALUE)
128    };
129}
130
131trait ToBoolean {
132    fn to_bool(&self) -> bool;
133}
134
135impl ToBoolean for String {
136    fn to_bool(&self) -> bool {
137        matches!(
138            self.to_lowercase().as_str(),
139            "true" | "t" | "y" | "1" | "yes"
140        )
141    }
142}
143
144pub trait QueryEnv {
145    fn get_var(&self, var: &str) -> Option<String>;
146}
147
148struct RealEnv;
149
150impl QueryEnv for RealEnv {
151    fn get_var(&self, var: &str) -> Option<String> {
152        get_trimmed_env_var!(var)
153    }
154}
155
156#[derive(Default)]
157pub struct AzureMetadata {
158    resource_id: Option<String>,
159    subscription_id: Option<String>,
160    site_name: Option<String>,
161    resource_group: Option<String>,
162    extension_version: Option<String>,
163    operating_system: String,
164    instance_name: Option<String>,
165    instance_id: Option<String>,
166    site_kind: String,
167    site_type: String,
168    runtime: Option<String>,
169    runtime_version: Option<String>,
170    function_runtime_version: Option<String>,
171}
172
173impl AzureMetadata {
174    fn get_azure_context<T: QueryEnv>(query: &T) -> AzureContext {
175        match (
176            query.get_var(FUNCTIONS_WORKER_RUNTIME),
177            query.get_var(FUNCTIONS_EXTENSION_VERSION),
178        ) {
179            (Some(_), Some(_)) => AzureContext::AzureFunctions,
180            (Some(_), None) => AzureContext::AzureFunctions,
181            (None, Some(_)) => AzureContext::AzureFunctions,
182            (None, None) => AzureContext::AzureAppService,
183        }
184    }
185
186    fn extract_subscription_id(s: Option<String>) -> Option<String> {
187        s?.split('+')
188            .next()
189            .filter(|s| !s.trim().is_empty())
190            .map(|v| v.to_string())
191    }
192
193    fn extract_resource_group(s: Option<String>) -> Option<String> {
194        #[allow(clippy::unwrap_used)]
195        let re: Regex = Regex::new(r".+\+(.+)-.+webspace(-Linux)?").unwrap();
196
197        s.as_ref().and_then(|text| {
198            re.captures(text)
199                .and_then(|caps| caps.get(1).map(|m| m.as_str().to_string()))
200        })
201    }
202
203    /*
204     * Computation of the resource id follow the same way the .NET tracer is doing:
205     * https://github.com/DataDog/dd-trace-dotnet/blob/834a4b05b4ed91a819eb78761bf1ddb805969f65/tracer/src/Datadog.Trace/PlatformHelpers/AzureAppServices.cs#L215
206     */
207    fn build_resource_id(
208        subscription_id: Option<&String>,
209        site_name: Option<&String>,
210        resource_group: Option<&String>,
211    ) -> Option<String> {
212        match (subscription_id, site_name, resource_group) {
213            (Some(id_sub), Some(sitename), Some(res_grp)) => Some(
214                format!("/subscriptions/{id_sub}/resourcegroups/{res_grp}/providers/microsoft.web/sites/{sitename}")
215                .to_lowercase(),
216            ),
217            _ => None,
218        }
219    }
220
221    fn build_metadata<T: QueryEnv>(query: T) -> Option<Self> {
222        let subscription_id =
223            AzureMetadata::extract_subscription_id(query.get_var(WEBSITE_OWNER_NAME));
224        let site_name = query.get_var(WEBSITE_SITE_NAME);
225
226        let (site_kind, site_type) = match AzureMetadata::get_azure_context(&query) {
227            AzureContext::AzureFunctions => ("functionapp".to_owned(), "function".to_owned()),
228            _ => ("app".to_owned(), "app".to_owned()),
229        };
230
231        let website_sku = query.get_var(WEBSITE_SKU);
232
233        let resource_group = query
234            .get_var(DD_AZURE_RESOURCE_GROUP)
235            .or_else(|| query.get_var(WEBSITE_RESOURCE_GROUP))
236            .or_else(|| {
237                // Check if we're in flex consumption plan first
238                match website_sku.as_deref() {
239                    Some("FlexConsumption") => None,
240                    /* Flex Consumption plans need the `DD_AZURE_RESOURCE_GROUP` env var. If this
241                     * logic ever changes, update the logic in
242                     * `serverless-components/src/datadog-trace-agent` and the serverless compat
243                     * layers accordingly. */
244                    _ => AzureMetadata::extract_resource_group(query.get_var(WEBSITE_OWNER_NAME)),
245                }
246            });
247
248        let resource_id = AzureMetadata::build_resource_id(
249            subscription_id.as_ref(),
250            site_name.as_ref(),
251            resource_group.as_ref(),
252        );
253        let extension_version = query.get_var(SITE_EXTENSION_VERSION);
254        let operating_system = query
255            .get_var(WEBSITE_OS)
256            .unwrap_or(std::env::consts::OS.to_string());
257
258        let computer_name = query.get_var(COMPUTERNAME);
259        let pod_name = query.get_var(WEBSITE_POD_NAME);
260        let container_name = query.get_var(CONTAINER_NAME);
261        let instance_name = resolve_instance_name(
262            computer_name.as_deref(),
263            pod_name.as_deref(),
264            container_name.as_deref(),
265        );
266
267        let instance_id = query.get_var(INSTANCE_ID);
268
269        let runtime = query.get_var(FUNCTIONS_WORKER_RUNTIME);
270        let runtime_version = query.get_var(FUNCTIONS_WORKER_RUNTIME_VERSION);
271        let function_runtime_version = query.get_var(FUNCTIONS_EXTENSION_VERSION);
272
273        Some(AzureMetadata {
274            resource_id,
275            subscription_id,
276            site_name,
277            resource_group,
278            extension_version,
279            operating_system,
280            instance_name,
281            instance_id,
282            site_kind,
283            site_type,
284            runtime,
285            runtime_version,
286            function_runtime_version,
287        })
288    }
289
290    pub fn new<T: QueryEnv>(query: T) -> Option<Self> {
291        let is_relevant = query
292            .get_var(SERVICE_CONTEXT)
293            .map(|s| s.to_bool())
294            .unwrap_or(false);
295
296        if !is_relevant {
297            return None;
298        }
299
300        AzureMetadata::build_metadata(query)
301    }
302
303    pub fn new_function<T: QueryEnv>(query: T) -> Option<Self> {
304        match matches!(
305            AzureMetadata::get_azure_context(&query),
306            AzureContext::AzureFunctions
307        ) {
308            true => AzureMetadata::build_metadata(query),
309            false => None,
310        }
311    }
312
313    pub fn get_resource_id(&self) -> &str {
314        get_value_or_unknown!(self.resource_id)
315    }
316
317    pub fn get_subscription_id(&self) -> &str {
318        get_value_or_unknown!(self.subscription_id)
319    }
320
321    pub fn get_site_name(&self) -> &str {
322        get_value_or_unknown!(self.site_name)
323    }
324
325    pub fn get_resource_group(&self) -> &str {
326        get_value_or_unknown!(self.resource_group)
327    }
328
329    pub fn get_extension_version(&self) -> &str {
330        get_value_or_unknown!(self.extension_version)
331    }
332
333    pub fn get_operating_system(&self) -> &str {
334        self.operating_system.as_str()
335    }
336
337    pub fn get_instance_name(&self) -> &str {
338        get_value_or_unknown!(self.instance_name)
339    }
340
341    pub fn get_instance_id(&self) -> &str {
342        get_value_or_unknown!(self.instance_id)
343    }
344
345    pub fn get_site_type(&self) -> &str {
346        self.site_type.as_str()
347    }
348
349    pub fn get_site_kind(&self) -> &str {
350        self.site_kind.as_str()
351    }
352
353    pub fn get_runtime(&self) -> &str {
354        get_value_or_unknown!(self.runtime)
355    }
356
357    pub fn get_runtime_version(&self) -> &str {
358        get_value_or_unknown!(self.runtime_version)
359    }
360
361    pub fn get_function_runtime_version(&self) -> &str {
362        get_value_or_unknown!(self.function_runtime_version)
363    }
364
365    /// Returns Azure App Services tags as an iterator of (tag_name, tag_value) tuples.
366    /// These tags are specific to Azure App Services.
367    pub fn get_app_service_tags(&self) -> impl ExactSizeIterator<Item = (&'static str, &str)> {
368        [
369            (
370                "aas.environment.extension_version",
371                self.get_extension_version(),
372            ),
373            ("aas.environment.instance_id", self.get_instance_id()),
374            ("aas.environment.instance_name", self.get_instance_name()),
375            ("aas.environment.os", self.get_operating_system()),
376            ("aas.resource.group", self.get_resource_group()),
377            ("aas.resource.id", self.get_resource_id()),
378            ("aas.site.kind", self.get_site_kind()),
379            ("aas.site.name", self.get_site_name()),
380            ("aas.site.type", self.get_site_type()),
381            ("aas.subscription.id", self.get_subscription_id()),
382        ]
383        .into_iter()
384    }
385
386    /// Returns Azure Functions tags as an iterator of (tag_name, tag_value) tuples.
387    /// These tags are specific to Azure Functions.
388    pub fn get_function_tags(&self) -> impl ExactSizeIterator<Item = (&'static str, &str)> {
389        [
390            ("aas.environment.instance_id", self.get_instance_id()),
391            ("aas.environment.instance_name", self.get_instance_name()),
392            ("aas.environment.os", self.get_operating_system()),
393            ("aas.environment.runtime", self.get_runtime()),
394            (
395                "aas.environment.runtime_version",
396                self.get_runtime_version(),
397            ),
398            (
399                "aas.environment.function_runtime",
400                self.get_function_runtime_version(),
401            ),
402            ("aas.resource.group", self.get_resource_group()),
403            ("aas.resource.id", self.get_resource_id()),
404            ("aas.site.kind", self.get_site_kind()),
405            ("aas.site.name", self.get_site_name()),
406            ("aas.site.type", self.get_site_type()),
407            ("aas.subscription.id", self.get_subscription_id()),
408        ]
409        .into_iter()
410    }
411}
412
413/// Resolves the instance name to match the Azure integration metric's `instance` tag.
414fn resolve_instance_name(
415    computer_name: Option<&str>,
416    pod_name: Option<&str>,
417    container_name: Option<&str>,
418) -> Option<String> {
419    fn non_empty(s: Option<&str>) -> Option<&str> {
420        s.map(|v| v.trim()).filter(|v| !v.is_empty())
421    }
422
423    non_empty(computer_name)
424        .or_else(|| non_empty(pod_name))
425        .or_else(|| non_empty(container_name))
426        .map(|s| s.to_string())
427}
428
429pub static AAS_METADATA: LazyLock<Option<AzureMetadata>> =
430    LazyLock::new(|| AzureMetadata::new(RealEnv {}));
431
432pub static AAS_METADATA_FUNCTION: LazyLock<Option<AzureMetadata>> =
433    LazyLock::new(|| AzureMetadata::new_function(RealEnv {}));
434
435#[cfg(test)]
436mod tests {
437
438    use indexmap::IndexMap;
439
440    use crate::azure_app_services::{QueryEnv, WEBSITE_OWNER_NAME};
441
442    use super::*;
443
444    struct MockEnv {
445        pub env_vars: IndexMap<String, String>,
446    }
447
448    impl MockEnv {
449        pub fn new(vars: &[(&str, &str)]) -> Self {
450            let mut env_vars: IndexMap<String, String> = IndexMap::new();
451            vars.iter().for_each(|(name, value)| {
452                env_vars.insert(name.to_string(), value.to_string());
453            });
454
455            MockEnv { env_vars }
456        }
457    }
458
459    impl QueryEnv for MockEnv {
460        fn get_var(&self, var: &str) -> Option<String> {
461            self.env_vars.get(var).cloned()
462        }
463    }
464
465    #[test]
466    fn test_metadata_is_not_relevant_by_default() {
467        let mocked_env = MockEnv::new(&[]);
468
469        let metadata = AzureMetadata::new(mocked_env);
470        assert!(metadata.is_none());
471    }
472
473    #[test]
474    fn test_metadata_is_relevant_first() {
475        let mocked_env = MockEnv::new(&[(SERVICE_CONTEXT, "true")]);
476
477        let metadata = AzureMetadata::new(mocked_env);
478        assert!(metadata.is_some());
479    }
480
481    #[test]
482    fn test_metadata_is_relevant_second() {
483        let mocked_env = MockEnv::new(&[(SERVICE_CONTEXT, "t")]);
484
485        let metadata = AzureMetadata::new(mocked_env);
486        assert!(metadata.is_some());
487    }
488
489    #[test]
490    fn test_metadata_is_relevant_third() {
491        let mocked_env = MockEnv::new(&[(SERVICE_CONTEXT, "TrUe")]);
492
493        let metadata = AzureMetadata::new(mocked_env);
494        assert!(metadata.is_some());
495    }
496
497    #[test]
498    fn test_metadata_is_relevant_fourth() {
499        let mocked_env = MockEnv::new(&[(SERVICE_CONTEXT, "1")]);
500
501        let metadata = AzureMetadata::new(mocked_env);
502        assert!(metadata.is_some());
503    }
504
505    #[test]
506    fn test_metadata_is_relevant_fifth() {
507        let mocked_env = MockEnv::new(&[(SERVICE_CONTEXT, "yEs")]);
508
509        let metadata = AzureMetadata::new(mocked_env);
510        assert!(metadata.is_some());
511    }
512
513    #[test]
514    fn test_metadata_is_relevant_sixth() {
515        let mocked_env = MockEnv::new(&[(SERVICE_CONTEXT, "Y")]);
516
517        let metadata = AzureMetadata::new(mocked_env);
518        assert!(metadata.is_some());
519    }
520
521    #[test]
522    fn test_metadata_is_not_relevant_if_explicit() {
523        let mocked_env = MockEnv::new(&[(SERVICE_CONTEXT, "0")]);
524
525        let metadata = AzureMetadata::new(mocked_env);
526        assert!(metadata.is_none());
527    }
528
529    #[test]
530    fn test_extract_subscription_without_plus_sign() {
531        let mocked_env = MockEnv::new(&[(WEBSITE_OWNER_NAME, "foo"), (SERVICE_CONTEXT, "1")]);
532
533        let metadata = AzureMetadata::new(mocked_env).unwrap();
534
535        let expected_id = "foo";
536
537        assert_eq!(metadata.get_subscription_id(), expected_id);
538    }
539
540    #[test]
541    fn test_extract_subscription_with_plus_sign() {
542        let mocked_env = MockEnv::new(&[(WEBSITE_OWNER_NAME, "foo+bar"), (SERVICE_CONTEXT, "1")]);
543
544        let metadata = AzureMetadata::new(mocked_env).unwrap();
545
546        let expected_id = "foo";
547        assert_eq!(metadata.get_subscription_id(), expected_id);
548    }
549
550    #[test]
551    fn test_extract_subscription_with_empty_string() {
552        let mocked_env = MockEnv::new(&[(WEBSITE_OWNER_NAME, ""), (SERVICE_CONTEXT, "1")]);
553
554        let metadata = AzureMetadata::new(mocked_env).unwrap();
555
556        assert_eq!(metadata.get_subscription_id(), UNKNOWN_VALUE);
557    }
558
559    #[test]
560    fn test_extract_subscription_with_only_whitespaces() {
561        let mocked_env = MockEnv::new(&[(WEBSITE_OWNER_NAME, "    "), (SERVICE_CONTEXT, "1")]);
562
563        let metadata = AzureMetadata::new(mocked_env).unwrap();
564
565        assert_eq!(metadata.get_subscription_id(), UNKNOWN_VALUE);
566    }
567
568    #[test]
569    fn test_extract_subscription_with_only_plus_sign() {
570        let mocked_env = MockEnv::new(&[(WEBSITE_OWNER_NAME, "+"), (SERVICE_CONTEXT, "1")]);
571
572        let metadata = AzureMetadata::new(mocked_env).unwrap();
573
574        assert_eq!(metadata.get_subscription_id(), UNKNOWN_VALUE);
575    }
576
577    #[test]
578    fn test_extract_subscription_with_whitespaces_separated_by_plus() {
579        let mocked_env = MockEnv::new(&[(WEBSITE_OWNER_NAME, "   + "), (SERVICE_CONTEXT, "1")]);
580
581        let metadata = AzureMetadata::new(mocked_env).unwrap();
582
583        assert_eq!(metadata.get_subscription_id(), UNKNOWN_VALUE);
584    }
585
586    #[test]
587    fn test_extract_subscription_plus_sign_and_other_string() {
588        let mocked_env = MockEnv::new(&[(WEBSITE_OWNER_NAME, "+other"), (SERVICE_CONTEXT, "1")]);
589
590        let metadata = AzureMetadata::new(mocked_env).unwrap();
591
592        assert_eq!(metadata.get_subscription_id(), UNKNOWN_VALUE);
593    }
594
595    #[test]
596    fn test_extract_resource_group_pattern_match_linux() {
597        let mocked_env = MockEnv::new(&[
598            (
599                WEBSITE_OWNER_NAME,
600                "00000000-0000-0000-0000-000000000000+test-rg-EastUSwebspace-Linux",
601            ),
602            ("FUNCTIONS_WORKER_RUNTIME", "node"),
603            ("FUNCTIONS_EXTENSION_VERSION", "~4"),
604        ]);
605
606        let metadata = AzureMetadata::new_function(mocked_env).unwrap();
607
608        let expected_resource_group = "test-rg";
609
610        assert_eq!(metadata.get_resource_group(), expected_resource_group);
611    }
612
613    #[test]
614    fn test_extract_resource_group_pattern_match_windows() {
615        let mocked_env = MockEnv::new(&[
616            (
617                WEBSITE_OWNER_NAME,
618                "00000000-0000-0000-0000-000000000000+test-rg-EastUSwebspace",
619            ),
620            ("FUNCTIONS_WORKER_RUNTIME", "node"),
621            ("FUNCTIONS_EXTENSION_VERSION", "~4"),
622        ]);
623
624        let metadata = AzureMetadata::new_function(mocked_env).unwrap();
625
626        let expected_resource_group = "test-rg";
627
628        assert_eq!(metadata.get_resource_group(), expected_resource_group);
629    }
630
631    #[test]
632    fn test_extract_resource_group_no_pattern_match() {
633        let mocked_env = MockEnv::new(&[
634            (WEBSITE_OWNER_NAME, "foo"),
635            (FUNCTIONS_WORKER_RUNTIME, "node"),
636            (FUNCTIONS_EXTENSION_VERSION, "~4"),
637        ]);
638
639        let metadata = AzureMetadata::new_function(mocked_env).unwrap();
640
641        assert_eq!(metadata.get_resource_group(), UNKNOWN_VALUE);
642    }
643
644    #[test]
645    fn test_use_resource_group_from_env_var_if_available() {
646        let mocked_env = MockEnv::new(&[
647            (WEBSITE_RESOURCE_GROUP, "test-rg-env-var"),
648            (
649                WEBSITE_OWNER_NAME,
650                "00000000-0000-0000-0000-000000000000+test-rg-EastUSwebspace-Linux",
651            ),
652            (SERVICE_CONTEXT, "1"),
653            (WEBSITE_SKU, "ElasticPremium"),
654        ]);
655
656        let metadata = AzureMetadata::new(mocked_env).unwrap();
657
658        let expected_resource_group = "test-rg-env-var";
659
660        assert_eq!(metadata.get_resource_group(), expected_resource_group);
661    }
662
663    #[test]
664    fn test_flex_consumption_resource_group_is_none_without_dd_azure_resource_group() {
665        let mocked_env = MockEnv::new(&[
666            (
667                WEBSITE_OWNER_NAME,
668                "00000000-0000-0000-0000-000000000000+flex-EastUSwebspace-Linux",
669            ),
670            (WEBSITE_SKU, "FlexConsumption"),
671            (FUNCTIONS_WORKER_RUNTIME, "node"),
672        ]);
673
674        let metadata = AzureMetadata::new_function(mocked_env).unwrap();
675
676        assert_eq!(metadata.get_resource_group(), UNKNOWN_VALUE);
677    }
678
679    #[test]
680    fn test_flex_consumption_uses_dd_azure_resource_group() {
681        let mocked_env = MockEnv::new(&[
682            (
683                WEBSITE_OWNER_NAME,
684                "00000000-0000-0000-0000-000000000000+flex-EastUSwebspace-Linux",
685            ),
686            (DD_AZURE_RESOURCE_GROUP, "test-flex-rg"),
687            (WEBSITE_SKU, "FlexConsumption"),
688            (FUNCTIONS_WORKER_RUNTIME, "node"),
689        ]);
690
691        let metadata = AzureMetadata::new_function(mocked_env).unwrap();
692
693        // Should use the DD_AZURE_RESOURCE_GROUP value instead of extracting from
694        // WEBSITE_OWNER_NAME
695        assert_eq!(metadata.get_resource_group(), "test-flex-rg");
696    }
697
698    #[test]
699    fn test_dd_azure_resource_group_has_highest_priority() {
700        let mocked_env = MockEnv::new(&[
701            (WEBSITE_RESOURCE_GROUP, "test-rg-env-var"),
702            (
703                WEBSITE_OWNER_NAME,
704                "00000000-0000-0000-0000-000000000000+test-rg-EastUSwebspace-Linux",
705            ),
706            (DD_AZURE_RESOURCE_GROUP, "dd-azure-rg-override"),
707            (SERVICE_CONTEXT, "1"),
708        ]);
709
710        let metadata = AzureMetadata::new(mocked_env).unwrap();
711
712        // DD_AZURE_RESOURCE_GROUP should have highest priority over WEBSITE_RESOURCE_GROUP and
713        // WEBSITE_OWNER_NAME
714        let expected_resource_group = "dd-azure-rg-override";
715
716        assert_eq!(metadata.get_resource_group(), expected_resource_group);
717    }
718
719    #[test]
720    fn test_build_resource_id() {
721        let mocked_env = MockEnv::new(&[
722            (WEBSITE_OWNER_NAME, "foo"),
723            (WEBSITE_SITE_NAME, "my_website"),
724            (WEBSITE_RESOURCE_GROUP, "resource_group"),
725            (SERVICE_CONTEXT, "1"),
726        ]);
727
728        let metadata = AzureMetadata::new(mocked_env).unwrap();
729
730        assert_eq!(
731            metadata.get_resource_id(),
732            "/subscriptions/foo/resourcegroups/resource_group/providers/microsoft.web/sites/my_website"
733        )
734    }
735
736    #[test]
737    fn test_build_resource_id_with_missing_subscription_id() {
738        let mocked_env = MockEnv::new(&[
739            (WEBSITE_SITE_NAME, "my_website"),
740            (WEBSITE_RESOURCE_GROUP, "resource_group"),
741            (SERVICE_CONTEXT, "1"),
742        ]);
743
744        let metadata = AzureMetadata::new(mocked_env).unwrap();
745
746        assert_eq!(metadata.get_resource_id(), UNKNOWN_VALUE)
747    }
748
749    #[test]
750    fn test_build_resource_id_with_missing_site_name() {
751        let mocked_env = MockEnv::new(&[
752            (WEBSITE_OWNER_NAME, "foo"),
753            (WEBSITE_RESOURCE_GROUP, "resource_group"),
754            (SERVICE_CONTEXT, "1"),
755        ]);
756
757        let metadata = AzureMetadata::new(mocked_env).unwrap();
758
759        assert_eq!(metadata.get_resource_id(), UNKNOWN_VALUE)
760    }
761
762    #[test]
763    fn test_build_resource_id_with_missing_resource_group() {
764        let mocked_env = MockEnv::new(&[
765            (WEBSITE_OWNER_NAME, "foo"),
766            (WEBSITE_SITE_NAME, "my_website"),
767            (SERVICE_CONTEXT, "1"),
768        ]);
769
770        let metadata = AzureMetadata::new(mocked_env).unwrap();
771
772        assert_eq!(metadata.get_resource_id(), UNKNOWN_VALUE)
773    }
774
775    #[test]
776    fn test_build_resource_id_with_missing_info() {
777        let mocked_env = MockEnv::new(&[(SERVICE_CONTEXT, "1")]);
778        let metadata = AzureMetadata::new(mocked_env).unwrap();
779
780        assert_eq!(metadata.get_resource_id(), UNKNOWN_VALUE)
781    }
782
783    #[test]
784    fn test_site_type_and_kind_default() {
785        let mocked_env = MockEnv::new(&[(SERVICE_CONTEXT, "1")]);
786        let metadata = AzureMetadata::new(mocked_env).unwrap();
787
788        assert_eq!(metadata.get_site_type(), "app");
789        assert_eq!(metadata.get_site_kind(), "app")
790    }
791
792    #[test]
793    fn test_site_type_and_kind_if_worker_runtime_not_specified() {
794        let mocked_env = MockEnv::new(&[
795            (FUNCTIONS_WORKER_RUNTIME, "my_runtime"),
796            (SERVICE_CONTEXT, "1"),
797        ]);
798        let metadata = AzureMetadata::new(mocked_env).unwrap();
799
800        assert_eq!(metadata.get_site_kind(), "functionapp");
801        assert_eq!(metadata.get_site_type(), "function")
802    }
803
804    #[test]
805    fn test_site_type_and_kind_if_extension_version_not_specified() {
806        let mocked_env = MockEnv::new(&[
807            (FUNCTIONS_EXTENSION_VERSION, "next_version"),
808            (SERVICE_CONTEXT, "1"),
809        ]);
810        let metadata = AzureMetadata::new(mocked_env).unwrap();
811
812        assert_eq!(metadata.get_site_kind(), "functionapp");
813        assert_eq!(metadata.get_site_type(), "function")
814    }
815
816    #[test]
817    fn test_site_type_and_kind_if_both_specified() {
818        let mocked_env = MockEnv::new(&[
819            (FUNCTIONS_WORKER_RUNTIME, "my_runtime"),
820            (FUNCTIONS_EXTENSION_VERSION, "next_version"),
821            (SERVICE_CONTEXT, "1"),
822        ]);
823        let metadata = AzureMetadata::new(mocked_env).unwrap();
824
825        assert_eq!(metadata.get_site_kind(), "functionapp");
826        assert_eq!(metadata.get_site_type(), "function")
827    }
828
829    #[test]
830    fn test_check_other_simple_env_retrieval() {
831        let expected_site_name = "my_site_name".to_owned();
832        let expected_resource_group = "my_resource_group".to_owned();
833        let expected_site_version = "v42".to_owned();
834        let expected_operating_system = "FreeBSD".to_owned();
835        let expected_instance_name = "my_instance_name".to_owned();
836        let expected_instance_id = "my_instance_id".to_owned();
837        let expected_function_extension_version = "~4".to_owned();
838        let expected_runtime = "node".to_owned();
839        let expected_runtime_version = "18".to_owned();
840
841        let mocked_env = MockEnv::new(&[
842            (WEBSITE_SITE_NAME, expected_site_name.as_str()),
843            (WEBSITE_RESOURCE_GROUP, expected_resource_group.as_str()),
844            (SITE_EXTENSION_VERSION, expected_site_version.as_str()),
845            (WEBSITE_OS, expected_operating_system.as_str()),
846            (COMPUTERNAME, expected_instance_name.as_str()),
847            (INSTANCE_ID, expected_instance_id.as_str()),
848            (SERVICE_CONTEXT, "1"),
849            (
850                FUNCTIONS_EXTENSION_VERSION,
851                expected_function_extension_version.as_str(),
852            ),
853            (FUNCTIONS_WORKER_RUNTIME, expected_runtime.as_str()),
854            (
855                FUNCTIONS_WORKER_RUNTIME_VERSION,
856                expected_runtime_version.as_str(),
857            ),
858        ]);
859
860        let metadata = AzureMetadata::new(mocked_env).unwrap();
861
862        assert_eq!(expected_site_name, metadata.get_site_name());
863        assert_eq!(expected_resource_group, metadata.get_resource_group());
864        assert_eq!(expected_site_version, metadata.get_extension_version());
865        assert_eq!(expected_operating_system, metadata.get_operating_system());
866        assert_eq!(expected_instance_name, metadata.get_instance_name());
867        assert_eq!(expected_instance_id, metadata.get_instance_id());
868        assert_eq!(
869            expected_function_extension_version,
870            metadata.get_function_runtime_version()
871        );
872        assert_eq!(expected_runtime, metadata.get_runtime());
873        assert_eq!(expected_runtime_version, metadata.get_runtime_version());
874    }
875
876    #[test]
877    fn test_get_app_service_tags() {
878        let expected_site_name = "my_site_name";
879        let expected_resource_group = "my_resource_group";
880        let expected_site_version = "v42";
881        let expected_operating_system = "FreeBSD";
882        let expected_instance_name = "my_instance_name";
883        let expected_instance_id = "my_instance_id";
884        let expected_subscription_id = "sub-123";
885        let expected_resource_id = "/subscriptions/sub-123/resourcegroups/my_resource_group/providers/microsoft.web/sites/my_site_name";
886
887        let mocked_env = MockEnv::new(&[
888            (WEBSITE_SITE_NAME, expected_site_name),
889            (WEBSITE_RESOURCE_GROUP, expected_resource_group),
890            (SITE_EXTENSION_VERSION, expected_site_version),
891            (WEBSITE_OS, expected_operating_system),
892            (COMPUTERNAME, expected_instance_name),
893            (INSTANCE_ID, expected_instance_id),
894            (SERVICE_CONTEXT, "1"),
895            (
896                WEBSITE_OWNER_NAME,
897                &format!("{}+rg-webspace", expected_subscription_id),
898            ),
899        ]);
900
901        let metadata = AzureMetadata::new(mocked_env).unwrap();
902
903        // Collect tags into a HashMap for easy lookup
904        let tags: std::collections::HashMap<&str, &str> = metadata.get_app_service_tags().collect();
905
906        // Verify all 10 App Service tags are present
907        assert_eq!(tags.len(), 10);
908        assert_eq!(tags.get("aas.resource.id"), Some(&expected_resource_id));
909        assert_eq!(
910            tags.get("aas.environment.extension_version"),
911            Some(&expected_site_version)
912        );
913        assert_eq!(
914            tags.get("aas.environment.instance_id"),
915            Some(&expected_instance_id)
916        );
917        assert_eq!(
918            tags.get("aas.environment.instance_name"),
919            Some(&expected_instance_name)
920        );
921        assert_eq!(
922            tags.get("aas.environment.os"),
923            Some(&expected_operating_system)
924        );
925        assert_eq!(
926            tags.get("aas.resource.group"),
927            Some(&expected_resource_group)
928        );
929        assert_eq!(tags.get("aas.site.name"), Some(&expected_site_name));
930        assert_eq!(tags.get("aas.site.kind"), Some(&"app"));
931        assert_eq!(tags.get("aas.site.type"), Some(&"app"));
932        assert_eq!(
933            tags.get("aas.subscription.id"),
934            Some(&expected_subscription_id)
935        );
936
937        // Verify runtime tags are NOT present
938        assert_eq!(tags.get("aas.environment.runtime"), None);
939        assert_eq!(tags.get("aas.environment.runtime_version"), None);
940        assert_eq!(tags.get("aas.environment.function_runtime"), None);
941
942        // Verify it's an ExactSizeIterator
943        let iter = metadata.get_app_service_tags();
944        assert_eq!(iter.len(), 10);
945    }
946
947    #[test]
948    fn test_get_function_tags() {
949        let expected_site_name = "my_site_name";
950        let expected_resource_group = "my_resource_group";
951        let expected_operating_system = "FreeBSD";
952        let expected_instance_name = "my_instance_name";
953        let expected_instance_id = "my_instance_id";
954        let expected_function_extension_version = "~4";
955        let expected_runtime = "node";
956        let expected_runtime_version = "18";
957        let expected_subscription_id = "sub-123";
958        let expected_resource_id = "/subscriptions/sub-123/resourcegroups/my_resource_group/providers/microsoft.web/sites/my_site_name";
959
960        let mocked_env = MockEnv::new(&[
961            (WEBSITE_SITE_NAME, expected_site_name),
962            (WEBSITE_RESOURCE_GROUP, expected_resource_group),
963            (WEBSITE_OS, expected_operating_system),
964            (COMPUTERNAME, expected_instance_name),
965            (INSTANCE_ID, expected_instance_id),
966            (SERVICE_CONTEXT, "1"),
967            (
968                FUNCTIONS_EXTENSION_VERSION,
969                expected_function_extension_version,
970            ),
971            (FUNCTIONS_WORKER_RUNTIME, expected_runtime),
972            (FUNCTIONS_WORKER_RUNTIME_VERSION, expected_runtime_version),
973            (
974                WEBSITE_OWNER_NAME,
975                &format!("{}+rg-webspace", expected_subscription_id),
976            ),
977        ]);
978
979        let metadata = AzureMetadata::new(mocked_env).unwrap();
980
981        // Collect tags into a HashMap for easy lookup
982        let tags: std::collections::HashMap<&str, &str> = metadata.get_function_tags().collect();
983
984        // Verify all 12 Function tags are present
985        assert_eq!(tags.len(), 12);
986        assert_eq!(tags.get("aas.resource.id"), Some(&expected_resource_id));
987        assert_eq!(
988            tags.get("aas.environment.instance_id"),
989            Some(&expected_instance_id)
990        );
991        assert_eq!(
992            tags.get("aas.environment.instance_name"),
993            Some(&expected_instance_name)
994        );
995        assert_eq!(
996            tags.get("aas.environment.os"),
997            Some(&expected_operating_system)
998        );
999        assert_eq!(tags.get("aas.environment.runtime"), Some(&expected_runtime));
1000        assert_eq!(
1001            tags.get("aas.environment.runtime_version"),
1002            Some(&expected_runtime_version)
1003        );
1004        assert_eq!(
1005            tags.get("aas.environment.function_runtime"),
1006            Some(&expected_function_extension_version)
1007        );
1008        assert_eq!(
1009            tags.get("aas.resource.group"),
1010            Some(&expected_resource_group)
1011        );
1012        assert_eq!(tags.get("aas.site.name"), Some(&expected_site_name));
1013        assert_eq!(tags.get("aas.site.kind"), Some(&"functionapp"));
1014        assert_eq!(tags.get("aas.site.type"), Some(&"function"));
1015        assert_eq!(
1016            tags.get("aas.subscription.id"),
1017            Some(&expected_subscription_id)
1018        );
1019
1020        // Verify extension_version tag is NOT present
1021        assert_eq!(tags.get("aas.environment.extension_version"), None);
1022
1023        // Verify it's an ExactSizeIterator
1024        let iter = metadata.get_function_tags();
1025        assert_eq!(iter.len(), 12);
1026    }
1027
1028    #[test]
1029    fn test_get_trimmed_env_var_missing() {
1030        // The proc-environ snapshot is frozen at exec time, so we can't inject
1031        // an empty value here. Just verify the missing-var path returns None.
1032        assert_eq!(
1033            get_trimmed_env_var!("__LIBDD_AAS_DEFINITELY_NOT_SET__"),
1034            None
1035        );
1036    }
1037
1038    #[test]
1039    fn test_instance_name_computer_name_wins_over_pod_and_container() {
1040        let mocked_env = MockEnv::new(&[
1041            (FUNCTIONS_WORKER_RUNTIME, "node"),
1042            (COMPUTERNAME, "10-20-30-40"),
1043            (WEBSITE_POD_NAME, "pod-1"),
1044            (CONTAINER_NAME, "container-1"),
1045        ]);
1046        let metadata = AzureMetadata::new_function(mocked_env).unwrap();
1047        assert_eq!(metadata.get_instance_name(), "10-20-30-40");
1048    }
1049
1050    #[test]
1051    fn test_instance_name_pod_name_preferred_over_container_name() {
1052        let mocked_env = MockEnv::new(&[
1053            (FUNCTIONS_WORKER_RUNTIME, "node"),
1054            (WEBSITE_POD_NAME, "0--abc-test"),
1055            (CONTAINER_NAME, "container-1"),
1056        ]);
1057        let metadata = AzureMetadata::new_function(mocked_env).unwrap();
1058        assert_eq!(metadata.get_instance_name(), "0--abc-test");
1059    }
1060
1061    #[test]
1062    fn test_instance_name_falls_back_to_container_name() {
1063        let mocked_env = MockEnv::new(&[
1064            (FUNCTIONS_WORKER_RUNTIME, "node"),
1065            (CONTAINER_NAME, "0--abc-test"),
1066        ]);
1067        let metadata = AzureMetadata::new_function(mocked_env).unwrap();
1068        assert_eq!(metadata.get_instance_name(), "0--abc-test");
1069    }
1070
1071    #[test]
1072    fn test_instance_name_empty_string_treated_as_missing() {
1073        let mocked_env = MockEnv::new(&[
1074            (FUNCTIONS_WORKER_RUNTIME, "node"),
1075            (CONTAINER_NAME, ""),
1076            (WEBSITE_POD_NAME, ""),
1077            (COMPUTERNAME, "worker-1"),
1078        ]);
1079        let metadata = AzureMetadata::new_function(mocked_env).unwrap();
1080        assert_eq!(metadata.get_instance_name(), "worker-1");
1081    }
1082
1083    #[test]
1084    fn test_instance_name_whitespace_only_treated_as_missing() {
1085        let mocked_env = MockEnv::new(&[
1086            (FUNCTIONS_WORKER_RUNTIME, "node"),
1087            (CONTAINER_NAME, "   "),
1088            (WEBSITE_POD_NAME, "   "),
1089            (COMPUTERNAME, "worker-2"),
1090        ]);
1091        let metadata = AzureMetadata::new_function(mocked_env).unwrap();
1092        assert_eq!(metadata.get_instance_name(), "worker-2");
1093    }
1094
1095    #[test]
1096    fn test_instance_name_no_instance_vars_is_unknown() {
1097        let mocked_env = MockEnv::new(&[(FUNCTIONS_WORKER_RUNTIME, "node")]);
1098        let metadata = AzureMetadata::new_function(mocked_env).unwrap();
1099        assert_eq!(metadata.get_instance_name(), UNKNOWN_VALUE);
1100    }
1101}