1#![expect(
2 clippy::let_underscore_must_use,
3 reason = "derivative's Debug derive with format_with expands to a must_use let binding"
4)]
5
6use std::collections::{BTreeMap, HashMap};
7use std::fmt;
8use std::fs::File;
9use std::io::Read;
10use std::sync::Arc;
11
12use azure_core::{
13 Error,
14 credentials::TokenCredential,
15 error::ErrorKind,
16 http::{StatusCode, Url},
17};
18use azure_storage_blob::{BlobContainerClient, BlobContainerClientOptions};
19
20use bytes::Bytes;
21use derivative::Derivative;
22use futures::FutureExt;
23use snafu::Snafu;
24use tower::ServiceBuilder;
25use vector_lib::{
26 codecs::{JsonSerializerConfig, NewlineDelimitedEncoderConfig, encoding::Framer},
27 configurable::configurable_component,
28 request_metadata::{GroupedCountByteSize, MetaDescriptive, RequestMetadata},
29 sensitive_string::SensitiveString,
30 stream::{BatcherSettings, DriverResponse},
31};
32
33use super::request_builder::AzureBlobRequestOptions;
34use crate::{
35 codecs::{Encoder, EncodingConfigWithFraming, SinkType},
36 config::{
37 AcknowledgementsConfig, DataType, DynValidatedSink, GenerateConfig, Input, SinkConfig,
38 SinkContext, ValidatedSink,
39 },
40 event::{EventFinalizers, EventStatus, Finalizable},
41 sinks::{
42 Healthcheck, VectorSink,
43 azure_blob::{service::AzureBlobService, sink::AzureBlobSink},
44 azure_common::{
45 config::AzureAuthentication,
46 config::AzureBlobTlsConfig,
47 connection_string::{Auth, ParsedConnectionString},
48 shared_key_policy::SharedKeyAuthorizationPolicy,
49 },
50 util::{
51 BatchConfig, BulkSizeBasedDefaultBatchSettings, Compression, ServiceBuilderExt,
52 TowerRequestConfig, partitioner::KeyPartitioner, retries::RetryLogic,
53 service::TowerRequestConfigDefaults,
54 },
55 },
56 template::{ConfinedTemplate, ConfinementConfig, Template},
57};
58
59#[derive(Clone, Copy, Debug)]
60pub struct AzureBlobTowerRequestConfigDefaults;
61
62impl TowerRequestConfigDefaults for AzureBlobTowerRequestConfigDefaults {
63 const RATE_LIMIT_NUM: u64 = 250;
64}
65
66#[configurable_component(sink(
68 "azure_blob",
69 "Store your observability data in Azure Blob Storage."
70))]
71#[derive(Clone, Debug)]
72#[serde(deny_unknown_fields)]
73pub struct AzureBlobSinkConfig {
74 #[configurable(derived)]
75 #[serde(default)]
76 pub auth: Option<AzureAuthentication>,
77
78 #[configurable(metadata(
98 docs::warnings = "Access keys and SAS tokens can be used to gain unauthorized access to Azure Blob Storage \
99 resources. Numerous security breaches have occurred due to leaked connection strings. It is important to keep \
100 connection strings secure and not expose them in logs, error messages, or version control systems."
101 ))]
102 #[configurable(metadata(
103 docs::examples = "DefaultEndpointsProtocol=https;AccountName=mylogstorage;AccountKey=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=;EndpointSuffix=core.windows.net"
104 ))]
105 #[configurable(metadata(
106 docs::examples = "BlobEndpoint=https://mylogstorage.blob.core.windows.net/;SharedAccessSignature=generatedsastoken"
107 ))]
108 #[configurable(metadata(docs::examples = "AccountName=mylogstorage"))]
109 #[configurable(required_one_of = "azure_blob_credentials")]
110 pub connection_string: Option<SensitiveString>,
111
112 #[configurable(metadata(docs::examples = "mylogstorage"))]
117 #[configurable(required_one_of = "azure_blob_credentials")]
118 pub(super) account_name: Option<String>,
119
120 #[configurable(metadata(docs::examples = "https://mylogstorage.blob.core.windows.net/"))]
125 #[configurable(required_one_of = "azure_blob_credentials")]
126 pub(super) blob_endpoint: Option<String>,
127
128 #[configurable(metadata(docs::examples = "my-logs"))]
130 pub(super) container_name: String,
131
132 #[configurable(metadata(docs::examples = "date/%F/hour/%H/"))]
138 #[configurable(metadata(docs::examples = "year=%Y/month=%m/day=%d/"))]
139 #[configurable(metadata(
140 docs::examples = "kubernetes/{{ metadata.cluster }}/{{ metadata.application_name }}/"
141 ))]
142 #[serde(default = "default_blob_prefix")]
143 pub blob_prefix: Template,
144
145 #[configurable(metadata(docs::syntax_override = "strftime"))]
162 pub blob_time_format: Option<String>,
163
164 pub blob_append_uuid: Option<bool>,
174
175 #[serde(flatten)]
176 pub encoding: EncodingConfigWithFraming,
177
178 #[configurable(derived)]
185 #[serde(default = "Compression::gzip_default")]
186 pub compression: Compression,
187
188 #[configurable(metadata(docs::additional_props_description = "A single tag."))]
207 #[configurable(metadata(docs::examples = "example_tags()"))]
208 #[serde(default)]
209 pub tags: Option<BTreeMap<String, String>>,
210
211 #[configurable(metadata(docs::additional_props_description = "A key/value pair."))]
221 #[configurable(metadata(docs::advanced))]
222 #[serde(default)]
223 pub metadata: Option<HashMap<String, String>>,
224
225 #[configurable(derived)]
226 #[serde(default)]
227 pub batch: BatchConfig<BulkSizeBasedDefaultBatchSettings>,
228
229 #[configurable(derived)]
230 #[serde(default)]
231 pub request: TowerRequestConfig<AzureBlobTowerRequestConfigDefaults>,
232
233 #[configurable(derived)]
234 #[serde(
235 default,
236 deserialize_with = "crate::serde::bool_or_struct",
237 skip_serializing_if = "crate::serde::is_default"
238 )]
239 pub(super) acknowledgements: AcknowledgementsConfig,
240
241 #[configurable(derived)]
242 #[serde(default)]
243 pub tls: Option<AzureBlobTlsConfig>,
244
245 #[serde(flatten)]
246 pub confinement: ConfinementConfig,
247}
248
249pub fn default_blob_prefix() -> Template {
250 Template::try_from(DEFAULT_KEY_PREFIX).unwrap()
251}
252
253impl GenerateConfig for AzureBlobSinkConfig {
254 fn generate_config() -> serde_json::Value {
255 serde_json::to_value(Self {
256 auth: None,
257 connection_string: Some(String::from("DefaultEndpointsProtocol=https;AccountName=some-account-name;AccountKey=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=;").into()),
258 account_name: None,
259 blob_endpoint: None,
260 container_name: String::from("logs"),
261 blob_prefix: default_blob_prefix(),
262 blob_time_format: Some(String::from("%s")),
263 blob_append_uuid: Some(true),
264 encoding: (Some(NewlineDelimitedEncoderConfig::new()), JsonSerializerConfig::default()).into(),
265 compression: Compression::gzip_default(),
266 tags: None,
267 metadata: None,
268 batch: BatchConfig::default(),
269 request: TowerRequestConfig::default(),
270 acknowledgements: Default::default(),
271 tls: None,
272 confinement: ConfinementConfig::default(),
273 })
274 .unwrap()
275 }
276}
277
278fn example_tags() -> HashMap<String, String> {
279 HashMap::<_, _>::from_iter([
280 ("Project".to_string(), "Blue".to_string()),
281 ("Classification".to_string(), "confidential".to_string()),
282 ("PHI".to_string(), "True".to_string()),
283 ])
284}
285
286#[async_trait::async_trait]
287#[typetag::serde(name = "azure_blob")]
288impl SinkConfig for AzureBlobSinkConfig {
289 fn confinement_config(&self) -> Option<&crate::template::ConfinementConfig> {
290 Some(&self.confinement)
291 }
292
293 fn input(&self) -> Input {
294 Input::new(self.encoding.config().1.input_type() & DataType::Log)
295 }
296
297 fn acknowledgements(&self) -> &AcknowledgementsConfig {
298 &self.acknowledgements
299 }
300
301 fn as_dyn_validated(&self) -> Option<&dyn DynValidatedSink> {
302 Some(self)
303 }
304}
305
306#[derive(Clone, Derivative)]
307#[derivative(Debug)]
308pub struct ValidatedAzureBlob {
309 #[derivative(Debug = "ignore")]
312 parsed_connection_string: ParsedConnectionString,
313 #[derivative(Debug(format_with = "fmt_container_url"))]
316 container_url: Url,
317 batcher_settings: BatcherSettings,
318 blob_time_format: String,
319 blob_append_uuid: bool,
320 #[derivative(Debug(format_with = "fmt_confined_blob_prefix"))]
321 confined_blob_prefix: ConfinedTemplate,
322}
323
324fn fmt_container_url(url: &Url, f: &mut fmt::Formatter<'_>) -> fmt::Result {
327 let mut url = url.clone();
328 url.set_query(None);
329 fmt::Debug::fmt(&url, f)
330}
331
332fn fmt_confined_blob_prefix(
334 template: &ConfinedTemplate,
335 f: &mut fmt::Formatter<'_>,
336) -> fmt::Result {
337 fmt::Debug::fmt(&template.to_string(), f)
338}
339
340#[async_trait::async_trait]
341impl ValidatedSink for AzureBlobSinkConfig {
342 type Validated = ValidatedAzureBlob;
343
344 fn validate(&self) -> crate::Result<ValidatedAzureBlob> {
345 let connection_string: String = match (
346 &self.connection_string,
347 &self.account_name,
348 &self.blob_endpoint,
349 ) {
350 (Some(connstr), None, None) => connstr.inner().into(),
351 (None, Some(account_name), None) => {
352 if self.auth.is_none() {
353 return Err(
354 "`auth` configuration must be provided when using `account_name`".into(),
355 );
356 }
357 format!("AccountName={}", account_name)
358 }
359 (None, None, Some(blob_endpoint)) => {
360 if self.auth.is_none() {
361 return Err(
362 "`auth` configuration must be provided when using `blob_endpoint`".into(),
363 );
364 }
365 let blob_endpoint = if blob_endpoint.ends_with('/') {
367 blob_endpoint.clone()
368 } else {
369 format!("{}/", blob_endpoint)
370 };
371 format!("BlobEndpoint={}", blob_endpoint)
372 }
373 (None, None, None) => {
374 return Err("One of `connection_string`, `account_name`, or `blob_endpoint` must be provided".into());
375 }
376 (Some(_), Some(_), _) => {
377 return Err("Cannot provide both `connection_string` and `account_name`".into());
378 }
379 (Some(_), _, Some(_)) => {
380 return Err("Cannot provide both `connection_string` and `blob_endpoint`".into());
381 }
382 (_, Some(_), Some(_)) => {
383 return Err("Cannot provide both `account_name` and `blob_endpoint`".into());
384 }
385 };
386
387 let parsed_connection_string = ParsedConnectionString::parse(&connection_string)
391 .map_err(|e| format!("Invalid connection string: {e}"))?;
392 validate_auth_conflict(&parsed_connection_string.auth(), &self.auth)?;
395 if let Auth::SharedKey {
398 account_name,
399 account_key,
400 } = parsed_connection_string.auth()
401 {
402 SharedKeyAuthorizationPolicy::new(
403 account_name,
404 account_key,
405 String::from("2025-11-05"),
407 )
408 .map_err(|e| format!("Failed to create SharedKey policy: {e}"))?;
409 }
410 let container_url = parsed_connection_string
411 .container_url(&self.container_name)
412 .map_err(|e| format!("Failed to build container URL: {e}"))?;
413 let container_url =
414 Url::parse(&container_url).map_err(|e| format!("Invalid container URL: {e}"))?;
415
416 let batcher_settings = self.batch.into_batcher_settings()?;
417
418 let blob_time_format = self
419 .blob_time_format
420 .as_ref()
421 .cloned()
422 .unwrap_or_else(|| DEFAULT_FILENAME_TIME_FORMAT.into());
423 let blob_append_uuid = self
424 .blob_append_uuid
425 .unwrap_or(DEFAULT_FILENAME_APPEND_UUID);
426
427 let confined_blob_prefix = self.confined_blob_prefix()?;
428
429 Ok(ValidatedAzureBlob {
430 parsed_connection_string,
431 container_url,
432 batcher_settings,
433 blob_time_format,
434 blob_append_uuid,
435 confined_blob_prefix,
436 })
437 }
438
439 async fn build(
440 &self,
441 validated: &ValidatedAzureBlob,
442 cx: SinkContext,
443 ) -> crate::Result<(VectorSink, Healthcheck)> {
444 let client = build_client(
445 self.auth.clone(),
446 validated.parsed_connection_string.clone(),
447 validated.container_url.clone(),
448 cx.proxy(),
449 self.tls.clone(),
450 )
451 .await?;
452
453 let healthcheck = build_healthcheck(self.container_name.clone(), Arc::clone(&client))?;
454 let sink = self.build_processor(client, validated)?;
455 Ok((sink, healthcheck))
456 }
457}
458
459const DEFAULT_KEY_PREFIX: &str = "blob/%F/";
460const DEFAULT_FILENAME_TIME_FORMAT: &str = "%s";
461const DEFAULT_FILENAME_APPEND_UUID: bool = true;
462
463impl AzureBlobSinkConfig {
464 pub fn build_processor(
465 &self,
466 client: Arc<BlobContainerClient>,
467 validated: &ValidatedAzureBlob,
468 ) -> crate::Result<VectorSink> {
469 let request_limits = self.request.into_settings();
470 let service = ServiceBuilder::new()
471 .settings(request_limits, AzureBlobRetryLogic)
472 .service(AzureBlobService::new(client));
473
474 let (framer, serializer) = self.encoding.build(SinkType::MessageBased)?;
475 let encoder = Encoder::<Framer>::new(framer, serializer);
476
477 let request_options = AzureBlobRequestOptions {
478 container_name: self.container_name.clone(),
479 blob_time_format: validated.blob_time_format.clone(),
480 blob_append_uuid: validated.blob_append_uuid,
481 encoder: (self.encoding.transformer(), encoder),
482 compression: self.compression,
483 tags: self.tags.clone(),
484 metadata: self.metadata.clone(),
485 };
486
487 let sink = AzureBlobSink::new(
488 service,
489 request_options,
490 KeyPartitioner::new(validated.confined_blob_prefix.clone(), None),
491 validated.batcher_settings,
492 );
493
494 Ok(VectorSink::from_event_streamsink(sink))
495 }
496
497 pub fn key_partitioner(&self) -> crate::Result<KeyPartitioner> {
498 let tpl = self.confined_blob_prefix()?;
499 Ok(KeyPartitioner::new(tpl, None))
500 }
501
502 fn confined_blob_prefix(&self) -> crate::Result<ConfinedTemplate> {
503 self.blob_prefix
504 .clone()
505 .confine(&self.confinement, Self::NAME, "blob_prefix")
506 }
507}
508
509#[cfg(test)]
510mod tests {
511 use super::*;
512 use crate::{
513 sinks::azure_common::config::SpecificAzureCredential, template::ConfinementConfig,
514 };
515
516 fn test_config(
517 connection_string: Option<&str>,
518 auth: Option<AzureAuthentication>,
519 ) -> AzureBlobSinkConfig {
520 AzureBlobSinkConfig {
521 auth,
522 connection_string: connection_string.map(|s| s.to_string().into()),
523 tags: None,
524 metadata: None,
525 account_name: None,
526 blob_endpoint: None,
527 container_name: "my-logs".to_string(),
528 blob_prefix: "blob".try_into().unwrap(),
529 blob_time_format: None,
530 blob_append_uuid: None,
531 encoding: (
532 Some(NewlineDelimitedEncoderConfig::new()),
533 JsonSerializerConfig::default(),
534 )
535 .into(),
536 compression: Compression::gzip_default(),
537 batch: BatchConfig::default(),
538 request: TowerRequestConfig::default(),
539 acknowledgements: Default::default(),
540 tls: None,
541 confinement: ConfinementConfig::default(),
542 }
543 }
544
545 #[test]
546 fn generate_config() {
547 crate::test_util::test_generate_config::<AzureBlobSinkConfig>();
548 }
549
550 #[test]
551 fn confinement_rejects_unconfined_blob_prefix() {
552 let template = Template::try_from("{{ tenant }}").unwrap();
553 let err = template
554 .confine(&ConfinementConfig::default(), "azure_blob", "blob_prefix")
555 .unwrap_err();
556 assert!(
557 err.to_string().contains("no literal string prefix"),
558 "unexpected error: {err}"
559 );
560 }
561
562 #[test]
563 fn confinement_opt_out_allows_unconfined_blob_prefix() {
564 let cfg = ConfinementConfig {
565 dangerously_allow_unconfined_template_resolution: true,
566 };
567 let template = Template::try_from("{{ tenant }}").unwrap();
568 assert!(template.confine(&cfg, "azure_blob", "blob_prefix").is_ok());
569 }
570
571 #[test]
572 fn confinement_blocks_dotdot_escape_at_render() {
573 use crate::event::Event;
574 use vector_lib::event::LogEvent;
575 use vrl::event_path;
576
577 let template = Template::try_from("safe/{{ tenant }}/").unwrap();
578 let template = template
579 .confine(&ConfinementConfig::default(), "azure_blob", "blob_prefix")
580 .unwrap();
581 let mut event = Event::Log(LogEvent::from("x"));
582 event
583 .as_mut_log()
584 .insert(event_path!("tenant"), "../../escape");
585 assert!(template.render_string(&event).is_err());
586 }
587
588 #[test]
589 fn validate_produces_usable_values() {
590 let config = AzureBlobSinkConfig {
591 auth: None,
592 connection_string: Some("AccountName=mylogstorage".to_string().into()),
593 tags: None,
594 metadata: None,
595 account_name: None,
596 blob_endpoint: None,
597 container_name: "my-logs".to_string(),
598 blob_prefix: "blob".try_into().unwrap(),
599 blob_time_format: None,
600 blob_append_uuid: None,
601 encoding: (
602 Some(NewlineDelimitedEncoderConfig::new()),
603 JsonSerializerConfig::default(),
604 )
605 .into(),
606 compression: Compression::gzip_default(),
607 batch: BatchConfig::default(),
608 request: TowerRequestConfig::default(),
609 acknowledgements: Default::default(),
610 tls: None,
611 confinement: ConfinementConfig::default(),
612 };
613
614 let validated = config.validate().expect("validation should succeed");
615 assert_eq!(validated.blob_time_format, "%s");
616 assert!(validated.blob_append_uuid);
617 assert_eq!(validated.confined_blob_prefix.to_string(), "blob");
618 }
619
620 #[test]
621 fn validated_debug_redacts_connection_string() {
622 let account_key = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=";
623 let config = test_config(
624 Some(&format!(
625 "AccountName=mylogstorage;AccountKey={account_key}"
626 )),
627 None,
628 );
629 let validated = config.validate().expect("validation should succeed");
630 let debug = format!("{validated:?}");
631 assert!(
632 !debug.contains(account_key),
633 "Debug output must not leak the connection string: {debug}"
634 );
635 }
636
637 #[test]
638 fn validated_debug_redacts_sas_token() {
639 let sas_sig = "supersecretsignature";
640 let config = test_config(
641 Some(&format!(
642 "BlobEndpoint=https://mylogstorage.blob.core.windows.net/;SharedAccessSignature=sv=2022-11-02&ss=b&srt=sco&sp=rcw&se=2099-01-01T00:00:00Z&sig={sas_sig}"
643 )),
644 None,
645 );
646 let validated = config.validate().expect("validation should succeed");
647 let debug = format!("{validated:?}");
648 assert!(
649 !debug.contains(sas_sig),
650 "Debug output must not leak the SAS token: {debug}"
651 );
652 }
653
654 #[test]
655 fn validate_rejects_malformed_connection_string() {
656 let config = AzureBlobSinkConfig {
657 auth: None,
658 connection_string: Some("not-a-valid-connection-string".to_string().into()),
659 tags: None,
660 metadata: None,
661 account_name: None,
662 blob_endpoint: None,
663 container_name: "my-logs".to_string(),
664 blob_prefix: "blob".try_into().unwrap(),
665 blob_time_format: None,
666 blob_append_uuid: None,
667 encoding: (
668 Some(NewlineDelimitedEncoderConfig::new()),
669 JsonSerializerConfig::default(),
670 )
671 .into(),
672 compression: Compression::gzip_default(),
673 batch: BatchConfig::default(),
674 request: TowerRequestConfig::default(),
675 acknowledgements: Default::default(),
676 tls: None,
677 confinement: ConfinementConfig::default(),
678 };
679
680 assert!(config.validate().is_err());
681 }
682
683 #[test]
684 fn validate_rejects_connection_string_sas_with_auth() {
685 let config = test_config(
686 Some(
687 "BlobEndpoint=https://mylogstorage.blob.core.windows.net/;SharedAccessSignature=sv=2022-11-02&ss=b&srt=sco&sp=rcw&se=2099-01-01T00:00:00Z&sig=...",
688 ),
689 Some(AzureAuthentication::Specific(
690 SpecificAzureCredential::ManagedIdentity {
691 user_assigned_managed_identity_id: None,
692 user_assigned_managed_identity_id_type: None,
693 },
694 )),
695 );
696
697 let err = config.validate().expect_err("validation should fail");
698 assert!(
699 err.to_string()
700 .contains("Cannot use both SAS token and another Azure Authentication method"),
701 "unexpected error: {err}"
702 );
703 }
704
705 #[test]
706 fn validate_rejects_connection_string_shared_key_with_auth() {
707 let config = test_config(
708 Some(
709 "DefaultEndpointsProtocol=https;AccountName=mylogstorage;AccountKey=base64key==;EndpointSuffix=core.windows.net",
710 ),
711 Some(AzureAuthentication::Specific(
712 SpecificAzureCredential::ManagedIdentity {
713 user_assigned_managed_identity_id: None,
714 user_assigned_managed_identity_id_type: None,
715 },
716 )),
717 );
718
719 let err = config.validate().expect_err("validation should fail");
720 assert!(
721 err.to_string()
722 .contains("Cannot use both Shared Key and another Azure Authentication method"),
723 "unexpected error: {err}"
724 );
725 }
726
727 #[test]
728 fn validate_accepts_connection_string_without_creds_with_auth() {
729 let config = test_config(
730 Some("AccountName=mylogstorage"),
731 Some(AzureAuthentication::Specific(
732 SpecificAzureCredential::ManagedIdentity {
733 user_assigned_managed_identity_id: None,
734 user_assigned_managed_identity_id_type: None,
735 },
736 )),
737 );
738
739 config.validate().expect("validation should succeed");
740 }
741
742 #[test]
743 fn validate_accepts_connection_string_with_creds_without_auth() {
744 let config = test_config(
745 Some(
746 "DefaultEndpointsProtocol=https;AccountName=mylogstorage;AccountKey=MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=;EndpointSuffix=core.windows.net",
747 ),
748 None,
749 );
750
751 config.validate().expect("validation should succeed");
752 }
753
754 #[test]
755 fn validate_rejects_invalid_base64_account_key() {
756 let config = test_config(
757 Some(
758 "DefaultEndpointsProtocol=https;AccountName=mylogstorage;AccountKey=base64key==;EndpointSuffix=core.windows.net",
759 ),
760 None,
761 );
762
763 let err = config.validate().expect_err("validation should fail");
764 assert!(
765 err.to_string()
766 .contains("Failed to create SharedKey policy"),
767 "unexpected error: {err}"
768 );
769 }
770}
771
772#[derive(Debug, Clone)]
773pub struct AzureBlobRequest {
774 pub blob_data: Bytes,
775 pub content_encoding: Option<&'static str>,
776 pub content_type: &'static str,
777 pub metadata: AzureBlobMetadata,
778 pub request_metadata: RequestMetadata,
779 pub tags: Option<String>,
781 pub blob_metadata: Option<std::collections::HashMap<String, String>>,
783}
784
785impl Finalizable for AzureBlobRequest {
786 fn take_finalizers(&mut self) -> EventFinalizers {
787 std::mem::take(&mut self.metadata.finalizers)
788 }
789}
790
791impl MetaDescriptive for AzureBlobRequest {
792 fn get_metadata(&self) -> &RequestMetadata {
793 &self.request_metadata
794 }
795
796 fn metadata_mut(&mut self) -> &mut RequestMetadata {
797 &mut self.request_metadata
798 }
799}
800
801#[derive(Clone, Debug)]
802pub struct AzureBlobMetadata {
803 pub partition_key: String,
804 pub count: usize,
805 pub finalizers: EventFinalizers,
806}
807
808#[derive(Debug, Clone)]
809pub struct AzureBlobRetryLogic;
810
811impl RetryLogic for AzureBlobRetryLogic {
812 type Error = Error;
813 type Request = AzureBlobRequest;
814 type Response = AzureBlobResponse;
815
816 fn is_retriable_error(&self, error: &Self::Error) -> bool {
817 match error.http_status() {
818 Some(code) => code.is_server_error() || code == StatusCode::TooManyRequests,
819 None => false,
820 }
821 }
822}
823
824#[derive(Debug)]
825pub struct AzureBlobResponse {
826 pub events_byte_size: GroupedCountByteSize,
827 pub byte_size: usize,
828}
829
830impl DriverResponse for AzureBlobResponse {
831 fn event_status(&self) -> EventStatus {
832 EventStatus::Delivered
833 }
834
835 fn events_sent(&self) -> &GroupedCountByteSize {
836 &self.events_byte_size
837 }
838
839 fn bytes_sent(&self) -> Option<usize> {
840 Some(self.byte_size)
841 }
842}
843
844#[derive(Debug, Snafu)]
845pub enum HealthcheckError {
846 #[snafu(display("Invalid connection string specified"))]
847 InvalidCredentials,
848 #[snafu(display("Container: {:?} not found", container))]
849 UnknownContainer { container: String },
850 #[snafu(display("Unknown status code: {}", status))]
851 Unknown { status: StatusCode },
852}
853
854pub fn build_healthcheck(
855 container_name: String,
856 client: Arc<BlobContainerClient>,
857) -> crate::Result<Healthcheck> {
858 let healthcheck = async move {
859 let resp: crate::Result<()> = match client.get_properties(None).await {
860 Ok(_) => Ok(()),
861 Err(error) => {
862 let code = error.http_status();
863 Err(match code {
864 Some(StatusCode::Forbidden) => Box::new(HealthcheckError::InvalidCredentials),
865 Some(StatusCode::NotFound) => Box::new(HealthcheckError::UnknownContainer {
866 container: container_name,
867 }),
868 Some(status) => Box::new(HealthcheckError::Unknown { status }),
869 None => "unknown status code".into(),
870 })
871 }
872 };
873 resp
874 };
875
876 Ok(healthcheck.boxed())
877}
878
879fn validate_auth_conflict(
885 parsed_auth: &Auth,
886 auth: &Option<AzureAuthentication>,
887) -> crate::Result<()> {
888 match (parsed_auth, auth) {
889 (Auth::Sas { .. }, Some(_)) => Err(
890 "Cannot use both SAS token and another Azure Authentication method at the same time"
891 .into(),
892 ),
893 (Auth::SharedKey { .. }, Some(_)) => Err(
894 "Cannot use both Shared Key and another Azure Authentication method at the same time"
895 .into(),
896 ),
897 _ => Ok(()),
898 }
899}
900
901pub async fn build_client(
902 auth: Option<AzureAuthentication>,
903 parsed: ParsedConnectionString,
904 url: Url,
905 proxy: &crate::config::ProxyConfig,
906 tls: Option<AzureBlobTlsConfig>,
907) -> crate::Result<Arc<BlobContainerClient>> {
908 let mut credential: Option<Arc<dyn TokenCredential>> = None;
911
912 validate_auth_conflict(&parsed.auth(), &auth)?;
917
918 let mut options = BlobContainerClientOptions::default();
920 match (parsed.auth(), &auth) {
921 (Auth::None, None) => {
922 warn!("No authentication method provided, requests will be anonymous.");
923 }
924 (Auth::Sas { .. }, None) => {
925 info!("Using SAS token authentication.");
926 }
927 (
928 Auth::SharedKey {
929 account_name,
930 account_key,
931 },
932 None,
933 ) => {
934 info!("Using Shared Key authentication.");
935
936 let policy = SharedKeyAuthorizationPolicy::new(
937 account_name,
938 account_key,
939 String::from("2025-11-05"),
941 )
942 .map_err(|e| format!("Failed to create SharedKey policy: {e}"))?;
943 options
944 .client_options
945 .per_call_policies
946 .push(Arc::new(policy));
947 }
948 (Auth::None, Some(AzureAuthentication::Specific(..))) => {
949 info!("Using Azure Authentication method.");
950 let credential_result: Arc<dyn TokenCredential> =
951 auth.unwrap().credential().await.map_err(|e| {
952 Error::with_message(
953 ErrorKind::Credential,
954 format!("Failed to configure Azure Authentication: {e}"),
955 )
956 })?;
957 credential = Some(credential_result);
958 }
959 (Auth::Sas { .. }, Some(AzureAuthentication::Specific(..))) => {
960 unreachable!("connection string SAS + explicit auth rejected in validate")
961 }
962 (Auth::SharedKey { .. }, Some(AzureAuthentication::Specific(..))) => {
963 unreachable!("connection string Shared Key + explicit auth rejected in validate")
964 }
965 #[cfg(test)]
966 (Auth::None, Some(AzureAuthentication::MockCredential)) => {
967 warn!("Using mock token credential authentication.");
968 credential = Some(auth.unwrap().credential().await.unwrap());
969 }
970 #[cfg(test)]
971 (_, Some(AzureAuthentication::MockCredential)) => {
972 unreachable!("connection string auth + mock credential rejected in validate")
973 }
974 }
975
976 let mut reqwest_builder = reqwest_13::ClientBuilder::new();
978 let bypass_proxy = {
979 let host = url.host_str().unwrap_or("");
980 let port = url.port();
981 proxy.no_proxy.matches(host)
982 || port
983 .map(|p| proxy.no_proxy.matches(&format!("{}:{}", host, p)))
984 .unwrap_or(false)
985 };
986 if bypass_proxy || !proxy.enabled {
987 reqwest_builder = reqwest_builder.no_proxy();
989 } else {
990 if let Some(http) = &proxy.http {
991 let p = reqwest_13::Proxy::http(http)
992 .map_err(|e| format!("Invalid HTTP proxy URL: {e}"))?;
993 reqwest_builder = reqwest_builder.proxy(p);
995 }
996 if let Some(https) = &proxy.https {
997 let p = reqwest_13::Proxy::https(https)
998 .map_err(|e| format!("Invalid HTTPS proxy URL: {e}"))?;
999 reqwest_builder = reqwest_builder.proxy(p);
1001 }
1002 }
1003
1004 if let Some(AzureBlobTlsConfig { ca_file }) = &tls
1005 && let Some(ca_file) = ca_file
1006 {
1007 let mut buf = Vec::new();
1008 File::open(ca_file)?.read_to_end(&mut buf)?;
1009 let cert = reqwest_13::Certificate::from_pem(&buf)?;
1010
1011 warn!("Adding TLS root certificate from {}", ca_file.display());
1012 reqwest_builder = reqwest_builder.add_root_certificate(cert);
1013 }
1014
1015 options.client_options.transport = Some(azure_core::http::Transport::new(std::sync::Arc::new(
1016 reqwest_builder
1017 .build()
1018 .map_err(|e| format!("Failed to build reqwest client: {e}"))?,
1019 )));
1020 let client =
1021 BlobContainerClient::new(url, credential, Some(options)).map_err(|e| format!("{e}"))?;
1022 Ok(Arc::new(client))
1023}