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