Skip to main content

balise_cli/dev/
docker.rs

1//! `balise dev` in Docker. The project's `compose.yaml` runs the supervisor (`balise dev
2//! --inside`) in a container; this side checks Docker and the port, gives compose what it
3//! derives from the configuration, and relays the container's logs.
4
5use 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
20/// How much longer than `shutdown.timeout` Docker waits before it kills the container: the
21/// supervisor's own margin, and the time it takes to stop.
22const STOP_MARGIN: Duration = Duration::from_secs(5);
23
24/// The service of `compose.yaml` that runs the app.
25const SERVICE: &str = "app";
26
27/// The project's dev environment in Docker.
28pub struct Compose {
29    root: PathBuf,
30    port: u16,
31    api_base: String,
32    /// The variables `compose.yaml` reads, derived from the configuration.
33    variables: Vec<(String, String)>,
34}
35
36impl Compose {
37    /// Checks that Docker answers, and reads what compose needs from the project at `root`.
38    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        // The container does not get this machine's variables: only the files count.
46        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    /// Where the API answers on this machine.
70    pub fn url(&self) -> String {
71        format!("http://localhost:{}{}", self.port, self.api_base)
72    }
73
74    /// Whether the app's container runs.
75    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    /// Whether the app answers its health check.
84    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    /// Builds the image and starts the container: in the foreground, its logs relayed until
100    /// Ctrl-C stops it, or in the background.
101    pub fn up(&self, machine: &dyn Machine, detach: bool) -> Result<(), Failure> {
102        // A container of this project already holds the port: compose attaches to it.
103        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        // Docker would create the mount point of `target/` itself, as root on Linux, where
116        // `cargo` on this machine could not write any more.
117        let _ = std::fs::create_dir_all(self.root.join("target"));
118        self.run(args)
119    }
120
121    /// Runs `balise check` in the dev container, the running one or a temporary one, and
122    /// reads what it found.
123    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    /// Runs a shell `script` with `args` in the dev container: `docker compose exec` in the
137    /// running one, `docker compose run` in a temporary one. Its standard error is shown.
138    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        // A temporary container starts from an image up to date; building it quietly keeps
149        // its progress out of the answer.
150        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    /// Stops and removes the container. The volumes stay: the next start does not compile
178    /// everything again.
179    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    /// Runs `docker compose`, its output shown. Ctrl-C and `SIGTERM` reach it through this
187    /// process, one by one: the first stops the container cleanly, a second one kills it.
188    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        // Its own group: the terminal's Ctrl-C reaches it only once, through this process.
198        #[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                // One signal at a time, so that compose reacts to each: the first stops the
213                // container cleanly, the second forces it; past that, compose does not
214                // answer, and is killed so that the terminal is given back.
215                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    // SAFETY: sending a signal to a process this one started and has not reaped.
244    unsafe {
245        libc::kill(child.id() as libc::pid_t, signal);
246    }
247}
248
249#[cfg(not(unix))]
250fn forward(_: &Child, _: i32) {
251    // Ctrl-C reaches every process of the console on Windows: compose already has it.
252}
253
254/// The block of `.env` that `balise dev` writes, between these lines.
255const 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
259/// Writes the variables `compose.yaml` reads in the project's `.env`, which compose reads on
260/// its own: `docker compose` then works by hand too, with the values of the last `balise`
261/// command. The rest of the file is the user's, and kept as it is. A failure only costs
262/// `docker compose` by hand, so it is not reported.
263fn 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                &current[..start],
276                &current[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
297/// What a command run without Rust on the machine says about it.
298pub 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
309/// The last line of standard output as JSON.
310pub 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
316/// Why a run gave nothing usable: its last line, or how it ended.
317pub 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/// The user the container runs as: this machine's, so that what the app writes in the
330/// project (uploads, crash reports) belongs to the user.
331#[cfg(unix)]
332fn user() -> String {
333    // SAFETY: these calls only read the process's ids.
334    let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
335    format!("{uid}:{gid}")
336}
337
338#[cfg(not(unix))]
339fn user() -> String {
340    // Docker Desktop maps the files of the project to the user on its own.
341    "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}