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, Stdio};
9use std::time::Duration;
10
11use balise_core::error_codes::{DEV0004, DEV0005, DEV0006, DEV0007};
12use balise_core::router::HEALTH_PATHS;
13
14use crate::tools::{DOCKER, Machine};
15use crate::{Failure, project};
16
17/// How much longer than `shutdown.timeout` Docker waits before it kills the container: the
18/// supervisor's own margin, and the time it takes to stop.
19const STOP_MARGIN: Duration = Duration::from_secs(5);
20
21/// The service of `compose.yaml` that runs the app.
22const SERVICE: &str = "app";
23
24/// The project's dev environment in Docker.
25pub struct Compose {
26    root: PathBuf,
27    port: u16,
28    api_base: String,
29    /// The variables `compose.yaml` reads, derived from the configuration.
30    variables: Vec<(String, String)>,
31}
32
33impl Compose {
34    /// Checks that Docker answers, and reads what compose needs from the project at `root`.
35    pub fn new(root: &Path, machine: &dyn Machine, color: bool) -> Result<Compose, Failure> {
36        if !machine.installed(DOCKER.program) {
37            return Err(Failure::problem(DEV0004, &[], &[]));
38        }
39        if !machine.docker_running() {
40            return Err(Failure::problem(DEV0005, &[], &[]));
41        }
42        // The container does not get this machine's variables: only the files count.
43        let config = project::config(root, &[]);
44        let grace = config.shutdown_timeout + STOP_MARGIN;
45        let mut variables = vec![
46            ("BALISE_VERSION", env!("CARGO_PKG_VERSION").to_string()),
47            ("BALISE_DEV_PORT", config.port.to_string()),
48            ("BALISE_DEV_STOP_GRACE", format!("{}ms", grace.as_millis())),
49            ("BALISE_DEV_USER", user()),
50        ];
51        if color {
52            variables.push(("CLICOLOR_FORCE", "1".to_string()));
53        }
54        Ok(Compose {
55            root: root.to_path_buf(),
56            port: config.port,
57            api_base: config.api_base(),
58            variables: variables
59                .into_iter()
60                .map(|(name, value)| (name.to_string(), value))
61                .collect(),
62        })
63    }
64
65    /// Where the API answers on this machine.
66    pub fn url(&self) -> String {
67        format!("http://localhost:{}{}", self.port, self.api_base)
68    }
69
70    /// Whether the app's container runs.
71    pub fn running(&self, machine: &dyn Machine) -> Result<bool, Failure> {
72        let args = ["compose", "ps", "--status", "running", "--quiet", SERVICE];
73        let ids = machine
74            .output("docker", &args, &self.root, &self.variables)
75            .map_err(|cause| failed("ps", &cause))?;
76        Ok(!ids.trim().is_empty())
77    }
78
79    /// Whether the app answers its health check.
80    pub fn answers(&self) -> bool {
81        let Ok(mut stream) = TcpStream::connect((Ipv4Addr::LOCALHOST, self.port)) else {
82            return false;
83        };
84        let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
85        let request = format!(
86            "GET {} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
87            HEALTH_PATHS[0]
88        );
89        let mut response = String::new();
90        stream.write_all(request.as_bytes()).is_ok()
91            && stream.read_to_string(&mut response).is_ok()
92            && response.starts_with("HTTP/1.1 200")
93    }
94
95    /// Builds the image and starts the container: in the foreground, its logs relayed until
96    /// Ctrl-C stops it, or in the background.
97    pub fn up(&self, machine: &dyn Machine, detach: bool) -> Result<(), Failure> {
98        // A container of this project already holds the port: compose attaches to it.
99        if !self.running(machine)? && TcpListener::bind((Ipv4Addr::LOCALHOST, self.port)).is_err() {
100            let other = self.port.saturating_add(1).to_string();
101            return Err(Failure::problem(
102                DEV0006,
103                &[("port", &self.port.to_string())],
104                &[("other", &other)],
105            ));
106        }
107        let args: &[&str] = match detach {
108            true => &["up", "--build", "--detach", "--wait"],
109            false => &["up", "--build", "--no-log-prefix"],
110        };
111        // Docker would create the mount point of `target/` itself, as root on Linux, where
112        // `cargo` on this machine could not write any more.
113        let _ = std::fs::create_dir_all(self.root.join("target"));
114        self.run(args)
115    }
116
117    /// Stops and removes the container. The volumes stay: the next start does not compile
118    /// everything again.
119    pub fn down(&self) -> Result<(), Failure> {
120        self.run(&["down"])
121    }
122
123    /// Runs `docker compose`, its output shown. Ctrl-C and `SIGTERM` reach it through this
124    /// process, one by one: the first stops the container cleanly, a second one kills it.
125    fn run(&self, args: &[&str]) -> Result<(), Failure> {
126        super::catch_signals();
127        let mut command = Command::new("docker");
128        command
129            .arg("compose")
130            .args(args)
131            .current_dir(&self.root)
132            .envs(self.variables.iter().map(|(name, value)| (name, value)))
133            .stdin(Stdio::null());
134        // Its own group: the terminal's Ctrl-C reaches it only once, through this process.
135        #[cfg(unix)]
136        std::os::unix::process::CommandExt::process_group(&mut command, 0);
137        let mut child = command
138            .spawn()
139            .map_err(|error| failed(args[0], &error.to_string()))?;
140        let mut seen = 0;
141        let status = loop {
142            if let Some(status) = child
143                .try_wait()
144                .map_err(|e| failed(args[0], &e.to_string()))?
145            {
146                break status;
147            }
148            if let Some((received, signal)) = super::signal::received_since(seen) {
149                seen = received;
150                forward(&child, signal);
151            }
152            std::thread::sleep(Duration::from_millis(50));
153        };
154        match status.success() || super::stop_requested() {
155            true => Ok(()),
156            false => Err(failed(args[0], &super::app::describe(status))),
157        }
158    }
159}
160
161#[cfg(unix)]
162fn forward(child: &Child, signal: i32) {
163    // SAFETY: sending a signal to a process this one started and has not reaped.
164    unsafe {
165        libc::kill(child.id() as libc::pid_t, signal);
166    }
167}
168
169#[cfg(not(unix))]
170fn forward(_: &Child, _: i32) {
171    // Ctrl-C reaches every process of the console on Windows: compose already has it.
172}
173
174fn failed(command: &str, cause: &str) -> Failure {
175    Failure::problem(DEV0007, &[("command", command), ("cause", cause)], &[])
176}
177
178/// The user the container runs as: this machine's, so that what the app writes in the
179/// project (uploads, crash reports) belongs to the user.
180#[cfg(unix)]
181fn user() -> String {
182    // SAFETY: these calls only read the process's ids.
183    let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
184    format!("{uid}:{gid}")
185}
186
187#[cfg(not(unix))]
188fn user() -> String {
189    // Docker Desktop maps the files of the project to the user on its own.
190    "root".to_string()
191}