1use std::os::fd::FromRawFd;
2
3use crate::Container;
4use crate::seed::{
5 Command, DuplicateSeedName, LoadError, LoadedSeed, LoadedSeeds, Seed, SeedCacheConfig, SeedName,
6};
7
8#[derive(Debug, thiserror::Error)]
9pub enum SeedApplyError {
10 #[error("Failed to apply command seed")]
11 Command(#[from] cmd_proc::CommandError),
12 #[error("Failed to apply SQL seed")]
13 Sql(#[from] sqlx::Error),
14 #[error(transparent)]
15 EnvVariableValue(#[from] cmd_proc::EnvVariableValueError),
16}
17
18#[derive(Clone, Debug, PartialEq)]
19pub enum SslConfig {
20 Generated {
21 hostname: pg_client::config::HostName,
22 },
23 }
25
26#[derive(Clone, Debug, Eq, PartialEq)]
33pub struct TransparentWorkdir(String);
34
35impl TransparentWorkdir {
36 #[must_use]
37 pub fn as_str(&self) -> &str {
38 &self.0
39 }
40
41 #[must_use]
42 pub fn as_path(&self) -> &std::path::Path {
43 std::path::Path::new(&self.0)
44 }
45}
46
47#[derive(Debug, thiserror::Error)]
48pub enum TransparentWorkdirError {
49 #[error("transparent workdir path is not absolute: {0:?}")]
50 NotAbsolute(std::path::PathBuf),
51 #[error("transparent workdir path is not valid UTF-8: {0:?}")]
52 NotUtf8(std::path::PathBuf),
53}
54
55impl TryFrom<std::path::PathBuf> for TransparentWorkdir {
56 type Error = TransparentWorkdirError;
57
58 fn try_from(path: std::path::PathBuf) -> Result<Self, Self::Error> {
59 if !path.is_absolute() {
60 return Err(TransparentWorkdirError::NotAbsolute(path));
61 }
62 match path.into_os_string().into_string() {
63 Ok(string) => Ok(Self(string)),
64 Err(os_string) => Err(TransparentWorkdirError::NotUtf8(os_string.into())),
65 }
66 }
67}
68
69#[derive(Clone, Debug, PartialEq)]
70pub struct Definition {
71 pub instance_name: crate::InstanceName,
72 pub application_name: Option<pg_client::config::ApplicationName>,
73 pub backend: ociman::Backend,
74 pub database: pg_client::Database,
75 pub parameters: pg_client::parameter::Map,
76 pub seeds: indexmap::IndexMap<SeedName, Seed>,
77 pub ssl_config: Option<SslConfig>,
78 pub superuser: pg_client::User,
79 pub image: crate::image::Image,
80 pub cross_container_access: bool,
81 pub wait_available_timeout: std::time::Duration,
82 pub remove: bool,
83 pub session_name: Option<crate::session::Name>,
96 pub transparent_workdir: Option<TransparentWorkdir>,
103}
104
105impl Definition {
106 #[must_use]
107 pub fn new(
108 backend: ociman::backend::Backend,
109 image: crate::image::Image,
110 instance_name: crate::InstanceName,
111 ) -> Self {
112 Self {
113 instance_name,
114 backend,
115 application_name: None,
116 parameters: pg_client::parameter::Map::new(),
117 seeds: indexmap::IndexMap::new(),
118 ssl_config: None,
119 superuser: pg_client::User::POSTGRES,
120 database: pg_client::Database::POSTGRES,
121 image,
122 cross_container_access: false,
123 wait_available_timeout: std::time::Duration::from_secs(10),
124 remove: true,
125 session_name: None,
126 transparent_workdir: None,
127 }
128 }
129
130 #[must_use]
135 pub fn session_name(self, name: crate::session::Name) -> Self {
136 Self {
137 session_name: Some(name),
138 ..self
139 }
140 }
141
142 #[must_use]
143 pub fn remove(self, remove: bool) -> Self {
144 Self { remove, ..self }
145 }
146
147 #[must_use]
155 pub fn transparent_workdir(self, workdir: TransparentWorkdir) -> Self {
156 Self {
157 transparent_workdir: Some(workdir),
158 ..self
159 }
160 }
161
162 #[must_use]
163 pub fn image(self, image: crate::image::Image) -> Self {
164 Self { image, ..self }
165 }
166
167 pub fn add_seed(self, name: SeedName, seed: Seed) -> Result<Self, DuplicateSeedName> {
168 let mut seeds = self.seeds.clone();
169
170 if seeds.contains_key(&name) {
171 return Err(DuplicateSeedName(name));
172 }
173
174 seeds.insert(name, seed);
175 Ok(Self { seeds, ..self })
176 }
177
178 pub fn apply_file(
179 self,
180 name: SeedName,
181 path: std::path::PathBuf,
182 ) -> Result<Self, DuplicateSeedName> {
183 self.add_seed(name, Seed::SqlFile { path })
184 }
185
186 pub async fn load_seeds(
187 &self,
188 instance_name: &crate::InstanceName,
189 ) -> Result<LoadedSeeds<'_>, LoadError> {
190 LoadedSeeds::load(
191 &self.image,
192 self.ssl_config.as_ref(),
193 &self.parameters,
194 &self.seeds,
195 &self.backend,
196 instance_name,
197 )
198 .await
199 }
200
201 pub async fn print_cache_status(
202 &self,
203 instance_name: &crate::InstanceName,
204 json: bool,
205 ) -> Result<(), crate::container::Error> {
206 let loaded_seeds = self.load_seeds(instance_name).await?;
207 if json {
208 loaded_seeds.print_json(instance_name);
209 } else {
210 loaded_seeds.print(instance_name);
211 }
212 Ok(())
213 }
214
215 #[must_use]
216 pub fn superuser(self, user: pg_client::User) -> Self {
217 Self {
218 superuser: user,
219 ..self
220 }
221 }
222
223 pub fn apply_file_from_git_revision(
224 self,
225 name: SeedName,
226 path: std::path::PathBuf,
227 git_revision: impl Into<String>,
228 ) -> Result<Self, DuplicateSeedName> {
229 self.add_seed(
230 name,
231 Seed::SqlFileGitRevision {
232 git_revision: git_revision.into(),
233 path,
234 },
235 )
236 }
237
238 pub fn apply_sql_statement(
239 self,
240 name: SeedName,
241 statement: impl Into<String>,
242 ) -> Result<Self, DuplicateSeedName> {
243 self.add_seed(
244 name,
245 Seed::SqlStatement {
246 statement: statement.into(),
247 },
248 )
249 }
250
251 pub fn apply_command(
252 self,
253 name: SeedName,
254 command: Command,
255 cache: SeedCacheConfig,
256 ) -> Result<Self, DuplicateSeedName> {
257 self.add_seed(name, Seed::Command { command, cache })
258 }
259
260 pub fn apply_script(
261 self,
262 name: SeedName,
263 script: impl Into<String>,
264 cache: SeedCacheConfig,
265 ) -> Result<Self, DuplicateSeedName> {
266 self.add_seed(
267 name,
268 Seed::Script {
269 script: script.into(),
270 cache,
271 },
272 )
273 }
274
275 pub fn apply_container_script(
276 self,
277 name: SeedName,
278 script: impl Into<String>,
279 ) -> Result<Self, DuplicateSeedName> {
280 self.add_seed(
281 name,
282 Seed::ContainerScript {
283 script: script.into(),
284 },
285 )
286 }
287
288 pub fn apply_csv_file(
289 self,
290 name: SeedName,
291 path: std::path::PathBuf,
292 table: pg_client::QualifiedTable,
293 ) -> Result<Self, DuplicateSeedName> {
294 self.add_seed(
295 name,
296 Seed::CsvFile {
297 path,
298 table,
299 delimiter: ',',
300 },
301 )
302 }
303
304 #[must_use]
305 pub fn ssl_config(self, ssl_config: SslConfig) -> Self {
306 Self {
307 ssl_config: Some(ssl_config),
308 ..self
309 }
310 }
311
312 #[must_use]
324 pub fn parameter(
325 self,
326 name: pg_client::parameter::Name,
327 value: pg_client::parameter::Value,
328 ) -> Self {
329 let mut parameters = self.parameters;
330 parameters.insert(name, value);
331 Self { parameters, ..self }
332 }
333
334 #[must_use]
335 pub fn cross_container_access(self, enabled: bool) -> Self {
336 Self {
337 cross_container_access: enabled,
338 ..self
339 }
340 }
341
342 #[must_use]
343 pub fn wait_available_timeout(self, timeout: std::time::Duration) -> Self {
344 Self {
345 wait_available_timeout: timeout,
346 ..self
347 }
348 }
349
350 #[must_use]
351 pub fn to_ociman_definition(&self) -> ociman::Definition {
352 let mut ociman_definition =
353 ociman::Definition::new(self.backend.clone(), (&self.image).into());
354 if let Some(session_name) = &self.session_name {
355 ociman_definition = ociman_definition.container_name(session_name.container_name());
356 }
357 if let Some(workdir) = &self.transparent_workdir {
358 let workdir_str = workdir.as_str();
359 ociman_definition = ociman_definition.mount(format!(
360 "type=bind,source={workdir_str},target={workdir_str}"
361 ));
362 }
363 ociman_definition
364 }
365
366 pub async fn with_container<T>(
367 &self,
368 mut action: impl AsyncFnMut(&Container) -> T,
369 ) -> Result<T, crate::container::Error> {
370 let mut db_container = self.boot_and_seed().await?;
371 let result = action(&db_container).await;
372 db_container.stop().await?;
373 Ok(result)
374 }
375
376 async fn boot_and_seed(&self) -> Result<Container, crate::container::Error> {
383 let loaded_seeds = self.load_seeds(&self.instance_name).await?;
384 let (last_cache_hit, uncached_seeds) = self.populate_cache(&loaded_seeds).await?;
385
386 let boot_definition = match &last_cache_hit {
387 Some(reference) => self
388 .clone()
389 .image(crate::image::Image::Explicit(reference.clone())),
390 None => self.clone(),
391 };
392
393 let seed_entries = crate::label::build_seed_entries(self, &loaded_seeds);
394 let db_container = Container::run_definition(&boot_definition, &seed_entries).await?;
395
396 if last_cache_hit.is_some() {
397 db_container
398 .set_superuser_password(
399 db_container
400 .client_config
401 .session
402 .password
403 .as_ref()
404 .unwrap(),
405 self.wait_available_timeout,
406 )
407 .await?;
408 }
409
410 db_container
411 .wait_available(self.wait_available_timeout)
412 .await?;
413
414 for seed in &uncached_seeds {
415 self.apply_loaded_seed(&db_container, seed).await?;
416 }
417
418 Ok(db_container)
419 }
420
421 pub async fn start(&self) -> Result<(), crate::container::Error> {
428 let _container = self.boot_and_seed().await?;
429 Ok(())
430 }
431
432 pub async fn populate_cache(
438 &self,
439 loaded_seeds: &LoadedSeeds<'_>,
440 ) -> Result<(Option<ociman::Reference>, Vec<LoadedSeed>), crate::container::Error> {
441 let all_seed_entries = crate::label::build_seed_entries(self, loaded_seeds);
442 let mut previous_cache_reference: Option<&ociman::Reference> = None;
443 let mut seeds_iter = loaded_seeds.iter_seeds().enumerate().peekable();
444
445 while let Some((index, seed)) = seeds_iter.next() {
446 let Some(cache_reference) = seed.cache_status().reference() else {
447 let mut remaining = vec![seed.clone()];
449 remaining.extend(seeds_iter.map(|(_, seed)| seed.clone()));
450 return Ok((previous_cache_reference.cloned(), remaining));
451 };
452
453 if seed.cache_status().is_hit() {
454 previous_cache_reference = Some(cache_reference);
455 continue;
456 }
457
458 let caching_image = previous_cache_reference
459 .map(|reference| crate::image::Image::Explicit(reference.clone()))
460 .unwrap_or_else(|| self.image.clone());
461
462 let current = &all_seed_entries[..=index];
465
466 if let LoadedSeed::ContainerScript { script, .. } = seed {
467 log::info!("Applying container-script seed: {}", seed.name());
468
469 let base_image: ociman::image::Reference = (&caching_image).into();
470 let build_dir = create_container_script_build_dir(&base_image, script);
471
472 ociman::image::BuildDefinition::from_directory(
473 &self.backend,
474 cache_reference.clone(),
475 &build_dir,
476 )
477 .build()
478 .await;
479
480 std::fs::remove_dir_all(&build_dir)
481 .expect("failed to clean up container-script build directory");
482 } else {
483 let caching_definition = self.clone().remove(false).image(caching_image);
484
485 let mut container = Container::run_definition(&caching_definition, current).await?;
486
487 if previous_cache_reference.is_some() {
488 container
489 .set_superuser_password(
490 container.client_config.session.password.as_ref().unwrap(),
491 self.wait_available_timeout,
492 )
493 .await?;
494 }
495
496 container
497 .wait_available(self.wait_available_timeout)
498 .await?;
499
500 self.apply_loaded_seed(&container, seed).await?;
501 container.stop_commit_remove(cache_reference).await?;
502 }
503
504 log::info!("Committed cache image: {cache_reference}");
505
506 previous_cache_reference = Some(cache_reference);
507 }
508
509 Ok((previous_cache_reference.cloned(), Vec::new()))
510 }
511
512 pub async fn run_integration_server(
513 &self,
514 result_fd: std::os::fd::RawFd,
515 control_fd: std::os::fd::RawFd,
516 ) -> Result<(), crate::container::Error> {
517 self.with_container(async |container| {
518 let result_owned = unsafe { std::os::fd::OwnedFd::from_raw_fd(result_fd) };
521 let control_owned = unsafe { std::os::fd::OwnedFd::from_raw_fd(control_fd) };
522
523 let mut result_file = std::fs::File::from(result_owned);
524 let json = serde_json::to_string(&container.client_config).unwrap();
525
526 use std::io::Write;
527 writeln!(result_file, "{json}").expect("Failed to write config to result pipe");
528 drop(result_file);
529
530 log::info!("Integration server is running, waiting for EOF on control pipe");
531
532 let control_fd = tokio::io::unix::AsyncFd::new(control_owned)
533 .expect("Failed to register control pipe with tokio");
534
535 let _ = control_fd.readable().await.unwrap();
536
537 log::info!("Integration server received EOF on control pipe, exiting");
538 })
539 .await
540 }
541
542 async fn apply_loaded_seed(
543 &self,
544 db_container: &Container,
545 loaded_seed: &LoadedSeed,
546 ) -> Result<(), SeedApplyError> {
547 log::info!("Applying seed: {}", loaded_seed.name());
548 match loaded_seed {
549 LoadedSeed::SqlFile { content, .. } => db_container.apply_sql(content).await?,
550 LoadedSeed::SqlFileGitRevision { content, .. } => {
551 db_container.apply_sql(content).await?
552 }
553 LoadedSeed::SqlStatement { statement, .. } => db_container.apply_sql(statement).await?,
554 LoadedSeed::Command { command, .. } => {
555 self.execute_command(db_container, command).await?
556 }
557 LoadedSeed::Script { script, .. } => self.execute_script(db_container, script).await?,
558 LoadedSeed::ContainerScript { script, .. } => {
559 db_container.exec_container_script(script).await?
560 }
561 LoadedSeed::CsvFile {
562 table,
563 delimiter,
564 content,
565 ..
566 } => db_container.apply_csv(table, *delimiter, content).await?,
567 }
568
569 Ok(())
570 }
571
572 async fn execute_command(
573 &self,
574 db_container: &Container,
575 command: &Command,
576 ) -> Result<(), SeedApplyError> {
577 cmd_proc::Command::new(&command.command)
578 .arguments(&command.arguments)
579 .envs(db_container.pg_env()?)
580 .env(
581 &crate::ENV_DATABASE_URL,
582 db_container
583 .database_url()
584 .parse::<cmd_proc::EnvVariableValue>()?,
585 )
586 .status()
587 .await?;
588 Ok(())
589 }
590
591 async fn execute_script(
592 &self,
593 db_container: &Container,
594 script: &str,
595 ) -> Result<(), SeedApplyError> {
596 cmd_proc::Command::new("sh")
597 .arguments(["-e", "-c"])
598 .argument(script)
599 .envs(db_container.pg_env()?)
600 .env(
601 &crate::ENV_DATABASE_URL,
602 db_container
603 .database_url()
604 .parse::<cmd_proc::EnvVariableValue>()?,
605 )
606 .status()
607 .await?;
608 Ok(())
609 }
610}
611
612fn create_container_script_build_dir(
613 base_image: &ociman::image::Reference,
614 script: &str,
615) -> std::path::PathBuf {
616 use rand::RngExt;
617
618 let suffix: String = rand::rng()
619 .sample_iter(rand::distr::Alphanumeric)
620 .take(16)
621 .map(char::from)
622 .collect();
623
624 let dir = std::env::temp_dir().join(format!("pg-ephemeral-build-{suffix}"));
625 std::fs::create_dir(&dir).expect("failed to create container-script build directory");
626
627 std::fs::write(dir.join("script.sh"), script).expect("failed to write container-script");
628
629 std::fs::write(
630 dir.join("Dockerfile"),
631 format!("FROM {base_image}\nCOPY script.sh /tmp/pg-ephemeral-script.sh\nRUN sh -e /tmp/pg-ephemeral-script.sh && rm /tmp/pg-ephemeral-script.sh\n"),
632 )
633 .expect("failed to write Dockerfile");
634
635 dir
636}
637
638#[cfg(test)]
639mod test {
640 use super::*;
641
642 fn test_backend() -> ociman::Backend {
643 ociman::Backend::Podman {
644 version: semver::Version::new(4, 0, 0),
645 rootless: true,
646 }
647 }
648
649 fn test_instance_name() -> crate::InstanceName {
650 "test".parse().unwrap()
651 }
652
653 #[test]
654 fn test_add_seed_rejects_duplicate() {
655 let definition = Definition::new(
656 test_backend(),
657 crate::Image::default(),
658 test_instance_name(),
659 );
660 let seed_name: SeedName = "test-seed".parse().unwrap();
661
662 let definition = definition
663 .add_seed(
664 seed_name.clone(),
665 Seed::SqlFile {
666 path: "file1.sql".into(),
667 },
668 )
669 .unwrap();
670
671 let result = definition.add_seed(
672 seed_name.clone(),
673 Seed::SqlFile {
674 path: "file2.sql".into(),
675 },
676 );
677
678 assert_eq!(result, Err(DuplicateSeedName(seed_name)));
679 }
680
681 #[test]
682 fn test_add_seed_allows_different_names() {
683 let definition = Definition::new(
684 test_backend(),
685 crate::Image::default(),
686 test_instance_name(),
687 );
688
689 let definition = definition
690 .add_seed(
691 "seed1".parse().unwrap(),
692 Seed::SqlFile {
693 path: "file1.sql".into(),
694 },
695 )
696 .unwrap();
697
698 let result = definition.add_seed(
699 "seed2".parse().unwrap(),
700 Seed::SqlFile {
701 path: "file2.sql".into(),
702 },
703 );
704
705 assert!(result.is_ok());
706 }
707
708 #[test]
709 fn test_apply_file_rejects_duplicate() {
710 let definition = Definition::new(
711 test_backend(),
712 crate::Image::default(),
713 test_instance_name(),
714 );
715 let seed_name: SeedName = "test-seed".parse().unwrap();
716
717 let definition = definition
718 .apply_file(seed_name.clone(), "file1.sql".into())
719 .unwrap();
720
721 let result = definition.apply_file(seed_name.clone(), "file2.sql".into());
722
723 assert_eq!(result, Err(DuplicateSeedName(seed_name)));
724 }
725
726 #[test]
727 fn test_apply_sql_statement_adds_seed() {
728 let definition = Definition::new(
729 test_backend(),
730 crate::Image::default(),
731 test_instance_name(),
732 );
733
734 let result = definition.apply_sql_statement(
735 "create-users".parse().unwrap(),
736 "CREATE TABLE users (id INT)",
737 );
738
739 assert!(result.is_ok());
740 let definition = result.unwrap();
741 assert_eq!(definition.seeds.len(), 1);
742 }
743
744 #[test]
745 fn test_apply_command_adds_seed() {
746 let definition = Definition::new(
747 test_backend(),
748 crate::Image::default(),
749 test_instance_name(),
750 );
751
752 let result = definition.apply_command(
753 "test-command".parse().unwrap(),
754 Command::new("echo", vec!["test"]),
755 SeedCacheConfig::CommandHash,
756 );
757
758 assert!(result.is_ok());
759 let definition = result.unwrap();
760 assert_eq!(definition.seeds.len(), 1);
761 }
762
763 #[test]
764 fn test_apply_command_rejects_duplicate() {
765 let definition = Definition::new(
766 test_backend(),
767 crate::Image::default(),
768 test_instance_name(),
769 );
770 let seed_name: SeedName = "test-command".parse().unwrap();
771
772 let definition = definition
773 .apply_command(
774 seed_name.clone(),
775 Command::new("echo", vec!["test1"]),
776 SeedCacheConfig::CommandHash,
777 )
778 .unwrap();
779
780 let result = definition.apply_command(
781 seed_name.clone(),
782 Command::new("echo", vec!["test2"]),
783 SeedCacheConfig::CommandHash,
784 );
785
786 assert_eq!(result, Err(DuplicateSeedName(seed_name)));
787 }
788
789 #[test]
790 fn test_apply_script_adds_seed() {
791 let definition = Definition::new(
792 test_backend(),
793 crate::Image::default(),
794 test_instance_name(),
795 );
796
797 let result = definition.apply_script(
798 "test-script".parse().unwrap(),
799 "echo test",
800 SeedCacheConfig::CommandHash,
801 );
802
803 assert!(result.is_ok());
804 let definition = result.unwrap();
805 assert_eq!(definition.seeds.len(), 1);
806 }
807
808 #[test]
809 fn test_apply_script_rejects_duplicate() {
810 let definition = Definition::new(
811 test_backend(),
812 crate::Image::default(),
813 test_instance_name(),
814 );
815 let seed_name: SeedName = "test-script".parse().unwrap();
816
817 let definition = definition
818 .apply_script(
819 seed_name.clone(),
820 "echo test1",
821 SeedCacheConfig::CommandHash,
822 )
823 .unwrap();
824
825 let result = definition.apply_script(
826 seed_name.clone(),
827 "echo test2",
828 SeedCacheConfig::CommandHash,
829 );
830
831 assert_eq!(result, Err(DuplicateSeedName(seed_name)));
832 }
833
834 #[test]
835 fn test_apply_container_script_adds_seed() {
836 let definition = Definition::new(
837 test_backend(),
838 crate::Image::default(),
839 test_instance_name(),
840 );
841
842 let result = definition.apply_container_script(
843 "install-ext".parse().unwrap(),
844 "apt-get update && apt-get install -y postgresql-17-cron",
845 );
846
847 assert!(result.is_ok());
848 let definition = result.unwrap();
849 assert_eq!(definition.seeds.len(), 1);
850 }
851
852 #[test]
853 fn test_apply_container_script_rejects_duplicate() {
854 let definition = Definition::new(
855 test_backend(),
856 crate::Image::default(),
857 test_instance_name(),
858 );
859 let seed_name: SeedName = "install-ext".parse().unwrap();
860
861 let definition = definition
862 .apply_container_script(seed_name.clone(), "apt-get update")
863 .unwrap();
864
865 let result = definition.apply_container_script(seed_name.clone(), "apt-get update");
866
867 assert_eq!(result, Err(DuplicateSeedName(seed_name)));
868 }
869
870 #[test]
871 fn test_parameter_sets_value() {
872 let definition = Definition::new(
873 test_backend(),
874 crate::Image::default(),
875 test_instance_name(),
876 )
877 .parameter(
878 "synchronous_commit".parse().unwrap(),
879 "off".parse().unwrap(),
880 );
881
882 assert_eq!(
883 definition
884 .parameters
885 .get(&"synchronous_commit".parse().unwrap()),
886 Some(&"off".parse().unwrap()),
887 );
888 }
889
890 fn test_qualified_table() -> pg_client::QualifiedTable {
891 pg_client::QualifiedTable {
892 schema: pg_client::identifier::Schema::PUBLIC,
893 table: "users".parse().unwrap(),
894 }
895 }
896
897 #[test]
898 fn test_apply_csv_file_adds_seed() {
899 let definition = Definition::new(
900 test_backend(),
901 crate::Image::default(),
902 test_instance_name(),
903 );
904
905 let result = definition.apply_csv_file(
906 "import-users".parse().unwrap(),
907 "fixtures/users.csv".into(),
908 test_qualified_table(),
909 );
910
911 assert!(result.is_ok());
912 let definition = result.unwrap();
913 assert_eq!(definition.seeds.len(), 1);
914 }
915
916 #[test]
917 fn test_apply_csv_file_rejects_duplicate() {
918 let definition = Definition::new(
919 test_backend(),
920 crate::Image::default(),
921 test_instance_name(),
922 );
923 let seed_name: SeedName = "import-users".parse().unwrap();
924
925 let definition = definition
926 .apply_csv_file(
927 seed_name.clone(),
928 "fixtures/users.csv".into(),
929 test_qualified_table(),
930 )
931 .unwrap();
932
933 let result = definition.apply_csv_file(
934 seed_name.clone(),
935 "fixtures/other.csv".into(),
936 test_qualified_table(),
937 );
938
939 assert_eq!(result, Err(DuplicateSeedName(seed_name)));
940 }
941}