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.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 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 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 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 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 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 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
170pub 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 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}