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///
314/// NOTE: cloning shares the underlying client and therefore its HTTP
315/// connection pool. Connection-pool isolation (as hedged gets require, see
316/// [crate::hedge]) needs a fresh [AzureBlobConfig::new].
317#[derive(Clone, Debug)]
318pub struct AzureBlobConfig {
319    metrics: S3BlobMetrics,
320    client: ContainerClient,
321    prefix: String,
322}
323
324impl AzureBlobConfig {
325    const EXTERNAL_TESTS_AZURE_CONTAINER: &'static str =
326        "MZ_PERSIST_EXTERNAL_STORAGE_TEST_AZURE_CONTAINER";
327
328    /// Returns a new [AzureBlobConfig] for use in production.
329    ///
330    /// Stores objects in the given container prepended with the (possibly empty)
331    /// prefix. Azure credentials must be available in the process or environment.
332    pub fn new(
333        account: String,
334        container: String,
335        prefix: String,
336        metrics: S3BlobMetrics,
337        url: Url,
338        knobs: Box<dyn BlobKnobs>,
339    ) -> Result<Self, Error> {
340        let transport = TransportOptions::new(Arc::new(
341            reqwest::ClientBuilder::new()
342                .timeout(knobs.operation_attempt_timeout())
343                .read_timeout(knobs.read_timeout())
344                .connect_timeout(knobs.connect_timeout())
345                .build()
346                .expect("valid config for azure HTTP client"),
347        ));
348        let retry = RetryOptions::exponential(
349            ExponentialRetryOptions::default().max_total_elapsed(knobs.operation_timeout()),
350        );
351
352        let client = if account == EMULATOR_ACCOUNT {
353            info!("Connecting to Azure emulator");
354            ClientBuilder::with_location(
355                CloudLocation::Emulator {
356                    address: url.domain().expect("domain for Azure emulator").to_string(),
357                    port: url.port().expect("port for Azure emulator"),
358                },
359                StorageCredentials::emulator(),
360            )
361        } else {
362            let sas_credentials = match url.query() {
363                Some(query) => Some(StorageCredentials::sas_token(query)),
364                None => None,
365            };
366
367            let credentials = match sas_credentials {
368                Some(Ok(credentials)) => credentials,
369                Some(Err(err)) => {
370                    warn!("Failed to parse SAS token: {err}");
371                    // TODO: should we fallback here? Or can we fully rely on query params
372                    // to determine whether a SAS token was provided?
373                    StorageCredentials::token_credential(token_credential())
374                }
375                None => StorageCredentials::token_credential(token_credential()),
376            };
377
378            ClientBuilder::new(account, credentials)
379        }
380        .transport(transport)
381        .retry(retry)
382        .blob_service_client()
383        .container_client(container);
384
385        // NOTE: a SAS token provided via the URL query string is static and
386        // never refreshed, so callers must provision one that outlives the
387        // process. Token credentials (workload identity and managed identity)
388        // refresh themselves.
389
390        Ok(AzureBlobConfig {
391            metrics,
392            client,
393            prefix,
394        })
395    }
396
397    /// Returns a new [AzureBlobConfig] for use in unit tests.
398    pub fn new_for_test() -> Result<Option<Self>, Error> {
399        struct TestBlobKnobs;
400        impl Debug for TestBlobKnobs {
401            fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
402                f.debug_struct("TestBlobKnobs").finish_non_exhaustive()
403            }
404        }
405        impl BlobKnobs for TestBlobKnobs {
406            fn operation_timeout(&self) -> Duration {
407                Duration::from_secs(30)
408            }
409
410            fn operation_attempt_timeout(&self) -> Duration {
411                Duration::from_secs(10)
412            }
413
414            fn connect_timeout(&self) -> Duration {
415                Duration::from_secs(5)
416            }
417
418            fn read_timeout(&self) -> Duration {
419                Duration::from_secs(5)
420            }
421
422            fn is_cc_active(&self) -> bool {
423                false
424            }
425        }
426
427        let container_name = match std::env::var(Self::EXTERNAL_TESTS_AZURE_CONTAINER) {
428            Ok(container) => container,
429            Err(_) => {
430                assert!(
431                    !mz_ore::env::is_var_truthy("CI"),
432                    "CI is supposed to run this test but something has gone wrong!"
433                );
434                return Ok(None);
435            }
436        };
437
438        let prefix = Uuid::new_v4().to_string();
439        let metrics = S3BlobMetrics::new(&MetricsRegistry::new());
440
441        let config = AzureBlobConfig::new(
442            EMULATOR_ACCOUNT.to_string(),
443            container_name.clone(),
444            prefix,
445            metrics,
446            Url::parse(&format!("http://localhost:40111/{}", container_name)).expect("valid url"),
447            Box::new(TestBlobKnobs),
448        )?;
449
450        Ok(Some(config))
451    }
452}
453
454/// Implementation of [Blob] backed by Azure Blob Storage.
455#[derive(Debug)]
456pub struct AzureBlob {
457    metrics: S3BlobMetrics,
458    client: ContainerClient,
459    prefix: String,
460}
461
462impl AzureBlob {
463    /// Opens the given location for non-exclusive read-write access.
464    pub async fn open(config: AzureBlobConfig) -> Result<Self, ExternalError> {
465        if config.client.service_client().account() == EMULATOR_ACCOUNT {
466            // TODO: we could move this logic into the test harness.
467            // it's currently here because it's surprisingly annoying to
468            // create the container out-of-band
469            if let Err(error) = config.client.create().await {
470                info!(
471                    ?error,
472                    "failed to create emulator container; this is expected on repeat runs"
473                );
474            }
475        }
476
477        let ret = AzureBlob {
478            metrics: config.metrics,
479            client: config.client,
480            prefix: config.prefix,
481        };
482
483        Ok(ret)
484    }
485
486    fn get_path(&self, key: &str) -> String {
487        format!("{}/{}", self.prefix, key)
488    }
489}
490
491#[async_trait]
492impl Blob for AzureBlob {
493    async fn get(&self, key: &str) -> Result<Option<SegmentedBytes>, ExternalError> {
494        let path = self.get_path(key);
495        let blob = self.client.blob_client(path);
496
497        /// Fetch the body of a single [`GetBlobResponse`].
498        async fn fetch_chunk(
499            response: GetBlobResponse,
500            metrics: S3BlobMetrics,
501        ) -> Result<Vec<Bytes>, ExternalError> {
502            let content_length = response.blob.properties.content_length;
503
504            let mut parts: Vec<Bytes> = Vec::new();
505            let mut total_len: u64 = 0;
506            let mut body = response.data;
507            while let Some(value) = body.next().await {
508                let value = value
509                    .map_err(|e| ExternalError::from(e.context("azure blob get body error")))?;
510                total_len += u64::cast_from(value.len());
511                parts.push(value);
512            }
513
514            // Report if the content-length header didn't match the number of
515            // bytes we read from the network.
516            if content_length != total_len {
517                metrics.get_invalid_resp.inc();
518            }
519
520            Ok(parts)
521        }
522
523        let mut requests = FuturesOrdered::new();
524        // TODO: the default chunk size is 1MB. We have not tried tuning it,
525        // but making this configurable / running some benchmarks could be
526        // valuable.
527        let mut stream = blob.get().into_stream();
528
529        while let Some(value) = stream.next().await {
530            // Return early if any of the individual fetch requests return an error.
531            let response = match value {
532                Ok(v) => v,
533                Err(e) => {
534                    if let Some(e) = e.as_http_error() {
535                        if e.status() == StatusCode::NotFound {
536                            return Ok(None);
537                        }
538                    }
539
540                    return Err(ExternalError::from(e.context("azure blob get error")));
541                }
542            };
543
544            // Drive all of the fetch requests concurrently.
545            let metrics = self.metrics.clone();
546            requests.push_back(fetch_chunk(response, metrics));
547        }
548
549        // Await on all of our chunks.
550        let mut segments = SegmentedBytes::with_capacity(requests.len());
551        while let Some(body) = requests.next().await {
552            for part in body.context("azure blob get body err")? {
553                segments.push(part);
554            }
555        }
556
557        Ok(Some(segments))
558    }
559
560    async fn list_keys_and_metadata(
561        &self,
562        key_prefix: &str,
563        f: &mut (dyn FnMut(BlobMetadata) + Send + Sync),
564    ) -> Result<(), ExternalError> {
565        let blob_key_prefix = self.get_path(key_prefix);
566        let strippable_root_prefix = format!("{}/", self.prefix);
567
568        let mut stream = self
569            .client
570            .list_blobs()
571            .prefix(blob_key_prefix.clone())
572            .into_stream();
573
574        while let Some(response) = stream.next().await {
575            let response =
576                response.map_err(|e| ExternalError::from(e.context("azure blob list error")))?;
577
578            for blob in response.blobs.items {
579                let azure_storage_blobs::container::operations::list_blobs::BlobItem::Blob(blob) =
580                    blob
581                else {
582                    continue;
583                };
584
585                if let Some(key) = blob.name.strip_prefix(&strippable_root_prefix) {
586                    let size_in_bytes = blob.properties.content_length;
587                    f(BlobMetadata { key, size_in_bytes });
588                }
589            }
590        }
591
592        Ok(())
593    }
594
595    async fn set(&self, key: &str, value: Bytes) -> Result<(), ExternalError> {
596        let path = self.get_path(key);
597        let blob = self.client.blob_client(path);
598
599        blob.put_block_blob(value)
600            .await
601            .map_err(|e| ExternalError::from(e.context("azure blob put error")))?;
602
603        Ok(())
604    }
605
606    async fn delete(&self, key: &str) -> Result<Option<usize>, ExternalError> {
607        let path = self.get_path(key);
608        let blob = self.client.blob_client(path);
609
610        match blob.get_properties().await {
611            Ok(props) => {
612                let size = usize::cast_from(props.blob.properties.content_length);
613                blob.delete()
614                    .await
615                    .map_err(|e| ExternalError::from(e.context("azure blob delete error")))?;
616                Ok(Some(size))
617            }
618            Err(e) => {
619                if let Some(e) = e.as_http_error() {
620                    if e.status() == StatusCode::NotFound {
621                        return Ok(None);
622                    }
623                }
624
625                Err(ExternalError::from(e.context("azure blob error")))
626            }
627        }
628    }
629
630    async fn restore(&self, key: &str) -> Result<(), ExternalError> {
631        let path = self.get_path(key);
632        let blob = self.client.blob_client(&path);
633
634        match blob.get_properties().await {
635            Ok(_) => Ok(()),
636            Err(e) => {
637                if let Some(e) = e.as_http_error() {
638                    if e.status() == StatusCode::NotFound {
639                        return Err(Determinate::new(anyhow!(
640                            "azure blob error: unable to restore non-existent key {key}"
641                        ))
642                        .into());
643                    }
644                }
645
646                Err(ExternalError::from(e.context("azure blob error")))
647            }
648        }
649    }
650}
651
652#[cfg(test)]
653mod tests {
654    use azure_core::auth::Secret;
655    use std::sync::Mutex;
656    use tracing::info;
657
658    use crate::location::tests::blob_impl_test;
659
660    use super::*;
661
662    /// A [MockExchange] wrapped for sharing with the credential's exchange
663    /// closure.
664    struct MockExchange {
665        /// Client assertions passed to each exchange call.
666        assertions: Vec<String>,
667        /// Whether the next exchange calls fail.
668        fail: bool,
669    }
670
671    fn mock_exchange(state: &Arc<Mutex<MockExchange>>) -> ExchangeFn {
672        let state = Arc::clone(state);
673        Arc::new(move |assertion, _scopes| {
674            let state = Arc::clone(&state);
675            async move {
676                let mut state = state.lock().unwrap();
677                state.assertions.push(assertion);
678                if state.fail {
679                    return Err(azure_core::error::Error::message(
680                        ErrorKind::Credential,
681                        "mock exchange failure",
682                    ));
683                }
684                Ok(AccessToken::new(
685                    Secret::new(format!("aad-{}", state.assertions.len())),
686                    OffsetDateTime::now_utc() + Duration::from_secs(3600),
687                ))
688            }
689            .boxed()
690        })
691    }
692
693    /// Tests that the token file is re-read (and trimmed) on every fetch,
694    /// that fetched tokens are served from the slot without further
695    /// exchanges, and that a failed initial fetch is not cached.
696    #[mz_ore::test(tokio::test)]
697    async fn refreshing_workload_identity_credential() {
698        let token_file = tempfile::NamedTempFile::new().expect("create temp token file");
699        std::fs::write(token_file.path(), "token-a\n").expect("write token file");
700
701        let state = Arc::new(Mutex::new(MockExchange {
702            assertions: Vec::new(),
703            fail: false,
704        }));
705        let credential = RefreshingWorkloadIdentityCredential::with_exchange(
706            token_file.path().to_path_buf(),
707            mock_exchange(&state),
708            TOKEN_REFRESH_BUFFER,
709            TOKEN_REFRESH_RETRY_INTERVAL,
710        );
711        let scopes = &["https://storage.azure.com/"];
712
713        let token = credential.get_token(scopes).await.expect("token");
714        assert_eq!(token.token.secret(), "aad-1");
715        let token = credential.get_token(scopes).await.expect("token");
716        assert_eq!(token.token.secret(), "aad-1");
717        assert_eq!(state.lock().unwrap().assertions, vec!["token-a"]);
718
719        // A failed initial fetch surfaces the error without caching it, and
720        // the rotated token file is re-read on the next fetch.
721        std::fs::write(token_file.path(), "token-b").expect("write token file");
722        credential.clear_cache().await.expect("clear cache");
723        state.lock().unwrap().fail = true;
724        assert!(credential.get_token(scopes).await.is_err());
725        state.lock().unwrap().fail = false;
726        let token = credential.get_token(scopes).await.expect("token");
727        assert_eq!(token.token.secret(), "aad-3");
728        assert_eq!(
729            state.lock().unwrap().assertions,
730            vec!["token-a", "token-b", "token-b"]
731        );
732    }
733
734    /// Tests that the background task refreshes the slot with fresh token
735    /// file contents and keeps the last good token through failed refreshes.
736    #[mz_ore::test(tokio::test)]
737    async fn workload_identity_credential_background_refresh() {
738        let token_file = tempfile::NamedTempFile::new().expect("create temp token file");
739        std::fs::write(token_file.path(), "token-a").expect("write token file");
740
741        let state = Arc::new(Mutex::new(MockExchange {
742            assertions: Vec::new(),
743            fail: false,
744        }));
745        // A refresh buffer longer than the issued validity makes every token
746        // immediately due, so refreshes run continuously at the (shortened)
747        // retry interval.
748        let credential = RefreshingWorkloadIdentityCredential::with_exchange(
749            token_file.path().to_path_buf(),
750            mock_exchange(&state),
751            Duration::from_secs(7200),
752            Duration::from_millis(10),
753        );
754        let scopes = &["https://storage.azure.com/"];
755
756        let token = credential.get_token(scopes).await.expect("token");
757        assert_eq!(token.token.secret(), "aad-1");
758
759        // The background task picks up the rotated token file without any
760        // caller blocking on the refresh.
761        std::fs::write(token_file.path(), "token-b").expect("write token file");
762        tokio::time::timeout(Duration::from_secs(30), async {
763            loop {
764                let token = credential.get_token(scopes).await.expect("token");
765                if token.token.secret() != "aad-1" {
766                    break;
767                }
768                tokio::time::sleep(Duration::from_millis(10)).await;
769            }
770        })
771        .await
772        .expect("token refreshed within timeout");
773        assert_eq!(
774            state.lock().unwrap().assertions.last().map(String::as_str),
775            Some("token-b")
776        );
777
778        // Failed refreshes keep the last good token in the slot and retry.
779        state.lock().unwrap().fail = true;
780        let held = credential.get_token(scopes).await.expect("token");
781        let calls_when_failing = state.lock().unwrap().assertions.len();
782        tokio::time::timeout(Duration::from_secs(30), async {
783            while state.lock().unwrap().assertions.len() <= calls_when_failing + 2 {
784                tokio::time::sleep(Duration::from_millis(10)).await;
785            }
786        })
787        .await
788        .expect("retries within timeout");
789        let token = credential.get_token(scopes).await.expect("token");
790        assert_eq!(token.token.secret(), held.token.secret());
791    }
792
793    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `TLS_method` on OS `linux`
794    #[mz_ore::test(tokio::test(flavor = "multi_thread"))]
795    async fn azure_blob() -> Result<(), ExternalError> {
796        let config = match AzureBlobConfig::new_for_test()? {
797            Some(client) => client,
798            None => {
799                info!(
800                    "{} env not set: skipping test that uses external service",
801                    AzureBlobConfig::EXTERNAL_TESTS_AZURE_CONTAINER
802                );
803                return Ok(());
804            }
805        };
806
807        blob_impl_test(move |_path| {
808            let config = config.clone();
809            async move {
810                let config = AzureBlobConfig {
811                    metrics: config.metrics.clone(),
812                    client: config.client.clone(),
813                    prefix: config.prefix.clone(),
814                };
815                AzureBlob::open(config).await
816            }
817        })
818        .await
819    }
820}