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)]
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 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 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 Ok(AzureBlobConfig {
387 metrics,
388 client,
389 prefix,
390 })
391 }
392
393 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#[derive(Debug)]
452pub struct AzureBlob {
453 metrics: S3BlobMetrics,
454 client: ContainerClient,
455 prefix: String,
456}
457
458impl AzureBlob {
459 pub async fn open(config: AzureBlobConfig) -> Result<Self, ExternalError> {
461 if config.client.service_client().account() == EMULATOR_ACCOUNT {
462 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 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 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 let mut stream = blob.get().into_stream();
524
525 while let Some(value) = stream.next().await {
526 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 let metrics = self.metrics.clone();
542 requests.push_back(fetch_chunk(response, metrics));
543 }
544
545 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 struct MockExchange {
661 assertions: Vec<String>,
663 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 #[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 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 #[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 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 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 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)] #[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}