1use 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#[configurable_component]
73#[derive(Clone, Copy, Debug, Eq, PartialEq)]
74#[serde(rename_all = "snake_case")]
75pub enum Region {
76 Eu,
78
79 Us,
81
82 Asia,
84
85 SãoPaulo,
87
88 Canada,
90
91 Dammam,
93
94 Doha,
96
97 Frankfurt,
99
100 London,
102
103 Mumbai,
105
106 Paris,
108
109 Singapore,
111
112 Sydney,
114
115 TelAviv,
117
118 Tokyo,
120
121 Turin,
123
124 Zurich,
126}
127
128impl Region {
129 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
158impl 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#[configurable_component(sink(
178 "gcp_chronicle_unstructured",
179 "Store unstructured log events in Google Chronicle."
180))]
181#[derive(Clone, Debug)]
182pub struct ChronicleUnstructuredConfig {
183 #[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 #[configurable(derived)]
193 #[configurable(required_one_of = "region_or_endpoint")]
194 pub region: Option<Region>,
195
196 #[configurable(validation(format = "uuid"))]
198 #[configurable(metadata(docs::examples = "c8c65bfa-5f2c-42d4-9189-64bb7b939f2c"))]
199 pub customer_id: String,
200
201 #[configurable(metadata(docs::templateable))]
203 #[configurable(metadata(
204 docs::examples = "production",
205 docs::examples = "production-{{ namespace }}",
206 ))]
207 pub namespace: Option<UnconfinedTemplate>,
208
209 #[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 #[configurable(metadata(docs::examples = "WINDOWS_DNS", docs::examples = "{{ log_type }}"))]
242 pub log_type: UnconfinedTemplate,
243
244 #[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 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#[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 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 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 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 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}