Skip to main content

pg_ephemeral/
definition.rs

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    // UserProvided { ca_cert: PathBuf, server_cert: PathBuf, server_key: PathBuf },
24}
25
26/// Absolute, UTF-8 host path mirrored into a container as a bind mount.
27///
28/// Constructed via [`TryFrom<PathBuf>`]. Validates at construction so the
29/// invariants (absolute + UTF-8) are proven by the type — downstream code
30/// (mount string formatting, `--workdir` flag emission) can rely on
31/// `as_str()` without re-checking.
32#[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    /// Optional user-facing identifier for the resulting container.
84    ///
85    /// When set, [`crate::label::apply`] emits a `pg-ephemeral.session.name`
86    /// label carrying this value, and the launched container gets a
87    /// deterministic OCI name (`pg-ephemeral-session-<name>`) so the runtime
88    /// atomically rejects collisions with another container of the same name.
89    ///
90    /// Naming is independent of lifecycle. The same Definition can be named
91    /// and ephemeral (driven through `with_container` with `--rm`), or named
92    /// and persistent (driven through `run_detached` with no auto-remove).
93    /// The label distinguishes named runs from anonymous ones; the OCI name
94    /// enforces uniqueness.
95    pub session_name: Option<crate::session::Name>,
96    /// Host path to bind-mount into the container at the same path on launch.
97    ///
98    /// When set, the launched container gets a `--mount type=bind,source=<path>,target=<path>`,
99    /// making the host directory accessible inside the container at the
100    /// mirrored path. Powers the "transparent" CLI mode where bare
101    /// commands operate on the user's cwd as if they were running locally.
102    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    /// Attach a user-facing session name to this Definition.
131    ///
132    /// See [`Definition::session_name`] for semantics — in particular that
133    /// naming is independent of lifecycle.
134    #[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    /// Bind-mount the given host path into the container at the same path.
148    ///
149    /// Used by the transparent CLI mode so the user's cwd is visible to
150    /// container-side tooling at the same absolute path it has on host.
151    /// The caller constructs [`TransparentWorkdir`] via `TryFrom<PathBuf>`,
152    /// which validates the path is absolute and UTF-8 — invariants Definition
153    /// relies on at launch / exec time.
154    #[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    /// Set a PostgreSQL server parameter, passed to the backend as a
313    /// `-c <name>=<value>` flag at container launch.
314    ///
315    /// Calling this repeatedly with the same name overwrites the prior value.
316    /// Parameters are folded into the seed cache-key chain, so changing one
317    /// invalidates the cached images that depend on it.
318    ///
319    /// When [`Self::ssl_config`] is set, pg-ephemeral controls the `ssl`,
320    /// `ssl_cert_file`, `ssl_key_file`, and `ssl_ca_file` parameters; supplying
321    /// any of those here is rejected at launch with
322    /// [`crate::container::Error::ParameterConflictsWithSslConfig`].
323    #[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    /// Boot a container from this definition, applying cache hits and seeds,
377    /// and return the running handle without stopping it.
378    ///
379    /// Used by [`Self::with_container`] (which then runs an action and stops)
380    /// and [`Self::start`] (which returns control to the CLI with the
381    /// container left running as a detached named session).
382    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    /// Boot a container, apply cache + seeds, and leave it running.
422    ///
423    /// Intended for named sessions: the caller is expected to have set
424    /// [`Self::session_name`] and `.remove(false)`, so the resulting
425    /// container is discoverable via [`crate::session::Session::find`] /
426    /// `list` and survives until explicitly stopped.
427    pub async fn start(&self) -> Result<(), crate::container::Error> {
428        let _container = self.boot_and_seed().await?;
429        Ok(())
430    }
431
432    /// Populate cache images for seeds.
433    ///
434    /// Returns a tuple of:
435    /// - The last cache hit reference (if any), which can be used to boot from
436    /// - The loaded seeds that could not be cached because the cache chain was broken
437    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                // Uncacheable seed - cache chain is broken, return remaining seeds
448                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            // Seeds applied at this cache image's commit point: every seed up
463            // through and including the current one.
464            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            // SAFETY: The parent process guarantees these are valid, exclusively-owned FDs
519            // inherited via the process spawn protocol.
520            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}