1use std::fs::File;
2use std::io::Read;
3use std::sync::Arc;
4
5use azure_core::{
6 Error,
7 credentials::TokenCredential,
8 error::ErrorKind,
9 http::{StatusCode, Url},
10};
11use azure_storage_blob::{BlobContainerClient, BlobContainerClientOptions};
12
13use bytes::Bytes;
14use futures::FutureExt;
15use snafu::Snafu;
16use tower::ServiceBuilder;
17use vector_lib::{
18 codecs::{JsonSerializerConfig, NewlineDelimitedEncoderConfig, encoding::Framer},
19 configurable::configurable_component,
20 request_metadata::{GroupedCountByteSize, MetaDescriptive, RequestMetadata},
21 sensitive_string::SensitiveString,
22 stream::DriverResponse,
23};
24
25use super::request_builder::AzureBlobRequestOptions;
26use crate::{
27 Result,
28 codecs::{Encoder, EncodingConfigWithFraming, SinkType},
29 config::{AcknowledgementsConfig, DataType, GenerateConfig, Input, SinkConfig, SinkContext},
30 event::{EventFinalizers, EventStatus, Finalizable},
31 sinks::{
32 Healthcheck, VectorSink,
33 azure_blob::{service::AzureBlobService, sink::AzureBlobSink},
34 azure_common::{
35 config::AzureAuthentication,
36 config::AzureBlobTlsConfig,
37 connection_string::{Auth, ParsedConnectionString},
38 shared_key_policy::SharedKeyAuthorizationPolicy,
39 },
40 util::{
41 BatchConfig, BulkSizeBasedDefaultBatchSettings, Compression, ServiceBuilderExt,
42 TowerRequestConfig, partitioner::KeyPartitioner, retries::RetryLogic,
43 service::TowerRequestConfigDefaults,
44 },
45 },
46 template::{ConfinementConfig, Template},
47};
48
49#[derive(Clone, Copy, Debug)]
50pub struct AzureBlobTowerRequestConfigDefaults;
51
52impl TowerRequestConfigDefaults for AzureBlobTowerRequestConfigDefaults {
53 const RATE_LIMIT_NUM: u64 = 250;
54}
55
56#[configurable_component(sink(
58 "azure_blob",
59 "Store your observability data in Azure Blob Storage."
60))]
61#[derive(Clone, Debug)]
62#[serde(deny_unknown_fields)]
63pub struct AzureBlobSinkConfig {
64 #[configurable(derived)]
65 #[serde(default)]
66 pub auth: Option<AzureAuthentication>,
67
68 #[configurable(metadata(
83 docs::warnings = "Access keys and SAS tokens can be used to gain unauthorized access to Azure Blob Storage \
84 resources. Numerous security breaches have occurred due to leaked connection strings. It is important to keep \
85 connection strings secure and not expose them in logs, error messages, or version control systems."
86 ))]
87 #[configurable(metadata(
88 docs::examples = "DefaultEndpointsProtocol=https;AccountName=mylogstorage;AccountKey=storageaccountkeybase64encoded;EndpointSuffix=core.windows.net"
89 ))]
90 #[configurable(metadata(
91 docs::examples = "BlobEndpoint=https://mylogstorage.blob.core.windows.net/;SharedAccessSignature=generatedsastoken"
92 ))]
93 #[configurable(metadata(docs::examples = "AccountName=mylogstorage"))]
94 pub connection_string: Option<SensitiveString>,
95
96 #[configurable(metadata(docs::examples = "mylogstorage"))]
101 pub(super) account_name: Option<String>,
102
103 #[configurable(metadata(docs::examples = "https://mylogstorage.blob.core.windows.net/"))]
108 pub(super) blob_endpoint: Option<String>,
109
110 #[configurable(metadata(docs::examples = "my-logs"))]
112 pub(super) container_name: String,
113
114 #[configurable(metadata(docs::examples = "date/%F/hour/%H/"))]
120 #[configurable(metadata(docs::examples = "year=%Y/month=%m/day=%d/"))]
121 #[configurable(metadata(
122 docs::examples = "kubernetes/{{ metadata.cluster }}/{{ metadata.application_name }}/"
123 ))]
124 #[serde(default = "default_blob_prefix")]
125 pub blob_prefix: Template,
126
127 #[configurable(metadata(docs::syntax_override = "strftime"))]
144 pub blob_time_format: Option<String>,
145
146 pub blob_append_uuid: Option<bool>,
156
157 #[serde(flatten)]
158 pub encoding: EncodingConfigWithFraming,
159
160 #[configurable(derived)]
167 #[serde(default = "Compression::gzip_default")]
168 pub compression: Compression,
169
170 #[configurable(derived)]
171 #[serde(default)]
172 pub batch: BatchConfig<BulkSizeBasedDefaultBatchSettings>,
173
174 #[configurable(derived)]
175 #[serde(default)]
176 pub request: TowerRequestConfig<AzureBlobTowerRequestConfigDefaults>,
177
178 #[configurable(derived)]
179 #[serde(
180 default,
181 deserialize_with = "crate::serde::bool_or_struct",
182 skip_serializing_if = "crate::serde::is_default"
183 )]
184 pub(super) acknowledgements: AcknowledgementsConfig,
185
186 #[configurable(derived)]
187 #[serde(default)]
188 pub tls: Option<AzureBlobTlsConfig>,
189
190 #[serde(flatten)]
191 pub confinement: ConfinementConfig,
192}
193
194pub fn default_blob_prefix() -> Template {
195 Template::try_from(DEFAULT_KEY_PREFIX).unwrap()
196}
197
198impl GenerateConfig for AzureBlobSinkConfig {
199 fn generate_config() -> toml::Value {
200 toml::Value::try_from(Self {
201 auth: None,
202 connection_string: Some(String::from("DefaultEndpointsProtocol=https;AccountName=some-account-name;AccountKey=some-account-key;").into()),
203 account_name: None,
204 blob_endpoint: None,
205 container_name: String::from("logs"),
206 blob_prefix: default_blob_prefix(),
207 blob_time_format: Some(String::from("%s")),
208 blob_append_uuid: Some(true),
209 encoding: (Some(NewlineDelimitedEncoderConfig::new()), JsonSerializerConfig::default()).into(),
210 compression: Compression::gzip_default(),
211 batch: BatchConfig::default(),
212 request: TowerRequestConfig::default(),
213 acknowledgements: Default::default(),
214 tls: None,
215 confinement: ConfinementConfig::default(),
216 })
217 .unwrap()
218 }
219}
220
221#[async_trait::async_trait]
222#[typetag::serde(name = "azure_blob")]
223impl SinkConfig for AzureBlobSinkConfig {
224 async fn build(&self, cx: SinkContext) -> Result<(VectorSink, Healthcheck)> {
225 let connection_string: String = match (
226 &self.connection_string,
227 &self.account_name,
228 &self.blob_endpoint,
229 ) {
230 (Some(connstr), None, None) => connstr.inner().into(),
231 (None, Some(account_name), None) => {
232 if self.auth.is_none() {
233 return Err(
234 "`auth` configuration must be provided when using `account_name`".into(),
235 );
236 }
237 format!("AccountName={}", account_name)
238 }
239 (None, None, Some(blob_endpoint)) => {
240 if self.auth.is_none() {
241 return Err(
242 "`auth` configuration must be provided when using `blob_endpoint`".into(),
243 );
244 }
245 let blob_endpoint = if blob_endpoint.ends_with('/') {
247 blob_endpoint.clone()
248 } else {
249 format!("{}/", blob_endpoint)
250 };
251 format!("BlobEndpoint={}", blob_endpoint)
252 }
253 (None, None, None) => {
254 return Err("One of `connection_string`, `account_name`, or `blob_endpoint` must be provided".into());
255 }
256 (Some(_), Some(_), _) => {
257 return Err("Cannot provide both `connection_string` and `account_name`".into());
258 }
259 (Some(_), _, Some(_)) => {
260 return Err("Cannot provide both `connection_string` and `blob_endpoint`".into());
261 }
262 (_, Some(_), Some(_)) => {
263 return Err("Cannot provide both `account_name` and `blob_endpoint`".into());
264 }
265 };
266
267 let client = build_client(
268 self.auth.clone(),
269 connection_string.clone(),
270 self.container_name.clone(),
271 cx.proxy(),
272 self.tls.clone(),
273 )
274 .await?;
275
276 let healthcheck = build_healthcheck(self.container_name.clone(), Arc::clone(&client))?;
277 let sink = self.build_processor(client)?;
278 self.confinement.set_confinement_gauge("sink", Self::NAME);
279 Ok((sink, healthcheck))
280 }
281
282 fn input(&self) -> Input {
283 Input::new(self.encoding.config().1.input_type() & DataType::Log)
284 }
285
286 fn acknowledgements(&self) -> &AcknowledgementsConfig {
287 &self.acknowledgements
288 }
289}
290
291const DEFAULT_KEY_PREFIX: &str = "blob/%F/";
292const DEFAULT_FILENAME_TIME_FORMAT: &str = "%s";
293const DEFAULT_FILENAME_APPEND_UUID: bool = true;
294
295impl AzureBlobSinkConfig {
296 pub fn build_processor(&self, client: Arc<BlobContainerClient>) -> crate::Result<VectorSink> {
297 let request_limits = self.request.into_settings();
298 let service = ServiceBuilder::new()
299 .settings(request_limits, AzureBlobRetryLogic)
300 .service(AzureBlobService::new(client));
301
302 let batcher_settings = self.batch.into_batcher_settings()?;
304
305 let blob_time_format = self
306 .blob_time_format
307 .as_ref()
308 .cloned()
309 .unwrap_or_else(|| DEFAULT_FILENAME_TIME_FORMAT.into());
310 let blob_append_uuid = self
311 .blob_append_uuid
312 .unwrap_or(DEFAULT_FILENAME_APPEND_UUID);
313
314 let transformer = self.encoding.transformer();
315 let (framer, serializer) = self.encoding.build(SinkType::MessageBased)?;
316 let encoder = Encoder::<Framer>::new(framer, serializer);
317
318 let request_options = AzureBlobRequestOptions {
319 container_name: self.container_name.clone(),
320 blob_time_format,
321 blob_append_uuid,
322 encoder: (transformer, encoder),
323 compression: self.compression,
324 };
325
326 let sink = AzureBlobSink::new(
327 service,
328 request_options,
329 self.key_partitioner()?,
330 batcher_settings,
331 );
332
333 Ok(VectorSink::from_event_streamsink(sink))
334 }
335
336 pub fn key_partitioner(&self) -> crate::Result<KeyPartitioner> {
337 let tpl = self
338 .blob_prefix
339 .clone()
340 .confine(&self.confinement, Self::NAME, "blob_prefix")?;
341 Ok(KeyPartitioner::new(tpl, None))
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use crate::template::ConfinementConfig;
349
350 #[test]
351 fn generate_config() {
352 crate::test_util::test_generate_config::<AzureBlobSinkConfig>();
353 }
354
355 #[test]
356 fn confinement_rejects_unconfined_blob_prefix() {
357 let template = Template::try_from("{{ tenant }}").unwrap();
358 let err = template
359 .confine(&ConfinementConfig::default(), "azure_blob", "blob_prefix")
360 .unwrap_err();
361 assert!(
362 err.to_string().contains("no literal string prefix"),
363 "unexpected error: {err}"
364 );
365 }
366
367 #[test]
368 fn confinement_opt_out_allows_unconfined_blob_prefix() {
369 let cfg = ConfinementConfig {
370 dangerously_allow_unconfined_template_resolution: true,
371 };
372 let template = Template::try_from("{{ tenant }}").unwrap();
373 assert!(template.confine(&cfg, "azure_blob", "blob_prefix").is_ok());
374 }
375
376 #[test]
377 fn confinement_blocks_dotdot_escape_at_render() {
378 use crate::event::Event;
379 use vector_lib::event::LogEvent;
380 use vrl::event_path;
381
382 let template = Template::try_from("safe/{{ tenant }}/").unwrap();
383 let template = template
384 .confine(&ConfinementConfig::default(), "azure_blob", "blob_prefix")
385 .unwrap();
386 let mut event = Event::Log(LogEvent::from("x"));
387 event
388 .as_mut_log()
389 .insert(event_path!("tenant"), "../../escape");
390 assert!(template.render_string(&event).is_err());
391 }
392}
393
394#[derive(Debug, Clone)]
395pub struct AzureBlobRequest {
396 pub blob_data: Bytes,
397 pub content_encoding: Option<&'static str>,
398 pub content_type: &'static str,
399 pub metadata: AzureBlobMetadata,
400 pub request_metadata: RequestMetadata,
401}
402
403impl Finalizable for AzureBlobRequest {
404 fn take_finalizers(&mut self) -> EventFinalizers {
405 std::mem::take(&mut self.metadata.finalizers)
406 }
407}
408
409impl MetaDescriptive for AzureBlobRequest {
410 fn get_metadata(&self) -> &RequestMetadata {
411 &self.request_metadata
412 }
413
414 fn metadata_mut(&mut self) -> &mut RequestMetadata {
415 &mut self.request_metadata
416 }
417}
418
419#[derive(Clone, Debug)]
420pub struct AzureBlobMetadata {
421 pub partition_key: String,
422 pub count: usize,
423 pub finalizers: EventFinalizers,
424}
425
426#[derive(Debug, Clone)]
427pub struct AzureBlobRetryLogic;
428
429impl RetryLogic for AzureBlobRetryLogic {
430 type Error = Error;
431 type Request = AzureBlobRequest;
432 type Response = AzureBlobResponse;
433
434 fn is_retriable_error(&self, error: &Self::Error) -> bool {
435 match error.http_status() {
436 Some(code) => code.is_server_error() || code == StatusCode::TooManyRequests,
437 None => false,
438 }
439 }
440}
441
442#[derive(Debug)]
443pub struct AzureBlobResponse {
444 pub events_byte_size: GroupedCountByteSize,
445 pub byte_size: usize,
446}
447
448impl DriverResponse for AzureBlobResponse {
449 fn event_status(&self) -> EventStatus {
450 EventStatus::Delivered
451 }
452
453 fn events_sent(&self) -> &GroupedCountByteSize {
454 &self.events_byte_size
455 }
456
457 fn bytes_sent(&self) -> Option<usize> {
458 Some(self.byte_size)
459 }
460}
461
462#[derive(Debug, Snafu)]
463pub enum HealthcheckError {
464 #[snafu(display("Invalid connection string specified"))]
465 InvalidCredentials,
466 #[snafu(display("Container: {:?} not found", container))]
467 UnknownContainer { container: String },
468 #[snafu(display("Unknown status code: {}", status))]
469 Unknown { status: StatusCode },
470}
471
472pub fn build_healthcheck(
473 container_name: String,
474 client: Arc<BlobContainerClient>,
475) -> crate::Result<Healthcheck> {
476 let healthcheck = async move {
477 let resp: crate::Result<()> = match client.get_properties(None).await {
478 Ok(_) => Ok(()),
479 Err(error) => {
480 let code = error.http_status();
481 Err(match code {
482 Some(StatusCode::Forbidden) => Box::new(HealthcheckError::InvalidCredentials),
483 Some(StatusCode::NotFound) => Box::new(HealthcheckError::UnknownContainer {
484 container: container_name,
485 }),
486 Some(status) => Box::new(HealthcheckError::Unknown { status }),
487 None => "unknown status code".into(),
488 })
489 }
490 };
491 resp
492 };
493
494 Ok(healthcheck.boxed())
495}
496
497pub async fn build_client(
498 auth: Option<AzureAuthentication>,
499 connection_string: String,
500 container_name: String,
501 proxy: &crate::config::ProxyConfig,
502 tls: Option<AzureBlobTlsConfig>,
503) -> crate::Result<Arc<BlobContainerClient>> {
504 let parsed = ParsedConnectionString::parse(&connection_string)
506 .map_err(|e| format!("Invalid connection string: {e}"))?;
507 let container_url = parsed
509 .container_url(&container_name)
510 .map_err(|e| format!("Failed to build container URL: {e}"))?;
511 let url = Url::parse(&container_url).map_err(|e| format!("Invalid container URL: {e}"))?;
512
513 let mut credential: Option<Arc<dyn TokenCredential>> = None;
514
515 let mut options = BlobContainerClientOptions::default();
517 match (parsed.auth(), &auth) {
518 (Auth::None, None) => {
519 warn!("No authentication method provided, requests will be anonymous.");
520 }
521 (Auth::Sas { .. }, None) => {
522 info!("Using SAS token authentication.");
523 }
524 (
525 Auth::SharedKey {
526 account_name,
527 account_key,
528 },
529 None,
530 ) => {
531 info!("Using Shared Key authentication.");
532
533 let policy = SharedKeyAuthorizationPolicy::new(
534 account_name,
535 account_key,
536 String::from("2025-11-05"),
538 )
539 .map_err(|e| format!("Failed to create SharedKey policy: {e}"))?;
540 options
541 .client_options
542 .per_call_policies
543 .push(Arc::new(policy));
544 }
545 (Auth::None, Some(AzureAuthentication::Specific(..))) => {
546 info!("Using Azure Authentication method.");
547 let credential_result: Arc<dyn TokenCredential> =
548 auth.unwrap().credential().await.map_err(|e| {
549 Error::with_message(
550 ErrorKind::Credential,
551 format!("Failed to configure Azure Authentication: {e}"),
552 )
553 })?;
554 credential = Some(credential_result);
555 }
556 (Auth::Sas { .. }, Some(AzureAuthentication::Specific(..))) => {
557 return Err(Box::new(Error::with_message(
558 ErrorKind::Credential,
559 "Cannot use both SAS token and another Azure Authentication method at the same time",
560 )));
561 }
562 (Auth::SharedKey { .. }, Some(AzureAuthentication::Specific(..))) => {
563 return Err(Box::new(Error::with_message(
564 ErrorKind::Credential,
565 "Cannot use both Shared Key and another Azure Authentication method at the same time",
566 )));
567 }
568 #[cfg(test)]
569 (Auth::None, Some(AzureAuthentication::MockCredential)) => {
570 warn!("Using mock token credential authentication.");
571 credential = Some(auth.unwrap().credential().await.unwrap());
572 }
573 #[cfg(test)]
574 (_, Some(AzureAuthentication::MockCredential)) => {
575 return Err(Box::new(Error::with_message(
576 ErrorKind::Credential,
577 "Cannot use both connection string auth and mock credential at the same time",
578 )));
579 }
580 }
581
582 let mut reqwest_builder = reqwest_13::ClientBuilder::new();
584 let bypass_proxy = {
585 let host = url.host_str().unwrap_or("");
586 let port = url.port();
587 proxy.no_proxy.matches(host)
588 || port
589 .map(|p| proxy.no_proxy.matches(&format!("{}:{}", host, p)))
590 .unwrap_or(false)
591 };
592 if bypass_proxy || !proxy.enabled {
593 reqwest_builder = reqwest_builder.no_proxy();
595 } else {
596 if let Some(http) = &proxy.http {
597 let p = reqwest_13::Proxy::http(http)
598 .map_err(|e| format!("Invalid HTTP proxy URL: {e}"))?;
599 reqwest_builder = reqwest_builder.proxy(p);
601 }
602 if let Some(https) = &proxy.https {
603 let p = reqwest_13::Proxy::https(https)
604 .map_err(|e| format!("Invalid HTTPS proxy URL: {e}"))?;
605 reqwest_builder = reqwest_builder.proxy(p);
607 }
608 }
609
610 if let Some(AzureBlobTlsConfig { ca_file }) = &tls
611 && let Some(ca_file) = ca_file
612 {
613 let mut buf = Vec::new();
614 File::open(ca_file)?.read_to_end(&mut buf)?;
615 let cert = reqwest_13::Certificate::from_pem(&buf)?;
616
617 warn!("Adding TLS root certificate from {}", ca_file.display());
618 reqwest_builder = reqwest_builder.add_root_certificate(cert);
619 }
620
621 options.client_options.transport = Some(azure_core::http::Transport::new(std::sync::Arc::new(
622 reqwest_builder
623 .build()
624 .map_err(|e| format!("Failed to build reqwest client: {e}"))?,
625 )));
626 let client =
627 BlobContainerClient::new(url, credential, Some(options)).map_err(|e| format!("{e}"))?;
628 Ok(Arc::new(client))
629}