Skip to main content

vector/sinks/gcp_chronicle/
chronicle_unstructured.rs

1//! This sink sends data to Google Chronicles unstructured log entries endpoint.
2//! See <https://cloud.google.com/chronicle/docs/reference/ingestion-api#unstructuredlogentries>
3//! for more information.
4use std::{collections::HashMap, io};
5
6use bytes::{Bytes, BytesMut};
7use futures_util::{future::BoxFuture, task::Poll};
8use goauth::scopes::Scope;
9use http::{
10    Request, StatusCode, Uri,
11    header::{self, HeaderName, HeaderValue},
12};
13use hyper::Body;
14use indoc::indoc;
15use serde::Serialize;
16use serde_json::json;
17use snafu::Snafu;
18use tokio_util::codec::Encoder as _;
19use tower::{Service, ServiceBuilder};
20use vector_lib::{
21    EstimatedJsonEncodedSizeOf,
22    config::{AcknowledgementsConfig, Input, telemetry},
23    configurable::configurable_component,
24    event::{Event, EventFinalizers, Finalizable},
25    request_metadata::{GroupedCountByteSize, MetaDescriptive, RequestMetadata},
26    sink::VectorSink,
27    stream::BatcherSettings,
28};
29use vrl::value::Kind;
30
31use crate::{
32    codecs::{self, EncodingConfig},
33    config::{DynValidatedSink, GenerateConfig, SinkConfig, SinkContext, ValidatedSink},
34    gcp::{GcpAuthConfig, GcpAuthenticator},
35    http::HttpClient,
36    schema,
37    sinks::{
38        Healthcheck,
39        gcp_chronicle::{
40            compression::ChronicleCompression,
41            partitioner::{ChroniclePartitionKey, ChroniclePartitioner},
42            sink::ChronicleSink,
43        },
44        gcs_common::{
45            config::{GcsRetryLogic, healthcheck_response},
46            service::GcsResponse,
47        },
48        util::{
49            BatchConfig, Compression, HttpEndpoint, RequestBuilder, SinkBatchSettings,
50            TowerRequestConfig,
51            encoding::{Encoder, as_tracked_write},
52            metadata::RequestMetadataBuilder,
53            request_builder::EncodeResult,
54            service::TowerRequestConfigDefaults,
55        },
56    },
57    template::{TemplateParseError, UnconfinedTemplate},
58    tls::{TlsConfig, TlsSettings},
59};
60
61#[derive(Debug, Snafu)]
62#[snafu(visibility(pub))]
63pub enum GcsHealthcheckError {
64    #[snafu(display("log_type template parse error: {}", source))]
65    LogTypeTemplate { source: TemplateParseError },
66
67    #[snafu(display("Endpoint not found"))]
68    NotFound,
69}
70
71/// Google Chronicle regions.
72#[configurable_component]
73#[derive(Clone, Copy, Debug, Eq, PartialEq)]
74#[serde(rename_all = "snake_case")]
75pub enum Region {
76    /// European Multi region
77    Eu,
78
79    /// US Multi region
80    Us,
81
82    /// APAC region (this is the same as the Singapore region endpoint retained for backwards compatibility)
83    Asia,
84
85    /// SãoPaulo Region
86    SãoPaulo,
87
88    /// Canada Region
89    Canada,
90
91    /// Dammam Region
92    Dammam,
93
94    /// Doha Region
95    Doha,
96
97    /// Frankfurt Region
98    Frankfurt,
99
100    /// London Region
101    London,
102
103    /// Mumbai Region
104    Mumbai,
105
106    /// Paris Region
107    Paris,
108
109    /// Singapore Region
110    Singapore,
111
112    /// Sydney Region
113    Sydney,
114
115    /// TelAviv Region
116    TelAviv,
117
118    /// Tokyo Region
119    Tokyo,
120
121    /// Turin Region
122    Turin,
123
124    /// Zurich Region
125    Zurich,
126}
127
128impl Region {
129    /// Each region has a its own endpoint.
130    const fn endpoint(self) -> &'static str {
131        match self {
132            Region::Eu => "https://europe-malachiteingestion-pa.googleapis.com",
133            Region::Us => "https://malachiteingestion-pa.googleapis.com",
134            Region::Asia => "https://asia-southeast1-malachiteingestion-pa.googleapis.com",
135            Region::SãoPaulo => "https://southamerica-east1-malachiteingestion-pa.googleapis.com",
136            Region::Canada => {
137                "https://northamerica-northeast2-malachiteingestion-pa.googleapis.com"
138            }
139            Region::Dammam => "https://me-central2-malachiteingestion-pa.googleapis.com",
140            Region::Doha => "https://me-central1-malachiteingestion-pa.googleapis.com",
141            Region::Frankfurt => "https://europe-west3-malachiteingestion-pa.googleapis.com",
142            Region::London => "https://europe-west2-malachiteingestion-pa.googleapis.com",
143            Region::Mumbai => "https://asia-south1-malachiteingestion-pa.googleapis.com",
144            Region::Paris => "https://europe-west9-malachiteingestion-pa.googleapis.com",
145            Region::Singapore => "https://asia-southeast1-malachiteingestion-pa.googleapis.com",
146            Region::Sydney => "https://australia-southeast1-malachiteingestion-pa.googleapis.com",
147            Region::TelAviv => "https://me-west1-malachiteingestion-pa.googleapis.com",
148            Region::Tokyo => "https://asia-northeast1-malachiteingestion-pa.googleapis.com",
149            Region::Turin => "https://europe-west12-malachiteingestion-pa.googleapis.com",
150            Region::Zurich => "https://europe-west6-malachiteingestion-pa.googleapis.com",
151        }
152    }
153}
154
155#[derive(Clone, Copy, Debug, Default)]
156pub struct ChronicleUnstructuredDefaultBatchSettings;
157
158// Chronicle Ingestion API has a 1MB limit[1] for unstructured log entries. We're also using a
159// conservatively low batch timeout to ensure events make it to Chronicle in a timely fashion, but
160// high enough that it allows for reasonable batching.
161//
162// [1]: https://cloud.google.com/chronicle/docs/reference/ingestion-api#unstructuredlogentries
163impl SinkBatchSettings for ChronicleUnstructuredDefaultBatchSettings {
164    const MAX_EVENTS: Option<usize> = None;
165    const MAX_BYTES: Option<usize> = Some(1_000_000);
166    const TIMEOUT_SECS: f64 = 15.0;
167}
168
169#[derive(Clone, Copy, Debug)]
170pub struct ChronicleUnstructuredTowerRequestConfigDefaults;
171
172impl TowerRequestConfigDefaults for ChronicleUnstructuredTowerRequestConfigDefaults {
173    const RATE_LIMIT_NUM: u64 = 1_000;
174}
175
176/// Configuration for the `gcp_chronicle_unstructured` sink.
177#[configurable_component(sink(
178    "gcp_chronicle_unstructured",
179    "Store unstructured log events in Google Chronicle."
180))]
181#[derive(Clone, Debug)]
182pub struct ChronicleUnstructuredConfig {
183    /// The endpoint to send data to.
184    #[configurable(metadata(
185        docs::examples = "http://127.0.0.1:8080",
186        docs::examples = "http://example.com:12345"
187    ))]
188    #[configurable(required_one_of = "region_or_endpoint")]
189    pub endpoint: Option<HttpEndpoint>,
190
191    /// The GCP region to use.
192    #[configurable(derived)]
193    #[configurable(required_one_of = "region_or_endpoint")]
194    pub region: Option<Region>,
195
196    /// The Unique identifier (UUID) corresponding to the Chronicle instance.
197    #[configurable(validation(format = "uuid"))]
198    #[configurable(metadata(docs::examples = "c8c65bfa-5f2c-42d4-9189-64bb7b939f2c"))]
199    pub customer_id: String,
200
201    /// User-configured environment namespace to identify the data domain the logs originated from.
202    #[configurable(metadata(docs::templateable))]
203    #[configurable(metadata(
204        docs::examples = "production",
205        docs::examples = "production-{{ namespace }}",
206    ))]
207    pub namespace: Option<UnconfinedTemplate>,
208
209    /// A set of labels that are attached to each batch of events.
210    #[configurable(metadata(docs::examples = "chronicle_labels_examples()"))]
211    #[configurable(metadata(docs::additional_props_description = "A Chronicle label."))]
212    pub labels: Option<HashMap<String, String>>,
213
214    #[serde(flatten)]
215    pub auth: GcpAuthConfig,
216
217    #[configurable(derived)]
218    #[serde(default)]
219    pub batch: BatchConfig<ChronicleUnstructuredDefaultBatchSettings>,
220
221    #[configurable(derived)]
222    pub encoding: EncodingConfig,
223
224    #[serde(default)]
225    #[configurable(derived)]
226    pub compression: ChronicleCompression,
227
228    #[configurable(derived)]
229    #[serde(default)]
230    pub request: TowerRequestConfig<ChronicleUnstructuredTowerRequestConfigDefaults>,
231
232    #[configurable(derived)]
233    pub tls: Option<TlsConfig>,
234
235    /// The type of log entries in a request.
236    ///
237    /// This must be one of the [supported log types][unstructured_log_types_doc], otherwise
238    /// Chronicle rejects the entry with an error.
239    ///
240    /// [unstructured_log_types_doc]: https://cloud.google.com/chronicle/docs/ingestion/parser-list/supported-default-parsers
241    #[configurable(metadata(docs::examples = "WINDOWS_DNS", docs::examples = "{{ log_type }}"))]
242    pub log_type: UnconfinedTemplate,
243
244    /// The default `log_type` to attach to events if the template in `log_type` cannot be resolved.
245    #[configurable(metadata(docs::examples = "VECTOR_DEV"))]
246    pub fallback_log_type: Option<String>,
247
248    #[configurable(derived)]
249    #[serde(
250        default,
251        deserialize_with = "crate::serde::bool_or_struct",
252        skip_serializing_if = "crate::serde::is_default"
253    )]
254    acknowledgements: AcknowledgementsConfig,
255}
256
257fn chronicle_labels_examples() -> HashMap<String, String> {
258    let mut examples = HashMap::new();
259    examples.insert("source".to_string(), "vector".to_string());
260    examples.insert("tenant".to_string(), "marketing".to_string());
261    examples
262}
263
264impl GenerateConfig for ChronicleUnstructuredConfig {
265    fn generate_config() -> serde_json::Value {
266        serde_yaml::from_str(indoc! {r#"
267            credentials_path: /path/to/credentials.json
268            customer_id: customer_id
269            namespace: namespace
270            region: asia
271            compression: gzip
272            log_type: log_type
273            fallback_log_type: VECTOR_DEV
274            encoding:
275              codec: text
276        "#})
277        .unwrap()
278    }
279}
280
281pub fn build_healthcheck(
282    client: HttpClient,
283    base_url: &str,
284    auth: GcpAuthenticator,
285) -> crate::Result<Healthcheck> {
286    let uri = base_url.parse::<Uri>()?;
287
288    let healthcheck = async move {
289        let mut request = http::Request::get(&uri).body(Body::empty())?;
290        auth.apply(&mut request);
291
292        let response = client.send(request).await?;
293        healthcheck_response(response, GcsHealthcheckError::NotFound.into())
294    };
295
296    Ok(Box::pin(healthcheck))
297}
298
299#[derive(Debug, Snafu)]
300pub enum ChronicleError {
301    #[snafu(display("Region or endpoint not defined"))]
302    RegionOrEndpoint,
303    #[snafu(display("You can only specify one of region or endpoint"))]
304    BothRegionAndEndpoint,
305}
306
307#[async_trait::async_trait]
308#[typetag::serde(name = "gcp_chronicle_unstructured")]
309impl SinkConfig for ChronicleUnstructuredConfig {
310    fn input(&self) -> Input {
311        let requirement =
312            schema::Requirement::empty().required_meaning("timestamp", Kind::timestamp());
313
314        Input::log().with_schema_requirement(requirement)
315    }
316
317    fn acknowledgements(&self) -> &AcknowledgementsConfig {
318        &self.acknowledgements
319    }
320
321    fn as_dyn_validated(&self) -> Option<&dyn DynValidatedSink> {
322        Some(self)
323    }
324}
325
326#[async_trait::async_trait]
327impl ValidatedSink for ChronicleUnstructuredConfig {
328    type Validated = ValidatedChronicleUnstructured;
329
330    fn validate(&self) -> crate::Result<ValidatedChronicleUnstructured> {
331        let endpoint = self.create_endpoint("v2/unstructuredlogentries:batchCreate")?;
332        endpoint.parse::<Uri>()?;
333
334        // For the healthcheck we see if we can fetch the list of available log types.
335        let healthcheck_endpoint = self.create_endpoint("v2/logtypes")?;
336        healthcheck_endpoint.parse::<Uri>()?;
337
338        let batch_settings = self.batch.into_batcher_settings()?;
339
340        Ok(ValidatedChronicleUnstructured {
341            endpoint,
342            healthcheck_endpoint,
343            batch_settings,
344        })
345    }
346
347    async fn build(
348        &self,
349        validated: &ValidatedChronicleUnstructured,
350        cx: SinkContext,
351    ) -> crate::Result<(VectorSink, Healthcheck)> {
352        let ValidatedChronicleUnstructured {
353            endpoint,
354            healthcheck_endpoint,
355            ..
356        } = validated;
357
358        let creds = self.auth.build(Scope::MalachiteIngestion).await?;
359
360        let tls = TlsSettings::from_options(self.tls.as_ref())?;
361        let client = HttpClient::new(tls, cx.proxy())?;
362
363        let healthcheck = build_healthcheck(client.clone(), healthcheck_endpoint, creds.clone())?;
364        creds.spawn_regenerate_token();
365        let sink = self.build_sink(client, endpoint.clone(), creds, validated)?;
366
367        Ok((sink, healthcheck))
368    }
369}
370
371#[derive(Clone, Debug)]
372pub struct ValidatedChronicleUnstructured {
373    endpoint: String,
374    healthcheck_endpoint: String,
375    batch_settings: BatcherSettings,
376}
377
378impl ChronicleUnstructuredConfig {
379    fn build_sink(
380        &self,
381        client: HttpClient,
382        base_url: String,
383        creds: GcpAuthenticator,
384        validated: &ValidatedChronicleUnstructured,
385    ) -> crate::Result<VectorSink> {
386        use crate::sinks::util::service::ServiceBuilderExt;
387
388        let request = self.request.into_settings();
389
390        let partitioner = self.partitioner()?;
391
392        let svc = ServiceBuilder::new()
393            .settings(request, GcsRetryLogic::default())
394            .service(ChronicleService::new(client, base_url, creds));
395
396        let request_builder = ChronicleRequestBuilder::new(self)?;
397        let sink = ChronicleSink::new(
398            svc,
399            request_builder,
400            partitioner,
401            validated.batch_settings,
402            "http",
403        );
404
405        Ok(VectorSink::from_event_streamsink(sink))
406    }
407
408    fn partitioner(&self) -> crate::Result<ChroniclePartitioner> {
409        Ok(ChroniclePartitioner::new(
410            self.log_type.clone(),
411            self.fallback_log_type.clone(),
412            self.namespace.clone(),
413        ))
414    }
415
416    fn create_endpoint(&self, path: &str) -> Result<String, ChronicleError> {
417        Ok(format!(
418            "{}/{}",
419            match (&self.endpoint, self.region) {
420                (Some(endpoint), None) => endpoint.to_string().trim_end_matches('/').to_string(),
421                (None, Some(region)) => region.endpoint().to_string(),
422                (Some(_), Some(_)) => return Err(ChronicleError::BothRegionAndEndpoint),
423                (None, None) => return Err(ChronicleError::RegionOrEndpoint),
424            },
425            path
426        ))
427    }
428}
429
430#[derive(Clone, Debug)]
431pub struct ChronicleRequest {
432    pub body: Bytes,
433    pub finalizers: EventFinalizers,
434    pub headers: HashMap<HeaderName, HeaderValue>,
435    metadata: RequestMetadata,
436}
437
438impl Finalizable for ChronicleRequest {
439    fn take_finalizers(&mut self) -> EventFinalizers {
440        std::mem::take(&mut self.finalizers)
441    }
442}
443
444impl MetaDescriptive for ChronicleRequest {
445    fn get_metadata(&self) -> &RequestMetadata {
446        &self.metadata
447    }
448
449    fn metadata_mut(&mut self) -> &mut RequestMetadata {
450        &mut self.metadata
451    }
452}
453
454#[derive(Clone, Debug, Serialize)]
455struct ChronicleRequestBody {
456    customer_id: String,
457    #[serde(skip_serializing_if = "Option::is_none")]
458    namespace: Option<String>,
459    #[serde(skip_serializing_if = "Option::is_none")]
460    labels: Option<Vec<Label>>,
461    log_type: String,
462    entries: Vec<serde_json::Value>,
463}
464
465#[derive(Clone, Debug)]
466struct ChronicleEncoder {
467    customer_id: String,
468    labels: Option<Vec<Label>>,
469    encoder: codecs::Encoder<()>,
470    transformer: codecs::Transformer,
471}
472
473impl Encoder<(ChroniclePartitionKey, Vec<Event>)> for ChronicleEncoder {
474    fn encode_input(
475        &self,
476        input: (ChroniclePartitionKey, Vec<Event>),
477        writer: &mut dyn io::Write,
478    ) -> io::Result<(usize, GroupedCountByteSize)> {
479        let (key, events) = input;
480        let mut encoder = self.encoder.clone();
481        let mut byte_size = telemetry().create_request_count_byte_size();
482        let events = events
483            .into_iter()
484            .filter_map(|mut event| {
485                let timestamp = event
486                    .as_log()
487                    .get_timestamp()
488                    .and_then(|ts| ts.as_timestamp())
489                    .cloned();
490                let mut bytes = BytesMut::new();
491                self.transformer.transform(&mut event);
492
493                byte_size.add_event(&event, event.estimated_json_encoded_size_of());
494
495                encoder.encode(event, &mut bytes).ok()?;
496
497                let mut value = json!({
498                    "log_text": String::from_utf8_lossy(&bytes),
499                });
500
501                if let Some(ts) = timestamp {
502                    value.as_object_mut().unwrap().insert(
503                        "ts_rfc3339".to_string(),
504                        ts.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true)
505                            .into(),
506                    );
507                }
508
509                Some(value)
510            })
511            .collect::<Vec<_>>();
512
513        let json = json!(ChronicleRequestBody {
514            customer_id: self.customer_id.clone(),
515            namespace: key.namespace,
516            labels: self.labels.clone(),
517            log_type: key.log_type,
518            entries: events,
519        });
520
521        let size = as_tracked_write::<_, _, io::Error>(writer, &json, |writer, json| {
522            serde_json::to_writer(writer, json)?;
523            Ok(())
524        })?;
525
526        Ok((size, byte_size))
527    }
528}
529
530// Settings required to produce a request that do not change per
531// request. All possible values are pre-computed for direct use in
532// producing a request.
533#[derive(Clone, Debug)]
534struct ChronicleRequestBuilder {
535    encoder: ChronicleEncoder,
536    compression: Compression,
537}
538
539struct ChronicleRequestPayload {
540    bytes: Bytes,
541}
542
543impl From<Bytes> for ChronicleRequestPayload {
544    fn from(bytes: Bytes) -> Self {
545        Self { bytes }
546    }
547}
548
549impl AsRef<[u8]> for ChronicleRequestPayload {
550    fn as_ref(&self) -> &[u8] {
551        self.bytes.as_ref()
552    }
553}
554
555impl RequestBuilder<(ChroniclePartitionKey, Vec<Event>)> for ChronicleRequestBuilder {
556    type Metadata = EventFinalizers;
557    type Events = (ChroniclePartitionKey, Vec<Event>);
558    type Encoder = ChronicleEncoder;
559    type Payload = ChronicleRequestPayload;
560    type Request = ChronicleRequest;
561    type Error = io::Error;
562
563    fn compression(&self) -> Compression {
564        self.compression
565    }
566
567    fn encoder(&self) -> &Self::Encoder {
568        &self.encoder
569    }
570
571    fn split_input(
572        &self,
573        input: (ChroniclePartitionKey, Vec<Event>),
574    ) -> (Self::Metadata, RequestMetadataBuilder, Self::Events) {
575        let (partition_key, mut events) = input;
576        let finalizers = events.take_finalizers();
577
578        let builder = RequestMetadataBuilder::from_events(&events);
579        (finalizers, builder, (partition_key, events))
580    }
581
582    fn build_request(
583        &self,
584        finalizers: Self::Metadata,
585        metadata: RequestMetadata,
586        payload: EncodeResult<Self::Payload>,
587    ) -> Self::Request {
588        let mut headers = HashMap::new();
589        headers.insert(
590            header::CONTENT_TYPE,
591            HeaderValue::from_static("application/json"),
592        );
593
594        match payload.compressed_byte_size {
595            Some(compressed_byte_size) => {
596                headers.insert(
597                    header::CONTENT_LENGTH,
598                    HeaderValue::from_str(&compressed_byte_size.to_string()).unwrap(),
599                );
600                headers.insert(
601                    header::CONTENT_ENCODING,
602                    HeaderValue::from_str(self.compression.content_encoding().unwrap()).unwrap(),
603                );
604            }
605            None => {
606                headers.insert(
607                    header::CONTENT_LENGTH,
608                    HeaderValue::from_str(&payload.uncompressed_byte_size.to_string()).unwrap(),
609                );
610            }
611        }
612
613        ChronicleRequest {
614            headers,
615            body: payload.into_payload().bytes,
616            finalizers,
617            metadata,
618        }
619    }
620}
621
622#[derive(Clone, Debug, Serialize)]
623struct Label {
624    key: String,
625    value: String,
626}
627
628impl ChronicleRequestBuilder {
629    fn new(config: &ChronicleUnstructuredConfig) -> crate::Result<Self> {
630        let transformer = config.encoding.transformer();
631        let serializer = config.encoding.config().build()?;
632        let compression = Compression::from(config.compression);
633        let encoder = vector_lib::codecs::Encoder::<()>::new(serializer);
634        let encoder = ChronicleEncoder {
635            customer_id: config.customer_id.clone(),
636            labels: config.labels.as_ref().map(|labs| {
637                labs.iter()
638                    .map(|(k, v)| Label {
639                        key: k.to_string(),
640                        value: v.to_string(),
641                    })
642                    .collect::<Vec<_>>()
643            }),
644            encoder,
645            transformer,
646        };
647        Ok(Self {
648            encoder,
649            compression,
650        })
651    }
652}
653
654#[derive(Debug, Clone)]
655pub struct ChronicleService {
656    client: HttpClient,
657    base_url: String,
658    creds: GcpAuthenticator,
659}
660
661impl ChronicleService {
662    pub const fn new(client: HttpClient, base_url: String, creds: GcpAuthenticator) -> Self {
663        Self {
664            client,
665            base_url,
666            creds,
667        }
668    }
669}
670
671#[derive(Debug, Snafu)]
672pub enum ChronicleResponseError {
673    #[snafu(display("Server responded with an error: {}", code))]
674    ServerError { code: StatusCode },
675    #[snafu(display("Failed to make HTTP(S) request: {}", error))]
676    HttpError { error: crate::http::HttpError },
677}
678
679impl Service<ChronicleRequest> for ChronicleService {
680    type Response = GcsResponse;
681    type Error = ChronicleResponseError;
682    type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
683
684    fn poll_ready(&mut self, _: &mut std::task::Context<'_>) -> Poll<Result<(), Self::Error>> {
685        Poll::Ready(Ok(()))
686    }
687
688    fn call(&mut self, request: ChronicleRequest) -> Self::Future {
689        let mut builder = Request::post(&self.base_url);
690        let metadata = request.get_metadata().clone();
691
692        let headers = builder.headers_mut().unwrap();
693        for (name, value) in request.headers {
694            headers.insert(name, value);
695        }
696
697        let mut http_request = builder.body(Body::from(request.body)).unwrap();
698        self.creds.apply(&mut http_request);
699
700        let mut client = self.client.clone();
701        Box::pin(async move {
702            match client.call(http_request).await {
703                Ok(response) => {
704                    let status = response.status();
705                    if status.is_success() {
706                        Ok(GcsResponse {
707                            inner: response,
708                            metadata,
709                        })
710                    } else {
711                        Err(ChronicleResponseError::ServerError { code: status })
712                    }
713                }
714                Err(error) => Err(ChronicleResponseError::HttpError { error }),
715            }
716        })
717    }
718}
719
720#[cfg(test)]
721mod unit_tests {
722    use std::io::Write;
723
724    use tempfile::NamedTempFile;
725    use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method};
726
727    use super::*;
728    use crate::test_util::{random_string, trace_init};
729
730    #[tokio::test]
731    async fn invalid_credentials_rejected_by_oauth_server() {
732        trace_init();
733
734        let mock_server = MockServer::start().await;
735        Mock::given(method("POST"))
736            .respond_with(ResponseTemplate::new(401))
737            .mount(&mock_server)
738            .await;
739
740        let base_url = mock_server.uri();
741        let creds = serde_json::json!({
742            "type": "service_account",
743            "project_id": "test",
744            "private_key_id": "1",
745            "private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIICXgIBAAKBgQDouHdVDVz0/M6PGe60Kf/g0nyOxCvbZgiUAZNzFimXDU+RpZ54\n6/oETl6VpRkbp8a4Xb8avll2lsamdHvGcsgnjJXdpp7LfWYLqHEpn0/XFM+womXg\nvglWCDwAsXmrmwpZKEC82mmyFigheyPA/sfuN6z+wa7P5B65xzIdDQX7nQIDAQAB\nAoGBANID/rUDrTrtll8v8Oon6OH0MjIIuOdzKhSfY3h9rKTDf2YaB2xq0KLoMpVr\ne8AoZb5l45t34naR1M3M2xKY7SSDAVJFfg/3Vxeot86DQ23IGLXj7LnNxXnvklXa\nEXaD8LNz/MXxS7/Lu0R+lEtjEkf23+BRb11fL6Q/EDToNHnhAkEA/FnwHhKMc/Bm\nXsS8bENuZP3SV2v7TU6MFTtXJFmsoZBxHnsM8UUi0gq9gBnApmdhy7v2N/Mv9gFI\nviSdr7vm1QJBAOwV3cHAciRHVK71TweOWIJKZBM9ZVut0VDs5GrBYZxGMBiOr3BI\ns7+0ugTKxVimuei6c0KNXw1kg3Vtc5+utakCQQDklAbXBpAomJHxt5zBKBc/7VXx\nEANyk/p5ZOXbLEsdkXuVU3p2tNwEi+v4s9r4H97Kr3goV+SSnbkpWntm6fn9AkBn\nFnE7rlXpA4C12QYGTaDWW7dxM0j0DGUvChH/j6uYuok73+o5hHWAy2DCwOwFduAN\nAIVd1S9hQLeqaf2oB3jpAkEAnRT+bAlMjtUOBO6XPNO4IbYwWJvGMcIEO7zu6AdB\nPJy3/U+bLimxFuYdrs6SnIHIUVdl35AlckHqzT54a5YKqQ==\n-----END RSA PRIVATE KEY-----",
746            "client_email": "test@test.com",
747            "client_id": "1",
748            "auth_uri": format!("{base_url}/o/oauth2/auth"),
749            "token_uri": format!("{base_url}/token"),
750            "auth_provider_x509_cert_url": format!("{base_url}/oauth2/v1/certs"),
751            "client_x509_cert_url": "https://example.com"
752        });
753
754        let mut tmp = NamedTempFile::new().unwrap();
755        write!(tmp, "{creds}").unwrap();
756
757        let log_type = random_string(10);
758        let cx = SinkContext::default();
759        // Normalize to forward slashes so YAML doesn't interpret Windows path separators as escapes.
760        let creds_path = tmp.path().to_str().unwrap().replace('\\', "/");
761        let config: ChronicleUnstructuredConfig = serde_yaml::from_str(&format!(
762            indoc! { r#"
763                endpoint: "http://127.0.0.1:1"
764                customer_id: test-customer
765                credentials_path: "{}"
766                log_type: "{}"
767                encoding:
768                  codec: text
769            "# },
770            creds_path, log_type
771        ))
772        .unwrap();
773        assert!(SinkConfig::build(&config, cx).await.is_err());
774    }
775
776    #[test]
777    fn deserialization_rejects_malformed_endpoint() {
778        // The `HttpEndpoint` config field rejects a malformed endpoint at
779        // config load time, so deserialization fails.
780        let result: Result<ChronicleUnstructuredConfig, _> = serde_yaml::from_str(indoc! {r#"
781            endpoint: "not a uri"
782            customer_id: test-customer
783            log_type: "WINDOWS_DNS"
784            encoding:
785              codec: text
786        "#});
787        assert!(
788            result.is_err(),
789            "config load should reject a malformed endpoint"
790        );
791    }
792
793    #[test]
794    fn deserialization_rejects_non_http_endpoint() {
795        // The `HttpEndpoint` config field rejects a non-http(s) endpoint at
796        // config load time, so deserialization fails.
797        let result: Result<ChronicleUnstructuredConfig, _> = serde_yaml::from_str(indoc! {r#"
798            endpoint: "ftp://example.com"
799            customer_id: test-customer
800            log_type: "WINDOWS_DNS"
801            encoding:
802              codec: text
803        "#});
804        assert!(
805            result.is_err(),
806            "config load should reject a non-http endpoint"
807        );
808    }
809
810    #[test]
811    fn relative_endpoint_becomes_absolute_https() {
812        use crate::config::ValidatedSink;
813
814        // A missing scheme is defaulted to https by `HttpEndpoint`.
815        let config: ChronicleUnstructuredConfig = serde_yaml::from_str(indoc! {r#"
816            endpoint: "chronicle.example.com:8080"
817            customer_id: test-customer
818            log_type: "WINDOWS_DNS"
819            encoding:
820              codec: text
821        "#})
822        .unwrap();
823
824        let validated = config.validate().expect("validation should succeed");
825        assert_eq!(
826            validated.endpoint,
827            "https://chronicle.example.com:8080/v2/unstructuredlogentries:batchCreate"
828        );
829        assert_eq!(
830            validated.healthcheck_endpoint,
831            "https://chronicle.example.com:8080/v2/logtypes"
832        );
833    }
834
835    #[test]
836    fn validate_produces_usable_values() {
837        use crate::config::ValidatedSink;
838
839        let config: ChronicleUnstructuredConfig = serde_yaml::from_str(indoc! {r#"
840            endpoint: "http://127.0.0.1:8080"
841            customer_id: test-customer
842            log_type: "WINDOWS_DNS"
843            encoding:
844              codec: text
845        "#})
846        .unwrap();
847
848        let validated = config.validate().expect("validation should succeed");
849        assert_eq!(
850            validated.endpoint,
851            "http://127.0.0.1:8080/v2/unstructuredlogentries:batchCreate"
852        );
853        assert_eq!(
854            validated.healthcheck_endpoint,
855            "http://127.0.0.1:8080/v2/logtypes"
856        );
857    }
858}