Skip to main content

mz_persist/
azure.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! An Azure Blob Storage implementation of [Blob] storage.
11
12use anyhow::{Context, anyhow};
13use async_trait::async_trait;
14use azure_core::auth::{AccessToken, TokenCredential};
15use azure_core::error::ErrorKind;
16use azure_core::{ExponentialRetryOptions, RetryOptions, StatusCode, TransportOptions};
17use azure_identity::{
18    TokenCredentialOptions, create_default_credential, federated_credentials_flow,
19};
20use azure_storage::{CloudLocation, EMULATOR_ACCOUNT, prelude::*};
21use azure_storage_blobs::blob::operations::GetBlobResponse;
22use azure_storage_blobs::prelude::*;
23use bytes::Bytes;
24use futures_util::future::BoxFuture;
25use futures_util::stream::FuturesOrdered;
26use futures_util::{FutureExt, StreamExt};
27use std::collections::BTreeMap;
28use std::fmt::{Debug, Formatter};
29use std::path::{Path, PathBuf};
30use std::sync::Arc;
31use std::time::Duration;
32use time::OffsetDateTime;
33use tokio::sync::RwLock;
34use tracing::{info, warn};
35use url::Url;
36use uuid::Uuid;
37
38use mz_ore::bytes::SegmentedBytes;
39use mz_ore::cast::CastFrom;
40use mz_ore::metrics::MetricsRegistry;
41use mz_ore::task::AbortOnDropHandle;
42
43use crate::cfg::BlobKnobs;
44use crate::error::Error;
45use crate::location::{Blob, BlobMetadata, Determinate, ExternalError};
46use crate::metrics::S3BlobMetrics;
47
48/// Environment variables that configure AKS-style workload identity. The
49/// names match the ones `azure_identity`'s credential chain reads.
50const AZURE_TENANT_ID: &str = "AZURE_TENANT_ID";
51const AZURE_CLIENT_ID: &str = "AZURE_CLIENT_ID";
52const AZURE_FEDERATED_TOKEN: &str = "AZURE_FEDERATED_TOKEN";
53const AZURE_FEDERATED_TOKEN_FILE: &str = "AZURE_FEDERATED_TOKEN_FILE";
54
55/// Time before an access token's expiry at which its refresh task fetches a
56/// replacement, so requests keep being served from an unexpired token while
57/// the refresh round trip to AAD is in flight.
58const TOKEN_REFRESH_BUFFER: Duration = Duration::from_secs(5 * 60);
59
60/// Minimum time a refresh task waits between fetch attempts once a refresh
61/// is due. This paces retries after failures, e.g. when AAD is transiently
62/// unreachable, and prevents hot-looping if issued tokens are already within
63/// [TOKEN_REFRESH_BUFFER] of expiry.
64const TOKEN_REFRESH_RETRY_INTERVAL: Duration = Duration::from_secs(10);
65
66/// Exchanges a client assertion (the projected service account token) for an
67/// AAD access token with the given scopes.
68type ExchangeFn = Arc<
69    dyn Fn(String, Vec<String>) -> BoxFuture<'static, azure_core::Result<AccessToken>>
70        + Send
71        + Sync,
72>;
73
74/// A shared slot holding the current access token for one scope set.
75type TokenSlot = Arc<std::sync::RwLock<AccessToken>>;
76
77/// A [TokenCredential] for AKS-style workload identity that re-reads the
78/// projected service account token file on every AAD access token refresh.
79///
80/// `azure_identity`'s `WorkloadIdentityCredential` reads
81/// `AZURE_FEDERATED_TOKEN_FILE` once at construction and holds the contents
82/// for the life of the process. Kubernetes rotates the projected token, so
83/// once the last cached AAD access token expires, every refresh presents an
84/// expired client assertion and fails, permanently locking a long-running
85/// process out of blob storage. Deferring the file read to refresh time picks
86/// up rotations.
87struct RefreshingWorkloadIdentityCredential {
88    federated_token_file: PathBuf,
89    exchange: ExchangeFn,
90    /// One token slot and refresh task per requested scope set. The task
91    /// keeps the slot fresh, so [TokenCredential::get_token] only blocks on
92    /// the first use of a scope set.
93    cache: RwLock<BTreeMap<Vec<String>, (TokenSlot, AbortOnDropHandle<()>)>>,
94    refresh_buffer: Duration,
95    retry_interval: Duration,
96}
97
98impl Debug for RefreshingWorkloadIdentityCredential {
99    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
100        f.debug_struct("RefreshingWorkloadIdentityCredential")
101            .field("federated_token_file", &self.federated_token_file)
102            .finish_non_exhaustive()
103    }
104}
105
106impl RefreshingWorkloadIdentityCredential {
107    /// Returns a credential if the workload identity environment variables
108    /// are present, or `None` to indicate that a different credential type
109    /// must be used.
110    fn from_env() -> Option<azure_core::Result<Self>> {
111        // A token provided directly via AZURE_FEDERATED_TOKEN is static, so
112        // there is nothing to re-read. `azure_identity`'s credential chain
113        // prefers it over the token file, defer to it to preserve that
114        // precedence.
115        if std::env::var(AZURE_FEDERATED_TOKEN).is_ok() {
116            return None;
117        }
118        let (Ok(tenant_id), Ok(client_id), Ok(token_file)) = (
119            std::env::var(AZURE_TENANT_ID),
120            std::env::var(AZURE_CLIENT_ID),
121            std::env::var(AZURE_FEDERATED_TOKEN_FILE),
122        ) else {
123            return None;
124        };
125        Some(Self::new(tenant_id, client_id, PathBuf::from(token_file)))
126    }
127
128    fn new(
129        tenant_id: String,
130        client_id: String,
131        federated_token_file: PathBuf,
132    ) -> azure_core::Result<Self> {
133        let options = TokenCredentialOptions::default();
134        let http_client = options.http_client();
135        let authority_host = options.authority_host()?;
136        let exchange: ExchangeFn = Arc::new(move |assertion, scopes| {
137            let http_client = Arc::clone(&http_client);
138            let authority_host = authority_host.clone();
139            let tenant_id = tenant_id.clone();
140            let client_id = client_id.clone();
141            async move {
142                let scopes: Vec<&str> = scopes.iter().map(String::as_str).collect();
143                let res = federated_credentials_flow::perform(
144                    http_client,
145                    &client_id,
146                    &assertion,
147                    &scopes,
148                    &tenant_id,
149                    &authority_host,
150                )
151                .await
152                .map_err(|err| {
153                    azure_core::error::Error::full(
154                        ErrorKind::Credential,
155                        err,
156                        "request token error",
157                    )
158                })?;
159                Ok(AccessToken::new(
160                    res.access_token().clone(),
161                    OffsetDateTime::now_utc() + Duration::from_secs(res.expires_in),
162                ))
163            }
164            .boxed()
165        });
166        Ok(Self::with_exchange(
167            federated_token_file,
168            exchange,
169            TOKEN_REFRESH_BUFFER,
170            TOKEN_REFRESH_RETRY_INTERVAL,
171        ))
172    }
173
174    fn with_exchange(
175        federated_token_file: PathBuf,
176        exchange: ExchangeFn,
177        refresh_buffer: Duration,
178        retry_interval: Duration,
179    ) -> Self {
180        Self {
181            federated_token_file,
182            exchange,
183            cache: RwLock::new(BTreeMap::new()),
184            refresh_buffer,
185            retry_interval,
186        }
187    }
188}
189
190/// Reads the projected service account token file and exchanges its contents
191/// for an AAD access token.
192async fn fetch_token(
193    federated_token_file: &Path,
194    exchange: &ExchangeFn,
195    scopes: Vec<String>,
196) -> azure_core::Result<AccessToken> {
197    let assertion = tokio::fs::read_to_string(federated_token_file)
198        .await
199        .map_err(|err| {
200            azure_core::error::Error::full(
201                ErrorKind::Credential,
202                err,
203                format!(
204                    "failed to read federated token from file {}",
205                    federated_token_file.display()
206                ),
207            )
208        })?;
209    // Kubernetes writes the projected token without surrounding whitespace,
210    // but a hand-provisioned file may have a trailing newline, which would
211    // corrupt the client assertion.
212    (exchange)(assertion.trim().to_string(), scopes).await
213}
214
215/// Keeps `slot` holding an unexpired token by fetching a replacement within
216/// `refresh_buffer` of the current token's expiry. A failed fetch leaves the
217/// current token in place and is retried after `retry_interval`.
218async fn refresh_task(
219    federated_token_file: PathBuf,
220    exchange: ExchangeFn,
221    slot: TokenSlot,
222    scopes: Vec<String>,
223    refresh_buffer: Duration,
224    retry_interval: Duration,
225) {
226    loop {
227        let refresh_at = slot.read().expect("lock poisoned").expires_on - refresh_buffer;
228        let wait = refresh_at - OffsetDateTime::now_utc();
229        let wait = if wait.is_positive() {
230            wait.unsigned_abs()
231        } else {
232            Duration::ZERO
233        };
234        tokio::time::sleep(wait.max(retry_interval)).await;
235        match fetch_token(&federated_token_file, &exchange, scopes.clone()).await {
236            Ok(token) => *slot.write().expect("lock poisoned") = token,
237            Err(err) => {
238                warn!("failed to refresh Azure workload identity token, will retry: {err}")
239            }
240        }
241    }
242}
243
244#[async_trait]
245impl TokenCredential for RefreshingWorkloadIdentityCredential {
246    async fn get_token(&self, scopes: &[&str]) -> azure_core::Result<AccessToken> {
247        let scopes_key: Vec<String> = scopes.iter().map(ToString::to_string).collect();
248
249        {
250            let cache = self.cache.read().await;
251            if let Some((slot, _refresh)) = cache.get(&scopes_key) {
252                return Ok(slot.read().expect("lock poisoned").clone());
253            }
254        }
255
256        let mut cache = self.cache.write().await;
257        if let Some((slot, _refresh)) = cache.get(&scopes_key) {
258            return Ok(slot.read().expect("lock poisoned").clone());
259        }
260
261        // First use of this scope set: fetch the initial token, then hand
262        // the slot to a task that keeps it fresh. A failed initial fetch is
263        // not cached, the next call retries it.
264        let token = fetch_token(
265            &self.federated_token_file,
266            &self.exchange,
267            scopes_key.clone(),
268        )
269        .await?;
270        let slot = Arc::new(std::sync::RwLock::new(token.clone()));
271        let refresh = mz_ore::task::spawn(
272            || "azure-workload-identity-token-refresh",
273            refresh_task(
274                self.federated_token_file.clone(),
275                Arc::clone(&self.exchange),
276                Arc::clone(&slot),
277                scopes_key.clone(),
278                self.refresh_buffer,
279                self.retry_interval,
280            ),
281        )
282        .abort_on_drop();
283        cache.insert(scopes_key, (slot, refresh));
284        Ok(token)
285    }
286
287    async fn clear_cache(&self) -> azure_core::Result<()> {
288        // Dropping the entries aborts their refresh tasks with them.
289        self.cache.write().await.clear();
290        Ok(())
291    }
292}
293
294/// Returns the token credential to use when the blob URL carries no SAS
295/// token.
296///
297/// Prefers [RefreshingWorkloadIdentityCredential] when its environment
298/// variables are present, because the workload identity credential in
299/// `azure_identity`'s default chain never re-reads the rotated token file.
300/// Otherwise falls back to the default chain, whose remaining credential
301/// types (e.g. managed identity via IMDS) refresh correctly.
302fn token_credential() -> Arc<dyn TokenCredential> {
303    match RefreshingWorkloadIdentityCredential::from_env() {
304        Some(credential) => {
305            info!("azure: using refreshing workload identity credentials");
306            Arc::new(credential.expect("Azure workload identity credentials"))
307        }
308        None => create_default_credential().expect("Azure default credentials"),
309    }
310}
311
312/// Configuration for opening an [AzureBlob].
313#[derive(Clone, Debug)]
314pub struct AzureBlobConfig {
315    metrics: S3BlobMetrics,
316    client: ContainerClient,
317    prefix: String,
318}
319
320impl AzureBlobConfig {
321    const EXTERNAL_TESTS_AZURE_CONTAINER: &'static str =
322        "MZ_PERSIST_EXTERNAL_STORAGE_TEST_AZURE_CONTAINER";
323
324    /// Returns a new [AzureBlobConfig] for use in production.
325    ///
326    /// Stores objects in the given container prepended with the (possibly empty)
327    /// prefix. Azure credentials must be available in the process or environment.
328    pub fn new(
329        account: String,
330        container: String,
331        prefix: String,
332        metrics: S3BlobMetrics,
333        url: Url,
334        knobs: Box<dyn BlobKnobs>,
335    ) -> Result<Self, Error> {
336        let transport = TransportOptions::new(Arc::new(
337            reqwest::ClientBuilder::new()
338                .timeout(knobs.operation_attempt_timeout())
339                .read_timeout(knobs.read_timeout())
340                .connect_timeout(knobs.connect_timeout())
341                .build()
342                .expect("valid config for azure HTTP client"),
343        ));
344        let retry = RetryOptions::exponential(
345            ExponentialRetryOptions::default().max_total_elapsed(knobs.operation_timeout()),
346        );
347
348        let client = if account == EMULATOR_ACCOUNT {
349            info!("Connecting to Azure emulator");
350            ClientBuilder::with_location(
351                CloudLocation::Emulator {
352                    address: url.domain().expect("domain for Azure emulator").to_string(),
353                    port: url.port().expect("port for Azure emulator"),
354                },
355                StorageCredentials::emulator(),
356            )
357        } else {
358            let sas_credentials = match url.query() {
359                Some(query) => Some(StorageCredentials::sas_token(query)),
360                None => None,
361            };
362
363            let credentials = match sas_credentials {
364                Some(Ok(credentials)) => credentials,
365                Some(Err(err)) => {
366                    warn!("Failed to parse SAS token: {err}");
367                    // TODO: should we fallback here? Or can we fully rely on query params
368                    // to determine whether a SAS token was provided?
369                    StorageCredentials::token_credential(token_credential())
370                }
371                None => StorageCredentials::token_credential(token_credential()),
372            };
373
374            ClientBuilder::new(account, credentials)
375        }
376        .transport(transport)
377        .retry(retry)
378        .blob_service_client()
379        .container_client(container);
380
381        // NOTE: a SAS token provided via the URL query string is static and
382        // never refreshed, so callers must provision one that outlives the
383        // process. Token credentials (workload identity and managed identity)
384        // refresh themselves.
385
386        Ok(AzureBlobConfig {
387            metrics,
388            client,
389            prefix,
390        })
391    }
392
393    /// Returns a new [AzureBlobConfig] for use in unit tests.
394    pub fn new_for_test() -> Result<Option<Self>, Error> {
395        struct TestBlobKnobs;
396        impl Debug for TestBlobKnobs {
397            fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
398                f.debug_struct("TestBlobKnobs").finish_non_exhaustive()
399            }
400        }
401        impl BlobKnobs for TestBlobKnobs {
402            fn operation_timeout(&self) -> Duration {
403                Duration::from_secs(30)
404            }
405
406            fn operation_attempt_timeout(&self) -> Duration {
407                Duration::from_secs(10)
408            }
409
410            fn connect_timeout(&self) -> Duration {
411                Duration::from_secs(5)
412            }
413
414            fn read_timeout(&self) -> Duration {
415                Duration::from_secs(5)
416            }
417
418            fn is_cc_active(&self) -> bool {
419                false
420            }
421        }
422
423        let container_name = match std::env::var(Self::EXTERNAL_TESTS_AZURE_CONTAINER) {
424            Ok(container) => container,
425            Err(_) => {
426                assert!(
427                    !mz_ore::env::is_var_truthy("CI"),
428                    "CI is supposed to run this test but something has gone wrong!"
429                );
430                return Ok(None);
431            }
432        };
433
434        let prefix = Uuid::new_v4().to_string();
435        let metrics = S3BlobMetrics::new(&MetricsRegistry::new());
436
437        let config = AzureBlobConfig::new(
438            EMULATOR_ACCOUNT.to_string(),
439            container_name.clone(),
440            prefix,
441            metrics,
442            Url::parse(&format!("http://localhost:40111/{}", container_name)).expect("valid url"),
443            Box::new(TestBlobKnobs),
444        )?;
445
446        Ok(Some(config))
447    }
448}
449
450/// Implementation of [Blob] backed by Azure Blob Storage.
451#[derive(Debug)]
452pub struct AzureBlob {
453    metrics: S3BlobMetrics,
454    client: ContainerClient,
455    prefix: String,
456}
457
458impl AzureBlob {
459    /// Opens the given location for non-exclusive read-write access.
460    pub async fn open(config: AzureBlobConfig) -> Result<Self, ExternalError> {
461        if config.client.service_client().account() == EMULATOR_ACCOUNT {
462            // TODO: we could move this logic into the test harness.
463            // it's currently here because it's surprisingly annoying to
464            // create the container out-of-band
465            if let Err(error) = config.client.create().await {
466                info!(
467                    ?error,
468                    "failed to create emulator container; this is expected on repeat runs"
469                );
470            }
471        }
472
473        let ret = AzureBlob {
474            metrics: config.metrics,
475            client: config.client,
476            prefix: config.prefix,
477        };
478
479        Ok(ret)
480    }
481
482    fn get_path(&self, key: &str) -> String {
483        format!("{}/{}", self.prefix, key)
484    }
485}
486
487#[async_trait]
488impl Blob for AzureBlob {
489    async fn get(&self, key: &str) -> Result<Option<SegmentedBytes>, ExternalError> {
490        let path = self.get_path(key);
491        let blob = self.client.blob_client(path);
492
493        /// Fetch the body of a single [`GetBlobResponse`].
494        async fn fetch_chunk(
495            response: GetBlobResponse,
496            metrics: S3BlobMetrics,
497        ) -> Result<Vec<Bytes>, ExternalError> {
498            let content_length = response.blob.properties.content_length;
499
500            let mut parts: Vec<Bytes> = Vec::new();
501            let mut total_len: u64 = 0;
502            let mut body = response.data;
503            while let Some(value) = body.next().await {
504                let value = value
505                    .map_err(|e| ExternalError::from(e.context("azure blob get body error")))?;
506                total_len += u64::cast_from(value.len());
507                parts.push(value);
508            }
509
510            // Report if the content-length header didn't match the number of
511            // bytes we read from the network.
512            if content_length != total_len {
513                metrics.get_invalid_resp.inc();
514            }
515
516            Ok(parts)
517        }
518
519        let mut requests = FuturesOrdered::new();
520        // TODO: the default chunk size is 1MB. We have not tried tuning it,
521        // but making this configurable / running some benchmarks could be
522        // valuable.
523        let mut stream = blob.get().into_stream();
524
525        while let Some(value) = stream.next().await {
526            // Return early if any of the individual fetch requests return an error.
527            let response = match value {
528                Ok(v) => v,
529                Err(e) => {
530                    if let Some(e) = e.as_http_error() {
531                        if e.status() == StatusCode::NotFound {
532                            return Ok(None);
533                        }
534                    }
535
536                    return Err(ExternalError::from(e.context("azure blob get error")));
537                }
538            };
539
540            // Drive all of the fetch requests concurrently.
541            let metrics = self.metrics.clone();
542            requests.push_back(fetch_chunk(response, metrics));
543        }
544
545        // Await on all of our chunks.
546        let mut segments = SegmentedBytes::with_capacity(requests.len());
547        while let Some(body) = requests.next().await {
548            for part in body.context("azure blob get body err")? {
549                segments.push(part);
550            }
551        }
552
553        Ok(Some(segments))
554    }
555
556    async fn list_keys_and_metadata(
557        &self,
558        key_prefix: &str,
559        f: &mut (dyn FnMut(BlobMetadata) + Send + Sync),
560    ) -> Result<(), ExternalError> {
561        let blob_key_prefix = self.get_path(key_prefix);
562        let strippable_root_prefix = format!("{}/", self.prefix);
563
564        let mut stream = self
565            .client
566            .list_blobs()
567            .prefix(blob_key_prefix.clone())
568            .into_stream();
569
570        while let Some(response) = stream.next().await {
571            let response =
572                response.map_err(|e| ExternalError::from(e.context("azure blob list error")))?;
573
574            for blob in response.blobs.items {
575                let azure_storage_blobs::container::operations::list_blobs::BlobItem::Blob(blob) =
576                    blob
577                else {
578                    continue;
579                };
580
581                if let Some(key) = blob.name.strip_prefix(&strippable_root_prefix) {
582                    let size_in_bytes = blob.properties.content_length;
583                    f(BlobMetadata { key, size_in_bytes });
584                }
585            }
586        }
587
588        Ok(())
589    }
590
591    async fn set(&self, key: &str, value: Bytes) -> Result<(), ExternalError> {
592        let path = self.get_path(key);
593        let blob = self.client.blob_client(path);
594
595        blob.put_block_blob(value)
596            .await
597            .map_err(|e| ExternalError::from(e.context("azure blob put error")))?;
598
599        Ok(())
600    }
601
602    async fn delete(&self, key: &str) -> Result<Option<usize>, ExternalError> {
603        let path = self.get_path(key);
604        let blob = self.client.blob_client(path);
605
606        match blob.get_properties().await {
607            Ok(props) => {
608                let size = usize::cast_from(props.blob.properties.content_length);
609                blob.delete()
610                    .await
611                    .map_err(|e| ExternalError::from(e.context("azure blob delete error")))?;
612                Ok(Some(size))
613            }
614            Err(e) => {
615                if let Some(e) = e.as_http_error() {
616                    if e.status() == StatusCode::NotFound {
617                        return Ok(None);
618                    }
619                }
620
621                Err(ExternalError::from(e.context("azure blob error")))
622            }
623        }
624    }
625
626    async fn restore(&self, key: &str) -> Result<(), ExternalError> {
627        let path = self.get_path(key);
628        let blob = self.client.blob_client(&path);
629
630        match blob.get_properties().await {
631            Ok(_) => Ok(()),
632            Err(e) => {
633                if let Some(e) = e.as_http_error() {
634                    if e.status() == StatusCode::NotFound {
635                        return Err(Determinate::new(anyhow!(
636                            "azure blob error: unable to restore non-existent key {key}"
637                        ))
638                        .into());
639                    }
640                }
641
642                Err(ExternalError::from(e.context("azure blob error")))
643            }
644        }
645    }
646}
647
648#[cfg(test)]
649mod tests {
650    use azure_core::auth::Secret;
651    use std::sync::Mutex;
652    use tracing::info;
653
654    use crate::location::tests::blob_impl_test;
655
656    use super::*;
657
658    /// A [MockExchange] wrapped for sharing with the credential's exchange
659    /// closure.
660    struct MockExchange {
661        /// Client assertions passed to each exchange call.
662        assertions: Vec<String>,
663        /// Whether the next exchange calls fail.
664        fail: bool,
665    }
666
667    fn mock_exchange(state: &Arc<Mutex<MockExchange>>) -> ExchangeFn {
668        let state = Arc::clone(state);
669        Arc::new(move |assertion, _scopes| {
670            let state = Arc::clone(&state);
671            async move {
672                let mut state = state.lock().unwrap();
673                state.assertions.push(assertion);
674                if state.fail {
675                    return Err(azure_core::error::Error::message(
676                        ErrorKind::Credential,
677                        "mock exchange failure",
678                    ));
679                }
680                Ok(AccessToken::new(
681                    Secret::new(format!("aad-{}", state.assertions.len())),
682                    OffsetDateTime::now_utc() + Duration::from_secs(3600),
683                ))
684            }
685            .boxed()
686        })
687    }
688
689    /// Tests that the token file is re-read (and trimmed) on every fetch,
690    /// that fetched tokens are served from the slot without further
691    /// exchanges, and that a failed initial fetch is not cached.
692    #[mz_ore::test(tokio::test)]
693    async fn refreshing_workload_identity_credential() {
694        let token_file = tempfile::NamedTempFile::new().expect("create temp token file");
695        std::fs::write(token_file.path(), "token-a\n").expect("write token file");
696
697        let state = Arc::new(Mutex::new(MockExchange {
698            assertions: Vec::new(),
699            fail: false,
700        }));
701        let credential = RefreshingWorkloadIdentityCredential::with_exchange(
702            token_file.path().to_path_buf(),
703            mock_exchange(&state),
704            TOKEN_REFRESH_BUFFER,
705            TOKEN_REFRESH_RETRY_INTERVAL,
706        );
707        let scopes = &["https://storage.azure.com/"];
708
709        let token = credential.get_token(scopes).await.expect("token");
710        assert_eq!(token.token.secret(), "aad-1");
711        let token = credential.get_token(scopes).await.expect("token");
712        assert_eq!(token.token.secret(), "aad-1");
713        assert_eq!(state.lock().unwrap().assertions, vec!["token-a"]);
714
715        // A failed initial fetch surfaces the error without caching it, and
716        // the rotated token file is re-read on the next fetch.
717        std::fs::write(token_file.path(), "token-b").expect("write token file");
718        credential.clear_cache().await.expect("clear cache");
719        state.lock().unwrap().fail = true;
720        assert!(credential.get_token(scopes).await.is_err());
721        state.lock().unwrap().fail = false;
722        let token = credential.get_token(scopes).await.expect("token");
723        assert_eq!(token.token.secret(), "aad-3");
724        assert_eq!(
725            state.lock().unwrap().assertions,
726            vec!["token-a", "token-b", "token-b"]
727        );
728    }
729
730    /// Tests that the background task refreshes the slot with fresh token
731    /// file contents and keeps the last good token through failed refreshes.
732    #[mz_ore::test(tokio::test)]
733    async fn workload_identity_credential_background_refresh() {
734        let token_file = tempfile::NamedTempFile::new().expect("create temp token file");
735        std::fs::write(token_file.path(), "token-a").expect("write token file");
736
737        let state = Arc::new(Mutex::new(MockExchange {
738            assertions: Vec::new(),
739            fail: false,
740        }));
741        // A refresh buffer longer than the issued validity makes every token
742        // immediately due, so refreshes run continuously at the (shortened)
743        // retry interval.
744        let credential = RefreshingWorkloadIdentityCredential::with_exchange(
745            token_file.path().to_path_buf(),
746            mock_exchange(&state),
747            Duration::from_secs(7200),
748            Duration::from_millis(10),
749        );
750        let scopes = &["https://storage.azure.com/"];
751
752        let token = credential.get_token(scopes).await.expect("token");
753        assert_eq!(token.token.secret(), "aad-1");
754
755        // The background task picks up the rotated token file without any
756        // caller blocking on the refresh.
757        std::fs::write(token_file.path(), "token-b").expect("write token file");
758        tokio::time::timeout(Duration::from_secs(30), async {
759            loop {
760                let token = credential.get_token(scopes).await.expect("token");
761                if token.token.secret() != "aad-1" {
762                    break;
763                }
764                tokio::time::sleep(Duration::from_millis(10)).await;
765            }
766        })
767        .await
768        .expect("token refreshed within timeout");
769        assert_eq!(
770            state.lock().unwrap().assertions.last().map(String::as_str),
771            Some("token-b")
772        );
773
774        // Failed refreshes keep the last good token in the slot and retry.
775        state.lock().unwrap().fail = true;
776        let held = credential.get_token(scopes).await.expect("token");
777        let calls_when_failing = state.lock().unwrap().assertions.len();
778        tokio::time::timeout(Duration::from_secs(30), async {
779            while state.lock().unwrap().assertions.len() <= calls_when_failing + 2 {
780                tokio::time::sleep(Duration::from_millis(10)).await;
781            }
782        })
783        .await
784        .expect("retries within timeout");
785        let token = credential.get_token(scopes).await.expect("token");
786        assert_eq!(token.token.secret(), held.token.secret());
787    }
788
789    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `TLS_method` on OS `linux`
790    #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
791    async fn azure_blob() -> Result<(), ExternalError> {
792        let config = match AzureBlobConfig::new_for_test()? {
793            Some(client) => client,
794            None => {
795                info!(
796                    "{} env not set: skipping test that uses external service",
797                    AzureBlobConfig::EXTERNAL_TESTS_AZURE_CONTAINER
798                );
799                return Ok(());
800            }
801        };
802
803        blob_impl_test(move |_path| {
804            let config = config.clone();
805            async move {
806                let config = AzureBlobConfig {
807                    metrics: config.metrics.clone(),
808                    client: config.client.clone(),
809                    prefix: config.prefix.clone(),
810                };
811                AzureBlob::open(config).await
812            }
813        })
814        .await
815    }
816}