1use 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 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 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 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 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 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 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 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
169pub 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 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}