1use std::io::{Read, Write};
6use std::net::{Ipv4Addr, TcpListener, TcpStream};
7use std::path::{Path, PathBuf};
8use std::process::{Child, Command, Output, Stdio};
9use std::time::Duration;
10
11use balise_core::error_codes::{DEV0004, DEV0005, DEV0006, DEV0007};
12use balise_core::router::HEALTH_PATHS;
13
14use balise_core::json;
15
16use crate::check::Checked;
17use crate::tools::{DOCKER, Machine};
18use crate::{Failure, project};
19
20const STOP_MARGIN: Duration = Duration::from_secs(5);
23
24const SERVICE: &str = "app";
26
27pub struct Compose {
29 root: PathBuf,
30 port: u16,
31 api_base: String,
32 variables: Vec<(String, String)>,
34}
35
36impl Compose {
37 pub fn new(root: &Path, machine: &dyn Machine, color: bool) -> Result<Compose, Failure> {
39 if !machine.installed(DOCKER.program) {
40 return Err(Failure::problem(DEV0004, &[], &[]));
41 }
42 if !machine.docker_running() {
43 return Err(Failure::problem(DEV0005, &[], &[]));
44 }
45 let config = project::config(root, &[]);
47 let grace = config.shutdown_timeout + STOP_MARGIN;
48 let mut variables = vec![
49 ("BALISE_VERSION", env!("CARGO_PKG_VERSION").to_string()),
50 ("BALISE_DEV_PORT", config.port.to_string()),
51 ("BALISE_DEV_STOP_GRACE", format!("{}ms", grace.as_millis())),
52 ("BALISE_DEV_USER", user()),
53 ];
54 write_env(root, &variables);
55 if color {
56 variables.push(("CLICOLOR_FORCE", "1".to_string()));
57 }
58 Ok(Compose {
59 root: root.to_path_buf(),
60 port: config.port,
61 api_base: config.api_base(),
62 variables: variables
63 .into_iter()
64 .map(|(name, value)| (name.to_string(), value))
65 .collect(),
66 })
67 }
68
69 pub fn url(&self) -> String {
71 format!("http://localhost:{}{}", self.port, self.api_base)
72 }
73
74 pub fn running(&self, machine: &dyn Machine) -> Result<bool, Failure> {
76 let args = ["compose", "ps", "--status", "running", "--quiet", SERVICE];
77 let ids = machine
78 .output("docker", &args, &self.root, &self.variables)
79 .map_err(|cause| failed("ps", &cause))?;
80 Ok(!ids.trim().is_empty())
81 }
82
83 pub fn answers(&self) -> bool {
85 let Ok(mut stream) = TcpStream::connect((Ipv4Addr::LOCALHOST, self.port)) else {
86 return false;
87 };
88 let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
89 let request = format!(
90 "GET {} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
91 HEALTH_PATHS[0]
92 );
93 let mut response = String::new();
94 stream.write_all(request.as_bytes()).is_ok()
95 && stream.read_to_string(&mut response).is_ok()
96 && response.starts_with("HTTP/1.1 200")
97 }
98
99 pub fn up(&self, machine: &dyn Machine, detach: bool) -> Result<(), Failure> {
102 if !self.running(machine)? && TcpListener::bind((Ipv4Addr::LOCALHOST, self.port)).is_err() {
104 let other = self.port.saturating_add(1).to_string();
105 return Err(Failure::problem(
106 DEV0006,
107 &[("port", &self.port.to_string())],
108 &[("other", &other)],
109 ));
110 }
111 let args: &[&str] = match detach {
112 true => &["up", "--build", "--detach", "--wait"],
113 false => &["up", "--build", "--no-log-prefix"],
114 };
115 let _ = std::fs::create_dir_all(self.root.join("target"));
118 self.run(args)
119 }
120
121 pub fn check(&self, running: bool, ci: bool) -> Result<Checked, Failure> {
124 let mut args = vec!["--json"];
125 if ci {
126 args.push("--ci");
127 }
128 let (command, output) = self.run_in(running, r#"exec balise-cli check "$@""#, &args)?;
129 let value = last_json(&output).ok_or_else(|| failed(command, &cause(&output)))?;
130 if let Some(failure) = Failure::from_json(&value) {
131 return Err(failure);
132 }
133 Checked::from_value(&value).ok_or_else(|| failed(command, "unexpected output"))
134 }
135
136 pub fn run_in(
139 &self,
140 running: bool,
141 script: &str,
142 args: &[&str],
143 ) -> Result<(&'static str, Output), Failure> {
144 let (command, how): (&str, &[&str]) = match running {
145 true => ("exec", &["exec", "-T", SERVICE]),
146 false => ("run", &["run", "--rm", "--no-deps", "-T", SERVICE]),
147 };
148 if !running {
151 let built = Command::new("docker")
152 .args(["compose", "build", "--quiet", SERVICE])
153 .current_dir(&self.root)
154 .envs(self.variables.iter().map(|(name, value)| (name, value)))
155 .stdin(Stdio::null())
156 .stdout(Stdio::null())
157 .status()
158 .map_err(|error| failed("build", &error.to_string()))?;
159 if !built.success() {
160 return Err(failed("build", &super::app::describe(built)));
161 }
162 }
163 let output = Command::new("docker")
164 .arg("compose")
165 .args(how)
166 .args(["sh", "-c", script, "sh"])
167 .args(args)
168 .current_dir(&self.root)
169 .envs(self.variables.iter().map(|(name, value)| (name, value)))
170 .stdin(Stdio::null())
171 .stderr(Stdio::inherit())
172 .output()
173 .map_err(|error| failed(command, &error.to_string()))?;
174 Ok((command, output))
175 }
176
177 pub fn down(&self, volumes: bool) -> Result<(), Failure> {
180 match volumes {
181 true => self.run(&["down", "--volumes"]),
182 false => self.run(&["down"]),
183 }
184 }
185
186 fn run(&self, args: &[&str]) -> Result<(), Failure> {
189 super::catch_signals();
190 let mut command = Command::new("docker");
191 command
192 .arg("compose")
193 .args(args)
194 .current_dir(&self.root)
195 .envs(self.variables.iter().map(|(name, value)| (name, value)))
196 .stdin(Stdio::null());
197 #[cfg(unix)]
199 std::os::unix::process::CommandExt::process_group(&mut command, 0);
200 let mut child = command
201 .spawn()
202 .map_err(|error| failed(args[0], &error.to_string()))?;
203 let mut seen = 0;
204 let status = loop {
205 if let Some(status) = child
206 .try_wait()
207 .map_err(|e| failed(args[0], &e.to_string()))?
208 {
209 break status;
210 }
211 if let Some((received, signal)) = super::signal::received_since(seen) {
212 for count in seen + 1..=received {
216 match count {
217 1 => eprintln!("Stopping the app… Ctrl-C again to force."),
218 2 => eprintln!("Forcing… Ctrl-C again to leave without waiting."),
219 _ => {
220 let _ = child.kill();
221 let _ = child.wait();
222 eprintln!(
223 "Left without waiting for Docker: `balise dev status` says what runs, `balise dev stop` stops it."
224 );
225 return Ok(());
226 }
227 }
228 forward(&child, signal);
229 }
230 seen = received;
231 }
232 std::thread::sleep(Duration::from_millis(50));
233 };
234 match status.success() || super::stop_requested() {
235 true => Ok(()),
236 false => Err(failed(args[0], &super::app::describe(status))),
237 }
238 }
239}
240
241#[cfg(unix)]
242fn forward(child: &Child, signal: i32) {
243 unsafe {
245 libc::kill(child.id() as libc::pid_t, signal);
246 }
247}
248
249#[cfg(not(unix))]
250fn forward(_: &Child, _: i32) {
251 }
253
254const ENV_START: &str =
256 "# Written by `balise dev` from balise.toml, for `docker compose`: change balise.toml instead.";
257const ENV_END: &str = "# End of what `balise dev` writes.";
258
259fn write_env(root: &Path, variables: &[(&str, String)]) {
264 let path = root.join(".env");
265 let current = std::fs::read_to_string(&path).unwrap_or_default();
266 let mut block = format!("{ENV_START}\n");
267 for (name, value) in variables {
268 block.push_str(&format!("{name}={value}\n"));
269 }
270 block.push_str(ENV_END);
271 let updated = match (current.find(ENV_START), current.find(ENV_END)) {
272 (Some(start), Some(end)) if end > start => {
273 format!(
274 "{}{block}{}",
275 ¤t[..start],
276 ¤t[end + ENV_END.len()..]
277 )
278 }
279 _ => {
280 let mut text = current.clone();
281 if !text.is_empty() && !text.ends_with('\n') {
282 text.push('\n');
283 }
284 if !text.is_empty() {
285 text.push('\n');
286 }
287 text.push_str(&block);
288 text.push('\n');
289 text
290 }
291 };
292 if updated != current {
293 let _ = std::fs::write(&path, updated);
294 }
295}
296
297pub fn note(running: bool) -> &'static str {
299 match running {
300 true => {
301 "No Rust on this machine: ran in the running dev container, which is slower. Installing Rust (https://rustup.rs) makes it faster."
302 }
303 false => {
304 "No Rust on this machine: ran in a temporary dev container, which is slower. Installing Rust (https://rustup.rs), or keeping `balise dev` running, makes it faster."
305 }
306 }
307}
308
309pub fn last_json(output: &Output) -> Option<json::Value> {
311 let stdout = String::from_utf8_lossy(&output.stdout);
312 let last = stdout.trim().lines().last()?;
313 json::parse(last.as_bytes()).ok()
314}
315
316pub fn cause(output: &Output) -> String {
318 let stdout = String::from_utf8_lossy(&output.stdout);
319 match stdout.trim().lines().last() {
320 Some(last) => last.to_string(),
321 None => super::app::describe(output.status),
322 }
323}
324
325fn failed(command: &str, cause: &str) -> Failure {
326 Failure::problem(DEV0007, &[("command", command), ("cause", cause)], &[])
327}
328
329#[cfg(unix)]
332fn user() -> String {
333 let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
335 format!("{uid}:{gid}")
336}
337
338#[cfg(not(unix))]
339fn user() -> String {
340 "root".to_string()
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 #[test]
349 fn the_env_block_is_written_once_and_the_rest_kept() {
350 let root = std::env::temp_dir().join("balise-env-block");
351 let _ = std::fs::remove_dir_all(&root);
352 std::fs::create_dir_all(&root).unwrap();
353 std::fs::write(root.join(".env"), "SECRET=1").unwrap();
354 write_env(&root, &[("BALISE_DEV_PORT", "3000".to_string())]);
355 write_env(&root, &[("BALISE_DEV_PORT", "3001".to_string())]);
356 let text = std::fs::read_to_string(root.join(".env")).unwrap();
357 assert_eq!(
358 text,
359 format!("SECRET=1\n\n{ENV_START}\nBALISE_DEV_PORT=3001\n{ENV_END}\n")
360 );
361 }
362}