1use std::collections::HashSet;
16use std::sync::Arc;
17use std::time::Duration;
18
19use async_trait::async_trait;
20use clap::{Parser, ValueEnum};
21use common_error::ext::BoxedError;
22use common_telemetry::{debug, error, info};
23use object_store::ObjectStore;
24use serde_json::Value;
25use snafu::{OptionExt, ResultExt};
26use tokio::sync::Semaphore;
27use tokio::time::Instant;
28
29use crate::common::{ObjectStoreConfig, new_fs_object_store};
30use crate::data::schema_export::append_schema_ddl;
31use crate::data::storage_export::{
32 AzblobBackend, FsBackend, GcsBackend, OssBackend, S3Backend, StorageExport, StorageType,
33};
34use crate::data::{COPY_PATH_PLACEHOLDER, default_database};
35use crate::database::{DatabaseClient, parse_proxy_opts};
36use crate::error::{
37 EmptyResultSnafu, Error, OpenDalSnafu, OutputDirNotSetSnafu, Result, SchemaNotFoundSnafu,
38};
39use crate::{Tool, database};
40
41type TableReference = (String, String, String);
42
43#[derive(Debug, Default, Clone, ValueEnum)]
44enum ExportTarget {
45 Schema,
47 Data,
49 #[default]
51 All,
52}
53
54#[derive(Debug, Default, Parser)]
56pub struct ExportCommand {
57 #[clap(long)]
59 addr: String,
60
61 #[clap(long)]
64 output_dir: Option<String>,
65
66 #[clap(long, default_value_t = default_database())]
68 database: String,
69
70 #[clap(long, short = 'j', default_value = "1", alias = "export-jobs")]
74 db_parallelism: usize,
75
76 #[clap(long, default_value = "4")]
80 table_parallelism: usize,
81
82 #[clap(long, default_value = "3")]
84 max_retry: usize,
85
86 #[clap(long, short = 't', value_enum, default_value = "all")]
88 target: ExportTarget,
89
90 #[clap(long)]
93 start_time: Option<String>,
94
95 #[clap(long)]
98 end_time: Option<String>,
99
100 #[clap(long)]
102 auth_basic: Option<String>,
103
104 #[clap(long, value_parser = humantime::parse_duration)]
109 timeout: Option<Duration>,
110
111 #[clap(long)]
116 proxy: Option<String>,
117
118 #[clap(long)]
122 no_proxy: bool,
123
124 #[clap(long)]
132 ddl_local_dir: Option<String>,
133
134 #[clap(flatten)]
135 storage: ObjectStoreConfig,
136}
137
138impl ExportCommand {
139 pub async fn build(&self) -> std::result::Result<Box<dyn Tool>, BoxedError> {
140 let (storage_type, operator) = if self.storage.enable_s3 {
142 (
143 StorageType::S3(S3Backend::new(self.storage.s3.clone())?),
144 self.storage.build_s3()?,
145 )
146 } else if self.storage.enable_oss {
147 (
148 StorageType::Oss(OssBackend::new(self.storage.oss.clone())?),
149 self.storage.build_oss()?,
150 )
151 } else if self.storage.enable_gcs {
152 (
153 StorageType::Gcs(GcsBackend::new(self.storage.gcs.clone())?),
154 self.storage.build_gcs()?,
155 )
156 } else if self.storage.enable_azblob {
157 (
158 StorageType::Azblob(AzblobBackend::new(self.storage.azblob.clone())?),
159 self.storage.build_azblob()?,
160 )
161 } else if let Some(output_dir) = &self.output_dir {
162 (
163 StorageType::Fs(FsBackend::new(output_dir.clone())),
164 new_fs_object_store(output_dir)?,
165 )
166 } else {
167 return Err(BoxedError::new(OutputDirNotSetSnafu {}.build()));
168 };
169
170 let (catalog, schema) =
171 database::split_database(&self.database).map_err(BoxedError::new)?;
172 let proxy = parse_proxy_opts(self.proxy.clone(), self.no_proxy)?;
173 let database_client = DatabaseClient::new(
174 self.addr.clone(),
175 catalog.clone(),
176 self.auth_basic.clone(),
177 self.timeout.unwrap_or_default(),
179 proxy,
180 self.no_proxy,
181 );
182
183 Ok(Box::new(Export {
184 catalog,
185 schema,
186 database_client,
187 export_jobs: self.db_parallelism,
188 target: self.target.clone(),
189 start_time: self.start_time.clone(),
190 end_time: self.end_time.clone(),
191 parallelism: self.table_parallelism,
192 storage_type,
193 ddl_local_dir: self.ddl_local_dir.clone(),
194 operator,
195 }))
196 }
197}
198
199#[derive(Clone)]
200pub struct Export {
201 catalog: String,
202 schema: Option<String>,
203 database_client: DatabaseClient,
204 export_jobs: usize,
205 target: ExportTarget,
206 start_time: Option<String>,
207 end_time: Option<String>,
208 parallelism: usize,
209 storage_type: StorageType,
210 ddl_local_dir: Option<String>,
211 operator: ObjectStore,
212}
213
214impl Export {
215 async fn get_db_names(&self) -> Result<Vec<String>> {
216 let db_names = self.all_db_names().await?;
217 let Some(schema) = &self.schema else {
218 return Ok(db_names);
219 };
220
221 db_names
223 .into_iter()
224 .find(|db_name| db_name.to_lowercase() == schema.to_lowercase())
225 .map(|name| vec![name])
226 .context(SchemaNotFoundSnafu {
227 catalog: &self.catalog,
228 schema,
229 })
230 }
231
232 async fn all_db_names(&self) -> Result<Vec<String>> {
234 let records = self
235 .database_client
236 .sql_in_public("SHOW DATABASES")
237 .await?
238 .context(EmptyResultSnafu)?;
239 let mut result = Vec::with_capacity(records.len());
240 for value in records {
241 let Value::String(schema) = &value[0] else {
242 unreachable!()
243 };
244 if schema == common_catalog::consts::INFORMATION_SCHEMA_NAME {
245 continue;
246 }
247 if schema == common_catalog::consts::PG_CATALOG_NAME {
248 continue;
249 }
250 result.push(schema.clone());
251 }
252 Ok(result)
253 }
254
255 async fn get_table_list(
258 &self,
259 catalog: &str,
260 schema: &str,
261 ) -> Result<(
262 Vec<TableReference>,
263 Vec<TableReference>,
264 Vec<TableReference>,
265 )> {
266 let sql = format!(
268 "SELECT table_catalog, table_schema, table_name \
269 FROM information_schema.columns \
270 WHERE column_name = '__tsid' \
271 and table_catalog = \'{catalog}\' \
272 and table_schema = \'{schema}\'"
273 );
274 let records = self
275 .database_client
276 .sql_in_public(&sql)
277 .await?
278 .context(EmptyResultSnafu)?;
279 let mut metric_physical_tables = HashSet::with_capacity(records.len());
280 for value in records {
281 let mut t = Vec::with_capacity(3);
282 for v in &value {
283 let Value::String(value) = v else {
284 unreachable!()
285 };
286 t.push(value);
287 }
288 metric_physical_tables.insert((t[0].clone(), t[1].clone(), t[2].clone()));
289 }
290
291 let sql = format!(
292 "SELECT table_catalog, table_schema, table_name, table_type \
293 FROM information_schema.tables \
294 WHERE (table_type = \'BASE TABLE\' OR table_type = \'VIEW\') \
295 and table_catalog = \'{catalog}\' \
296 and table_schema = \'{schema}\'",
297 );
298 let records = self
299 .database_client
300 .sql_in_public(&sql)
301 .await?
302 .context(EmptyResultSnafu)?;
303
304 debug!("Fetched table/view list: {:?}", records);
305
306 if records.is_empty() {
307 return Ok((vec![], vec![], vec![]));
308 }
309
310 let mut remaining_tables = Vec::with_capacity(records.len());
311 let mut views = Vec::new();
312 for value in records {
313 let mut t = Vec::with_capacity(4);
314 for v in &value {
315 let Value::String(value) = v else {
316 unreachable!()
317 };
318 t.push(value);
319 }
320 let table = (t[0].clone(), t[1].clone(), t[2].clone());
321 let table_type = t[3].as_str();
322 if !metric_physical_tables.contains(&table) {
324 if table_type == "VIEW" {
325 views.push(table);
326 } else {
327 remaining_tables.push(table);
328 }
329 }
330 }
331
332 Ok((
333 metric_physical_tables.into_iter().collect(),
334 remaining_tables,
335 views,
336 ))
337 }
338
339 async fn export_create_database(&self) -> Result<()> {
340 let timer = Instant::now();
341 let db_names = self.get_db_names().await?;
342 let db_count = db_names.len();
343 let operator = self.build_prefer_fs_operator().await?;
344
345 for schema in db_names {
346 let mut create_database = String::new();
347 append_schema_ddl(
348 &self.database_client,
349 &self.catalog,
350 &schema,
351 std::iter::once(("DATABASE", None)),
352 &mut create_database,
353 )
354 .await?;
355
356 let file_path = self.get_file_path(&schema, "create_database.sql");
357 self.write_to_storage(&operator, &file_path, create_database.into_bytes())
358 .await?;
359
360 info!(
361 "Exported {}.{} database creation SQL to {}",
362 self.catalog,
363 schema,
364 self.storage_type.format_output_path(&file_path)
365 );
366 }
367
368 let elapsed = timer.elapsed();
369 info!("Success {db_count} jobs, cost: {elapsed:?}");
370
371 Ok(())
372 }
373
374 async fn export_create_table(&self) -> Result<()> {
375 let timer = Instant::now();
376 let semaphore = Arc::new(Semaphore::new(self.export_jobs));
377 let db_names = self.get_db_names().await?;
378 let db_count = db_names.len();
379 let operator = Arc::new(self.build_prefer_fs_operator().await?);
380 let mut tasks = Vec::with_capacity(db_names.len());
381
382 for schema in db_names {
383 let semaphore_moved = semaphore.clone();
384 let export_self = self.clone();
385 let operator = operator.clone();
386 tasks.push(async move {
387 let _permit = semaphore_moved.acquire().await.unwrap();
388 let (metric_physical_tables, remaining_tables, views) = export_self
389 .get_table_list(&export_self.catalog, &schema)
390 .await?;
391
392 if !export_self.storage_type.is_remote_storage() {
394 let db_dir = format!("{}/{}/", export_self.catalog, schema);
395 operator.create_dir(&db_dir).await.context(OpenDalSnafu)?;
396 }
397
398 let file_path = export_self.get_file_path(&schema, "create_tables.sql");
399 let mut content = String::new();
400 let objects = metric_physical_tables
401 .iter()
402 .chain(&remaining_tables)
403 .map(|(_, _, table)| ("TABLE", Some(table.as_str())))
404 .chain(
405 views
406 .iter()
407 .map(|(_, _, view)| ("VIEW", Some(view.as_str()))),
408 );
409 append_schema_ddl(
410 &export_self.database_client,
411 &export_self.catalog,
412 &schema,
413 objects,
414 &mut content,
415 )
416 .await?;
417
418 export_self
420 .write_to_storage(&operator, &file_path, content.into_bytes())
421 .await?;
422
423 info!(
424 "Finished exporting {}.{schema} with {} table schemas to path: {}",
425 export_self.catalog,
426 metric_physical_tables.len() + remaining_tables.len() + views.len(),
427 export_self.storage_type.format_output_path(&file_path)
428 );
429
430 Ok::<(), Error>(())
431 });
432 }
433
434 for result in futures::future::join_all(tasks).await {
435 result?;
436 }
437 let elapsed = timer.elapsed();
438 info!("Success {db_count}/{db_count} jobs, cost: {elapsed:?}");
439
440 Ok(())
441 }
442
443 async fn build_operator(&self) -> Result<ObjectStore> {
444 Ok(self.operator.clone())
445 }
446
447 async fn build_prefer_fs_operator(&self) -> Result<ObjectStore> {
449 if self.storage_type.is_remote_storage()
450 && let Some(ddl_local_dir) = &self.ddl_local_dir
451 {
452 let root = ddl_local_dir.clone();
453 let op = new_fs_object_store(&root).map_err(|e| Error::Other {
454 source: e,
455 location: snafu::location!(),
456 })?;
457 Ok(op)
458 } else {
459 Ok(self.operator.clone())
460 }
461 }
462
463 async fn export_database_data(&self) -> Result<()> {
464 let timer = Instant::now();
465 let semaphore = Arc::new(Semaphore::new(self.export_jobs));
466 let db_names = self.get_db_names().await?;
467 let db_count = db_names.len();
468 let mut tasks = Vec::with_capacity(db_count);
469 let operator = Arc::new(self.build_operator().await?);
470 let fs_first_operator = Arc::new(self.build_prefer_fs_operator().await?);
471 let with_options = build_with_options(&self.start_time, &self.end_time, self.parallelism);
472
473 for schema in db_names {
474 let semaphore_moved = semaphore.clone();
475 let export_self = self.clone();
476 let with_options_clone = with_options.clone();
477 let operator = operator.clone();
478 let fs_first_operator = fs_first_operator.clone();
479
480 tasks.push(async move {
481 let _permit = semaphore_moved.acquire().await.unwrap();
482
483 if !export_self.storage_type.is_remote_storage() {
485 let db_dir = format!("{}/{}/", export_self.catalog, schema);
486 operator.create_dir(&db_dir).await.context(OpenDalSnafu)?;
487 }
488
489 let (path, connection_part) = export_self
490 .storage_type
491 .get_storage_path(&export_self.catalog, &schema);
492
493 let sql = format!(
495 r#"COPY DATABASE "{}"."{}" TO '{}' WITH ({}){};"#,
496 export_self.catalog, schema, path, with_options_clone, connection_part
497 );
498
499 let safe_sql = export_self.storage_type.mask_sensitive_info(&sql);
501 info!("Executing sql: {}", safe_sql);
502
503 export_self.database_client.sql_in_public(&sql).await?;
504 info!(
505 "Finished exporting {}.{} data to {}",
506 export_self.catalog, schema, path
507 );
508
509 let copy_database_from_sql = {
511 let command_without_connection = format!(
512 r#"COPY DATABASE "{}"."{}" FROM '{}' WITH ({});"#,
513 export_self.catalog, schema, COPY_PATH_PLACEHOLDER, with_options_clone
514 );
515
516 if connection_part.is_empty() {
517 command_without_connection
518 } else {
519 let command_with_connection = format!(
520 r#"COPY DATABASE "{}"."{}" FROM '{}' WITH ({}){};"#,
521 export_self.catalog, schema, path, with_options_clone, connection_part
522 );
523
524 format!(
525 "-- {}\n{}",
526 command_with_connection, command_without_connection
527 )
528 }
529 };
530
531 let copy_from_path = export_self.get_file_path(&schema, "copy_from.sql");
532 export_self
533 .write_to_storage(
534 &fs_first_operator,
535 ©_from_path,
536 copy_database_from_sql.into_bytes(),
537 )
538 .await?;
539
540 info!(
541 "Finished exporting {}.{} copy_from.sql to {}",
542 export_self.catalog,
543 schema,
544 export_self.storage_type.format_output_path(©_from_path)
545 );
546
547 Ok::<(), Error>(())
548 });
549 }
550
551 let success = self.execute_tasks(tasks).await;
552 let elapsed = timer.elapsed();
553 info!("Success {success}/{db_count} jobs, costs: {elapsed:?}");
554
555 Ok(())
556 }
557
558 fn get_file_path(&self, schema: &str, file_name: &str) -> String {
559 format!("{}/{}/{}", self.catalog, schema, file_name)
560 }
561
562 async fn write_to_storage(
563 &self,
564 op: &ObjectStore,
565 file_path: &str,
566 content: Vec<u8>,
567 ) -> Result<()> {
568 op.write(file_path, content)
569 .await
570 .context(OpenDalSnafu)
571 .map(|_| ())
572 }
573
574 async fn execute_tasks(
575 &self,
576 tasks: Vec<impl std::future::Future<Output = Result<()>>>,
577 ) -> usize {
578 futures::future::join_all(tasks)
579 .await
580 .into_iter()
581 .filter(|r| match r {
582 Ok(_) => true,
583 Err(e) => {
584 error!(e; "export job failed");
585 false
586 }
587 })
588 .count()
589 }
590}
591
592#[async_trait]
593impl Tool for Export {
594 async fn do_work(&self) -> std::result::Result<(), BoxedError> {
595 match self.target {
596 ExportTarget::Schema => {
597 self.export_create_database()
598 .await
599 .map_err(BoxedError::new)?;
600 self.export_create_table().await.map_err(BoxedError::new)
601 }
602 ExportTarget::Data => self.export_database_data().await.map_err(BoxedError::new),
603 ExportTarget::All => {
604 self.export_create_database()
605 .await
606 .map_err(BoxedError::new)?;
607 self.export_create_table().await.map_err(BoxedError::new)?;
608 self.export_database_data().await.map_err(BoxedError::new)
609 }
610 }
611 }
612}
613
614fn build_with_options(
616 start_time: &Option<String>,
617 end_time: &Option<String>,
618 parallelism: usize,
619) -> String {
620 let mut options = vec!["format = 'parquet'".to_string()];
621 if let Some(start) = start_time {
622 options.push(format!("start_time = '{}'", start));
623 }
624 if let Some(end) = end_time {
625 options.push(format!("end_time = '{}'", end));
626 }
627 options.push(format!("parallelism = {}", parallelism));
628 options.join(", ")
629}
630
631#[cfg(test)]
632mod tests {
633 use clap::Parser;
634 use common_test_util::temp_dir::create_temp_dir;
635
636 use super::*;
637
638 const MOCK_AZBLOB_ACCOUNT_KEY_B64: &str = "dGVzdC1rZXk=";
639
640 #[tokio::test]
643 async fn test_export_command_build_with_local_fs() {
644 let temp_dir = create_temp_dir("test_export_local_fs");
645 let output_dir = temp_dir.path().to_str().unwrap();
646
647 let cmd = ExportCommand::parse_from([
648 "export",
649 "--addr",
650 "127.0.0.1:4000",
651 "--output-dir",
652 output_dir,
653 ]);
654
655 let result = cmd.build().await;
656 assert!(result.is_ok());
657 }
658
659 #[tokio::test]
660 async fn test_export_command_build_with_s3_success() {
661 let cmd = ExportCommand::parse_from([
662 "export",
663 "--addr",
664 "127.0.0.1:4000",
665 "--s3",
666 "--s3-bucket",
667 "test-bucket",
668 "--s3-root",
669 "test-root",
670 "--s3-access-key-id",
671 "test-key",
672 "--s3-secret-access-key",
673 "test-secret",
674 "--s3-region",
676 "us-west-2",
677 "--s3-endpoint",
678 "https://s3.amazonaws.com",
679 ]);
680
681 let result = cmd.build().await;
682 assert!(result.is_ok());
683 }
684
685 #[tokio::test]
686 async fn test_export_command_build_with_oss_success() {
687 let cmd = ExportCommand::parse_from([
688 "export",
689 "--addr",
690 "127.0.0.1:4000",
691 "--oss",
692 "--oss-bucket",
693 "test-bucket",
694 "--oss-root",
695 "test-root",
696 "--oss-access-key-id",
697 "test-key-id",
698 "--oss-access-key-secret",
699 "test-secret",
700 "--oss-endpoint",
701 "https://oss.example.com",
702 ]);
703
704 let result = cmd.build().await;
705 assert!(result.is_ok());
706 }
707
708 #[tokio::test]
709 async fn test_export_command_build_with_gcs_success() {
710 let cmd = ExportCommand::parse_from([
711 "export",
712 "--addr",
713 "127.0.0.1:4000",
714 "--gcs",
715 "--gcs-bucket",
716 "test-bucket",
717 "--gcs-root",
718 "test-root",
719 "--gcs-scope",
720 "test-scope",
721 "--gcs-credential",
722 "test-credential-content",
723 "--gcs-endpoint",
724 "https://storage.googleapis.com",
725 ]);
726
727 let result = cmd.build().await;
728 assert!(result.is_ok());
729 }
730
731 #[tokio::test]
732 async fn test_export_command_build_with_gcs_adc_success() {
733 let cmd = ExportCommand::parse_from([
735 "export",
736 "--addr",
737 "127.0.0.1:4000",
738 "--gcs",
739 "--gcs-bucket",
740 "test-bucket",
741 "--gcs-root",
742 "test-root",
743 "--gcs-scope",
744 "test-scope",
745 ]);
748
749 let result = cmd.build().await;
750 assert!(result.is_ok());
751 }
752
753 #[tokio::test]
754 async fn test_export_command_build_with_azblob_success() {
755 let cmd = ExportCommand::parse_from([
756 "export",
757 "--addr",
758 "127.0.0.1:4000",
759 "--azblob",
760 "--azblob-container",
761 "test-container",
762 "--azblob-root",
763 "test-root",
764 "--azblob-account-name",
765 "test-account",
766 "--azblob-account-key",
767 MOCK_AZBLOB_ACCOUNT_KEY_B64,
768 "--azblob-endpoint",
769 "https://account.blob.core.windows.net",
770 ]);
771
772 let result = cmd.build().await;
773 assert!(result.is_ok());
774 }
775
776 #[tokio::test]
777 async fn test_export_command_build_with_azblob_with_sas_token() {
778 let cmd = ExportCommand::parse_from([
780 "export",
781 "--addr",
782 "127.0.0.1:4000",
783 "--azblob",
784 "--azblob-container",
785 "test-container",
786 "--azblob-root",
787 "test-root",
788 "--azblob-account-name",
789 "test-account",
790 "--azblob-account-key",
791 MOCK_AZBLOB_ACCOUNT_KEY_B64,
792 "--azblob-endpoint",
793 "https://account.blob.core.windows.net",
794 "--azblob-sas-token",
795 "test-sas-token",
796 ]);
797
798 let result = cmd.build().await;
799 assert!(result.is_ok());
800 }
801
802 #[test]
805 fn test_export_command_build_with_conflict() {
806 let result =
808 ExportCommand::try_parse_from(["export", "--addr", "127.0.0.1:4000", "--s3", "--oss"]);
809
810 assert!(result.is_err());
811 let err = result.unwrap_err();
812 assert!(err.kind() == clap::error::ErrorKind::ArgumentConflict);
814 }
815
816 #[tokio::test]
817 async fn test_export_command_build_with_s3_no_enable_flag() {
818 let result = ExportCommand::try_parse_from([
820 "export",
821 "--addr",
822 "127.0.0.1:4000",
823 "--s3-bucket",
825 "test-bucket",
826 "--s3-access-key-id",
827 "test-key",
828 "--output-dir",
829 "/tmp/test",
830 ]);
831
832 assert!(result.is_err());
833 let err = result.unwrap_err();
834 assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
835 assert!(err.to_string().contains("--s3"));
836 }
837
838 #[tokio::test]
839 async fn test_export_command_build_with_oss_no_enable_flag() {
840 let result = ExportCommand::try_parse_from([
842 "export",
843 "--addr",
844 "127.0.0.1:4000",
845 "--oss-bucket",
846 "test-bucket",
847 "--output-dir",
848 "/tmp/test",
849 ]);
850
851 assert!(result.is_err());
852 let err = result.unwrap_err();
853 assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
854 assert!(err.to_string().contains("--oss"));
855 }
856
857 #[tokio::test]
858 async fn test_export_command_build_with_gcs_no_enable_flag() {
859 let result = ExportCommand::try_parse_from([
861 "export",
862 "--addr",
863 "127.0.0.1:4000",
864 "--gcs-bucket",
865 "test-bucket",
866 "--output-dir",
867 "/tmp/test",
868 ]);
869
870 assert!(result.is_err());
871 let err = result.unwrap_err();
872 assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
873 assert!(err.to_string().contains("--gcs"));
874 }
875
876 #[tokio::test]
877 async fn test_export_command_build_with_azblob_no_enable_flag() {
878 let result = ExportCommand::try_parse_from([
880 "export",
881 "--addr",
882 "127.0.0.1:4000",
883 "--azblob-container",
884 "test-container",
885 "--output-dir",
886 "/tmp/test",
887 ]);
888
889 assert!(result.is_err());
890 let err = result.unwrap_err();
891 assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
892 assert!(err.to_string().contains("--azblob"));
893 }
894
895 #[tokio::test]
898 async fn test_export_command_build_with_s3_empty_root() {
899 let cmd = ExportCommand::parse_from([
901 "export",
902 "--addr",
903 "127.0.0.1:4000",
904 "--s3",
905 "--s3-bucket",
906 "test-bucket",
907 "--s3-root",
908 "", "--s3-access-key-id",
910 "test-key",
911 "--s3-secret-access-key",
912 "test-secret",
913 "--s3-region",
914 "us-west-2",
915 ]);
916
917 let result = cmd.build().await;
918 assert!(
920 result.is_ok(),
921 "Expected success but got: {:?}",
922 result.err()
923 );
924 }
925
926 #[tokio::test]
927 async fn test_export_command_build_with_oss_empty_access_key_id() {
928 let cmd = ExportCommand::parse_from([
930 "export",
931 "--addr",
932 "127.0.0.1:4000",
933 "--oss",
934 "--oss-bucket",
935 "test-bucket",
936 "--oss-access-key-id",
937 "", "--oss-access-key-secret",
939 "test-secret",
940 "--oss-endpoint",
941 "https://oss.example.com",
942 ]);
943
944 let result = cmd.build().await;
945 assert!(result.is_err());
946 if let Err(err) = result {
947 assert!(
948 err.to_string().contains("OSS access key ID must be set"),
949 "Actual error: {}",
950 err
951 );
952 }
953 }
954
955 #[tokio::test]
956 async fn test_export_command_build_with_oss_missing_endpoint() {
957 let cmd = ExportCommand::parse_from([
959 "export",
960 "--addr",
961 "127.0.0.1:4000",
962 "--oss",
963 "--oss-bucket",
964 "test-bucket",
965 "--oss-root",
966 "test-root",
967 "--oss-access-key-id",
968 "test-key-id",
969 "--oss-access-key-secret",
970 "test-secret",
971 ]);
972
973 let result = cmd.build().await;
974 assert!(result.is_err());
975 if let Err(err) = result {
976 assert!(
977 err.to_string().contains("OSS endpoint must be set"),
978 "Actual error: {}",
979 err
980 );
981 }
982 }
983
984 #[tokio::test]
985 async fn test_export_command_build_with_oss_multiple_missing_fields() {
986 let cmd = ExportCommand::parse_from([
988 "export",
989 "--addr",
990 "127.0.0.1:4000",
991 "--oss",
992 "--oss-bucket",
993 "test-bucket",
994 ]);
996
997 let result = cmd.build().await;
998 assert!(result.is_err());
999 if let Err(err) = result {
1000 let err_str = err.to_string();
1001 assert!(
1003 err_str.contains("OSS"),
1004 "Error should mention OSS: {}",
1005 err_str
1006 );
1007 assert!(
1008 err_str.contains("must be set"),
1009 "Error should mention required fields: {}",
1010 err_str
1011 );
1012 }
1013 }
1014
1015 #[tokio::test]
1016 async fn test_export_command_build_with_gcs_empty_bucket() {
1017 let cmd = ExportCommand::parse_from([
1019 "export",
1020 "--addr",
1021 "127.0.0.1:4000",
1022 "--gcs",
1023 "--gcs-bucket",
1024 "", "--gcs-root",
1026 "test-root",
1027 "--gcs-scope",
1028 "test-scope",
1029 ]);
1030
1031 let result = cmd.build().await;
1032 assert!(result.is_err());
1033 if let Err(err) = result {
1034 assert!(
1035 err.to_string().contains("GCS bucket must be set"),
1036 "Actual error: {}",
1037 err
1038 );
1039 }
1040 }
1041
1042 #[tokio::test]
1043 async fn test_export_command_build_with_gcs_empty_root() {
1044 let cmd = ExportCommand::parse_from([
1046 "export",
1047 "--addr",
1048 "127.0.0.1:4000",
1049 "--gcs",
1050 "--gcs-bucket",
1051 "test-bucket",
1052 "--gcs-root",
1053 "", "--gcs-scope",
1055 "test-scope",
1056 "--gcs-credential",
1057 "test-credential",
1058 "--gcs-endpoint",
1059 "https://storage.googleapis.com",
1060 ]);
1061
1062 let result = cmd.build().await;
1063 assert!(result.is_err());
1064 if let Err(err) = result {
1065 assert!(
1066 err.to_string().contains("GCS root must be set"),
1067 "Actual error: {}",
1068 err
1069 );
1070 }
1071 }
1072
1073 #[tokio::test]
1074 async fn test_export_command_build_with_azblob_empty_account_name() {
1075 let cmd = ExportCommand::parse_from([
1077 "export",
1078 "--addr",
1079 "127.0.0.1:4000",
1080 "--azblob",
1081 "--azblob-container",
1082 "test-container",
1083 "--azblob-account-name",
1084 "", "--azblob-endpoint",
1086 "https://account.blob.core.windows.net",
1087 ]);
1088
1089 let result = cmd.build().await;
1090 assert!(result.is_ok(), "Empty account_name should succeed");
1091 }
1092
1093 #[tokio::test]
1094 async fn test_export_command_build_with_azblob_missing_account_key() {
1095 let cmd = ExportCommand::parse_from([
1097 "export",
1098 "--addr",
1099 "127.0.0.1:4000",
1100 "--azblob",
1101 "--azblob-container",
1102 "test-container",
1103 "--azblob-endpoint",
1104 "https://account.blob.core.windows.net",
1105 ]);
1106
1107 let result = cmd.build().await;
1108 assert!(result.is_ok(), "Missing account_key should succeed");
1109 }
1110
1111 #[tokio::test]
1114 async fn test_export_command_build_with_no_storage() {
1115 let cmd = ExportCommand::parse_from(["export", "--addr", "127.0.0.1:4000"]);
1117
1118 let result = cmd.build().await;
1119 assert!(result.is_err());
1120 if let Err(err) = result {
1121 assert!(
1122 err.to_string().contains("Output directory not set"),
1123 "Actual error: {}",
1124 err
1125 );
1126 }
1127 }
1128
1129 #[tokio::test]
1130 async fn test_export_command_build_with_s3_minimal_config() {
1131 let cmd = ExportCommand::parse_from([
1133 "export",
1134 "--addr",
1135 "127.0.0.1:4000",
1136 "--s3",
1137 "--s3-bucket",
1138 "test-bucket",
1139 "--s3-access-key-id",
1140 "test-key",
1141 "--s3-secret-access-key",
1142 "test-secret",
1143 "--s3-region",
1144 "us-west-2",
1145 ]);
1147
1148 let result = cmd.build().await;
1149 assert!(result.is_ok(), "Minimal S3 config should succeed");
1150 }
1151
1152 #[tokio::test]
1153 async fn test_export_command_build_with_oss_minimal_config() {
1154 let cmd = ExportCommand::parse_from([
1156 "export",
1157 "--addr",
1158 "127.0.0.1:4000",
1159 "--oss",
1160 "--oss-bucket",
1161 "test-bucket",
1162 "--oss-access-key-id",
1163 "test-key-id",
1164 "--oss-access-key-secret",
1165 "test-secret",
1166 "--oss-endpoint",
1167 "https://oss.example.com",
1168 ]);
1170
1171 let result = cmd.build().await;
1172 assert!(result.is_ok(), "Minimal OSS config should succeed");
1173 }
1174
1175 #[tokio::test]
1176 async fn test_export_command_build_with_gcs_minimal_config() {
1177 let cmd = ExportCommand::parse_from([
1179 "export",
1180 "--addr",
1181 "127.0.0.1:4000",
1182 "--gcs",
1183 "--gcs-bucket",
1184 "test-bucket",
1185 "--gcs-root",
1186 "test-root",
1187 "--gcs-scope",
1188 "test-scope",
1189 ]);
1191
1192 let result = cmd.build().await;
1193 assert!(result.is_ok(), "Minimal GCS config should succeed");
1194 }
1195
1196 #[tokio::test]
1197 async fn test_export_command_build_with_azblob_minimal_config() {
1198 let cmd = ExportCommand::parse_from([
1200 "export",
1201 "--addr",
1202 "127.0.0.1:4000",
1203 "--azblob",
1204 "--azblob-container",
1205 "test-container",
1206 "--azblob-endpoint",
1207 "https://account.blob.core.windows.net",
1208 ]);
1209
1210 let result = cmd.build().await;
1211 assert!(result.is_ok(), "Minimal AzBlob config should succeed");
1212 }
1213
1214 #[tokio::test]
1215 async fn test_export_command_build_with_azblob_missing_endpoint() {
1216 let cmd = ExportCommand::parse_from([
1217 "export",
1218 "--addr",
1219 "127.0.0.1:4000",
1220 "--azblob",
1221 "--azblob-container",
1222 "test-container",
1223 ]);
1224
1225 let result = cmd.build().await;
1226 assert!(result.is_err());
1227 if let Err(err) = result {
1228 assert!(
1229 err.to_string().contains("AzBlob endpoint must be set"),
1230 "Actual error: {}",
1231 err
1232 );
1233 }
1234 }
1235
1236 #[tokio::test]
1237 async fn test_export_command_build_with_azblob_missing_container() {
1238 let cmd = ExportCommand::parse_from([
1239 "export",
1240 "--addr",
1241 "127.0.0.1:4000",
1242 "--azblob",
1243 "--azblob-endpoint",
1244 "https://account.blob.core.windows.net",
1245 ]);
1246
1247 let result = cmd.build().await;
1248 assert!(result.is_err());
1249 if let Err(err) = result {
1250 assert!(
1251 err.to_string().contains("AzBlob container must be set"),
1252 "Actual error: {}",
1253 err
1254 );
1255 }
1256 }
1257
1258 #[tokio::test]
1259 async fn test_export_command_build_with_local_and_s3() {
1260 let temp_dir = create_temp_dir("test_export_local_and_s3");
1262 let output_dir = temp_dir.path().to_str().unwrap();
1263
1264 let cmd = ExportCommand::parse_from([
1265 "export",
1266 "--addr",
1267 "127.0.0.1:4000",
1268 "--output-dir",
1269 output_dir,
1270 "--s3",
1271 "--s3-bucket",
1272 "test-bucket",
1273 "--s3-access-key-id",
1274 "test-key",
1275 "--s3-secret-access-key",
1276 "test-secret",
1277 "--s3-region",
1278 "us-west-2",
1279 ]);
1280
1281 let result = cmd.build().await;
1282 assert!(
1283 result.is_ok(),
1284 "S3 should be selected when both are provided"
1285 );
1286 }
1287
1288 #[tokio::test]
1291 async fn test_export_command_build_with_azblob_only_sas_token() {
1292 let cmd = ExportCommand::parse_from([
1294 "export",
1295 "--addr",
1296 "127.0.0.1:4000",
1297 "--azblob",
1298 "--azblob-container",
1299 "test-container",
1300 "--azblob-endpoint",
1301 "https://account.blob.core.windows.net",
1302 "--azblob-sas-token",
1303 "test-sas-token",
1304 ]);
1305
1306 let result = cmd.build().await;
1307 assert!(
1308 result.is_ok(),
1309 "AzBlob with only sas_token should succeed: {:?}",
1310 result.err()
1311 );
1312 }
1313
1314 #[tokio::test]
1315 async fn test_export_command_build_with_azblob_empty_account_key_with_sas() {
1316 let cmd = ExportCommand::parse_from([
1318 "export",
1319 "--addr",
1320 "127.0.0.1:4000",
1321 "--azblob",
1322 "--azblob-container",
1323 "test-container",
1324 "--azblob-account-key",
1325 "", "--azblob-endpoint",
1327 "https://account.blob.core.windows.net",
1328 "--azblob-sas-token",
1329 "test-sas-token",
1330 ]);
1331
1332 let result = cmd.build().await;
1333 assert!(
1334 result.is_ok(),
1335 "AzBlob with empty account_key but sas_token should succeed: {:?}",
1336 result.err()
1337 );
1338 }
1339}