Skip to main content

run_stack/
compose.rs

1//! Building and running the `docker compose` command.
2//!
3//! Which `-f` files and which `--profile` flags is the whole of the shell's
4//! dc(): the base file from the package, the generated overlays from .run/,
5//! and a profile per capability the config switches on.
6
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10use anyhow::{bail, Context, Result};
11
12use crate::env::Env;
13use crate::workspace::Workspace;
14
15pub use crate::assets::package_dir;
16
17pub struct Compose {
18    package: PathBuf,
19    files: Vec<PathBuf>,
20    profiles: Vec<String>,
21    env: Env,
22}
23
24impl Compose {
25    pub fn new(workspace: &Workspace, env: Env) -> Result<Self> {
26        let package = package_dir()?;
27        let mut files = vec![package.join("docker-compose.yml")];
28
29        // Generated overlays, each optional, in the order the shell adds them.
30        for name in [
31            "docker-compose.packages.yml",
32            "docker-compose.extra.yml",
33            "docker-compose.resources.yml",
34            "docker-compose.override.yml",
35        ] {
36            let in_workspace = workspace.run_dir.join(name);
37            let in_package = package.join(name);
38            if in_workspace.is_file() {
39                files.push(in_workspace);
40            } else if in_package.is_file() {
41                files.push(in_package);
42            }
43        }
44
45        let mut compose = Self {
46            package,
47            files,
48            profiles: Vec::new(),
49            env,
50        };
51        compose.select_profiles();
52        Ok(compose)
53    }
54
55    /// A profile per capability, exactly as the shell decides them.
56    fn select_profiles(&mut self) {
57        let mut profiles = Vec::new();
58        for (flag, default, profile) in [
59            ("RUN_QUEUE", true, "queue"),
60            ("RUN_SCHEDULER", true, "scheduler"),
61            ("RUN_ADMIN", true, "admin"),
62            ("RUN_LANDING", true, "landing"),
63        ] {
64            if self.env.is_true(flag, default) {
65                profiles.push(profile.to_string());
66            }
67        }
68
69        // Desktop runs only when a stack is chosen for it.
70        if self.env.is_true("RUN_DESKTOP", false)
71            && !matches!(self.env.get_or("DESKTOP_STACK", "none"), "none" | "")
72        {
73            profiles.push("desktop".to_string());
74        }
75
76        match self.env.get_or("DB_ENGINE", "postgres") {
77            "none" => {}
78            "mysql" => profiles.push("mysql".to_string()),
79            _ => profiles.push("postgres".to_string()),
80        }
81        for (flag, default, profile) in [
82            ("RUN_REDIS", true, "redis"),
83            ("RUN_MAILPIT", true, "mailpit"),
84            ("RUN_MINIO", false, "minio"),
85        ] {
86            if self.env.is_true(flag, default) {
87                profiles.push(profile.to_string());
88            }
89        }
90        self.profiles = profiles;
91    }
92
93    /// The argument list, without running anything — what the tests assert on.
94    pub fn args(&self, command: &[String]) -> Vec<String> {
95        let mut args = vec![
96            "compose".to_string(),
97            "--project-directory".to_string(),
98            self.package.display().to_string(),
99        ];
100        for file in &self.files {
101            args.push("-f".into());
102            args.push(file.display().to_string());
103        }
104        for profile in &self.profiles {
105            args.push("--profile".into());
106            args.push(profile.clone());
107        }
108        args.extend(command.iter().cloned());
109        args
110    }
111
112    /// The services this file set defines, straight from docker. Authoritative,
113    /// but it needs docker running — `cached_services` answers first.
114    pub fn service_names(&self) -> Vec<String> {
115        let mut process = Command::new("docker");
116        process.args(self.args(&["config".to_string(), "--services".to_string()]));
117        for (key, value) in self.env.iter() {
118            process.env(key, value);
119        }
120        let Ok(output) = process.output() else {
121            return Vec::new();
122        };
123        if !output.status.success() {
124            return Vec::new();
125        }
126        String::from_utf8_lossy(&output.stdout)
127            .lines()
128            .map(str::trim)
129            .filter(|line| !line.is_empty())
130            .map(str::to_string)
131            .collect()
132    }
133
134    /// Run it, inheriting stdio so logs stream and prompts work.
135    pub fn run(&self, command: &[String]) -> Result<i32> {
136        let mut process = Command::new("docker");
137        process.args(self.args(command));
138        for (key, value) in self.env.iter() {
139            process.env(key, value);
140        }
141        // One-shot deps routinely outlast compose's default client timeout,
142        // and the container is SIGKILLed mid-install when it fires.
143        process.env("COMPOSE_HTTP_TIMEOUT", "3600");
144        process.env("DOCKER_CLIENT_TIMEOUT", "3600");
145
146        let status = process
147            .status()
148            .context("running docker — is Docker installed and on PATH?")?;
149        Ok(status.code().unwrap_or(1))
150    }
151}
152
153pub fn require_docker() -> Result<()> {
154    let found = Command::new("docker")
155        .args(["compose", "version"])
156        .output()
157        .map(|output| output.status.success())
158        .unwrap_or(false);
159    if !found {
160        bail!("'docker compose' (v2) is required, and docker must be running");
161    }
162    Ok(())
163}
164
165pub fn workspace_overlay(run_dir: &Path, name: &str) -> PathBuf {
166    run_dir.join(name)
167}
168
169/// The services the last `up` remembered for this workspace, from the registry
170/// the shell implementation also writes (~/.run/services.json). Reading it
171/// keeps `rst <app>` from having to start docker just to recognise a name.
172pub fn cached_services(root: &Path) -> Vec<String> {
173    let path = match std::env::var_os("RUN_SERVICES_FILE") {
174        Some(path) => PathBuf::from(path),
175        None => match std::env::var_os("HOME") {
176            Some(home) => PathBuf::from(home).join(".run/services.json"),
177            None => return Vec::new(),
178        },
179    };
180    let Ok(text) = std::fs::read_to_string(&path) else {
181        return Vec::new();
182    };
183    let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else {
184        return Vec::new();
185    };
186    value[root.to_string_lossy().as_ref()]["services"]
187        .as_array()
188        .map(|items| {
189            items
190                .iter()
191                .filter_map(|item| item.as_str().map(str::to_string))
192                .collect()
193        })
194        .unwrap_or_default()
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    fn compose_with(env_lines: &str) -> (tempfile::TempDir, Compose) {
202        let dir = tempfile::tempdir().unwrap();
203        let run_dir = dir.path().join(".run");
204        std::fs::create_dir_all(&run_dir).unwrap();
205        std::fs::write(run_dir.join(".env"), env_lines).unwrap();
206        // A stand-in package directory: only the base file has to exist.
207        let package = dir.path().join("package");
208        std::fs::create_dir_all(&package).unwrap();
209        std::fs::write(package.join("docker-compose.yml"), "services: {}\n").unwrap();
210        std::env::set_var("RUN_PACKAGE_DIR", &package);
211
212        let workspace = Workspace {
213            root: dir.path().to_path_buf(),
214            run_dir: run_dir.clone(),
215        };
216        let mut env = Env::load(&run_dir.join(".env")).unwrap();
217        env.derive(dir.path());
218        let compose = Compose::new(&workspace, env).unwrap();
219        (dir, compose)
220    }
221
222    #[test]
223    fn switches_profiles_from_the_config() {
224        let (_dir, compose) = compose_with("RUN_ADMIN=false\nRUN_MINIO=true\nDB_ENGINE=mysql\n");
225        let args = compose.args(&["ps".to_string()]);
226        let joined = args.join(" ");
227        assert!(!joined.contains("--profile admin"));
228        assert!(joined.contains("--profile minio"));
229        assert!(joined.contains("--profile mysql"));
230        assert!(!joined.contains("--profile postgres"));
231    }
232
233    #[test]
234    fn leaves_the_database_out_when_there_is_none() {
235        let (_dir, compose) = compose_with("DB_ENGINE=none\n");
236        let joined = compose.args(&[]).join(" ");
237        assert!(!joined.contains("--profile postgres"));
238        assert!(!joined.contains("--profile mysql"));
239    }
240
241    #[test]
242    fn desktop_needs_a_stack_not_just_the_flag() {
243        let (_dir, compose) = compose_with("RUN_DESKTOP=true\nDESKTOP_STACK=none\n");
244        assert!(!compose.args(&[]).join(" ").contains("--profile desktop"));
245        let (_dir, compose) = compose_with("RUN_DESKTOP=true\nDESKTOP_STACK=electron\n");
246        assert!(compose.args(&[]).join(" ").contains("--profile desktop"));
247    }
248
249    #[test]
250    fn passes_the_command_through_last() {
251        let (_dir, compose) = compose_with("");
252        let args = compose.args(&["logs".to_string(), "-f".to_string()]);
253        assert_eq!(&args[args.len() - 2..], &["logs".to_string(), "-f".to_string()]);
254    }
255}