1use 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
48const 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
55const TOKEN_REFRESH_BUFFER: Duration = Duration::from_secs(5 * 60);
59
60const TOKEN_REFRESH_RETRY_INTERVAL: Duration = Duration::from_secs(10);
65
66type ExchangeFn = Arc<
69 dyn Fn(String, Vec<String>) -> BoxFuture<'static, azure_core::Result<AccessToken>>
70 + Send
71 + Sync,
72>;
73
74type TokenSlot = Arc<std::sync::RwLock<AccessToken>>;
76
77struct RefreshingWorkloadIdentityCredential {
88 federated_token_file: PathBuf,
89 exchange: ExchangeFn,
90 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 fn from_env() -> Option<azure_core::Result<Self>> {
111 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
190async 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 (exchange)(assertion.trim().to_string(), scopes).await
213}
214
215async 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 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 self.cache.write().await.clear();
290 Ok(())
291 }
292}
293
294fn 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#[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 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 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 Ok(AzureBlobConfig {
391 metrics,
392 client,
393 prefix,
394 })
395 }
396
397 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#[derive(Debug)]
456pub struct AzureBlob {
457 metrics: S3BlobMetrics,
458 client: ContainerClient,
459 prefix: String,
460}
461
462impl AzureBlob {
463 pub async fn open(config: AzureBlobConfig) -> Result<Self, ExternalError> {
465 if config.client.service_client().account() == EMULATOR_ACCOUNT {
466 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 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 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 let mut stream = blob.get().into_stream();
528
529 while let Some(value) = stream.next().await {
530 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 let metrics = self.metrics.clone();
546 requests.push_back(fetch_chunk(response, metrics));
547 }
548
549 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 struct MockExchange {
665 assertions: Vec<String>,
667 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 #[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 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 #[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 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 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 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)] #[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}