Skip to main content

vector/sinks/azure_blob/
config.rs

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/// Configuration for the `azure_blob` sink.
57#[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    /// The Azure Blob Storage Account connection string.
69    ///
70    /// Authentication with an access key or shared access signature (SAS)
71    /// are supported authentication methods. If using a non-account SAS,
72    /// healthchecks will fail and will need to be disabled by setting
73    /// `healthcheck.enabled` to `false` for this sink
74    ///
75    /// When generating an account SAS, the following are the minimum required option
76    /// settings for Vector to access blob storage and pass a health check.
77    /// | Option                 | Value              |
78    /// | ---------------------- | ------------------ |
79    /// | Allowed services       | Blob               |
80    /// | Allowed resource types | Container & Object |
81    /// | Allowed permissions    | Read & Create      |
82    #[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    /// The Azure Blob Storage Account name.
97    ///
98    /// If provided, this will be used instead of the `connection_string`.
99    /// This is useful for authenticating with an Azure credential.
100    #[configurable(metadata(docs::examples = "mylogstorage"))]
101    pub(super) account_name: Option<String>,
102
103    /// The Azure Blob Storage endpoint.
104    ///
105    /// If provided, this will be used instead of the `connection_string`.
106    /// This is useful for authenticating with an Azure credential.
107    #[configurable(metadata(docs::examples = "https://mylogstorage.blob.core.windows.net/"))]
108    pub(super) blob_endpoint: Option<String>,
109
110    /// The Azure Blob Storage Account container name.
111    #[configurable(metadata(docs::examples = "my-logs"))]
112    pub(super) container_name: String,
113
114    /// A prefix to apply to all blob keys.
115    ///
116    /// Prefixes are useful for partitioning objects, such as by creating a blob key that
117    /// stores blobs under a particular directory. If using a prefix for this purpose, it must end
118    /// in `/` to act as a directory path. A trailing `/` is **not** automatically added.
119    #[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    /// The timestamp format for the time component of the blob key.
128    ///
129    /// By default, blob keys are appended with a timestamp that reflects when the blob are sent to
130    /// Azure Blob Storage, such that the resulting blob key is functionally equivalent to joining
131    /// the blob prefix with the formatted timestamp, such as `date=2022-07-18/1658176486`.
132    ///
133    /// This would represent a `blob_prefix` set to `date=%F/` and the timestamp of Mon Jul 18 2022
134    /// 20:34:44 GMT+0000, with the `filename_time_format` being set to `%s`, which renders
135    /// timestamps in seconds since the Unix epoch.
136    ///
137    /// Supports the common [`strftime`][chrono_strftime_specifiers] specifiers found in most
138    /// languages.
139    ///
140    /// When set to an empty string, no timestamp is appended to the blob prefix.
141    ///
142    /// [chrono_strftime_specifiers]: https://docs.rs/chrono/latest/chrono/format/strftime/index.html#specifiers
143    #[configurable(metadata(docs::syntax_override = "strftime"))]
144    pub blob_time_format: Option<String>,
145
146    /// Whether or not to append a UUID v4 token to the end of the blob key.
147    ///
148    /// The UUID is appended to the timestamp portion of the object key, such that if the blob key
149    /// generated is `date=2022-07-18/1658176486`, setting this field to `true` results
150    /// in an blob key that looks like
151    /// `date=2022-07-18/1658176486-30f6652c-71da-4f9f-800d-a1189c47c547`.
152    ///
153    /// This ensures there are no name collisions, and can be useful in high-volume workloads where
154    /// blob keys must be unique.
155    pub blob_append_uuid: Option<bool>,
156
157    #[serde(flatten)]
158    pub encoding: EncodingConfigWithFraming,
159
160    /// Compression configuration.
161    ///
162    /// All compression algorithms use the default compression level unless otherwise specified.
163    ///
164    /// Some cloud storage API clients and browsers handle decompression transparently, so
165    /// depending on how they are accessed, files may not always appear to be compressed.
166    #[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                // BlobEndpoint must always end in a trailing slash
246                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        // Configure our partitioning/batching.
303        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    // Parse connection string without legacy SDK
505    let parsed = ParsedConnectionString::parse(&connection_string)
506        .map_err(|e| format!("Invalid connection string: {e}"))?;
507    // Compose container URL (SAS appended if present)
508    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    // Prepare options; attach Shared Key policy if needed
516    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                // Use an Azurite-supported storage service version
537                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    // Use reqwest v0.13 since Azure SDK only implements HttpClient for reqwest::Client v0.13
583    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        // Ensure no proxy (and disable any potential system proxy auto-detection)
594        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            // If credentials are embedded in the proxy URL, reqwest will handle them.
600            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            // If credentials are embedded in the proxy URL, reqwest will handle them.
606            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}