Skip to main content

supercov_engine/
docker_servers.rs

1//! A server a browser suite starts with `docker compose`, built from the copy
2//! the suite runs in. The image holds the instrumented code, but nothing else
3//! crossed into the container: not the environment that tells the server's
4//! runtime where to write, and not the directory it writes to. A `docker`
5//! launcher on the suite's PATH hands each service built from the copy that
6//! environment and Supercov's directory, mounted at the path it has outside,
7//! and records the project so the run can stop it, and read what it wrote,
8//! once the command ends.
9//!
10//! Anything it does not recognise, or cannot read, runs as written.
11
12use std::{
13    ffi::{OsStr, OsString},
14    fs,
15    io::Write,
16    path::{Path, PathBuf},
17    process::{Command, Stdio},
18};
19
20use serde_json::Value;
21
22/// The hidden command a launcher runs: `supercov __docker <docker> <args>`.
23pub const DOCKER_COMMAND: &str = "__docker";
24/// Where projects the launcher started are recorded, one JSON line each.
25pub const STATE_VARIABLE: &str = "SUPERCOV_DOCKER_STATE";
26/// The directory mounted into each service, at the same path.
27pub const MOUNT_VARIABLE: &str = "SUPERCOV_DOCKER_MOUNT";
28/// The copy: a service whose build context is in it was built from it.
29pub const COPY_VARIABLE: &str = "SUPERCOV_DOCKER_COPY";
30/// Names, comma-separated, of the variables each such service is given.
31pub const ENVIRONMENT_VARIABLE: &str = "SUPERCOV_DOCKER_ENVIRONMENT";
32/// Set in each such service: its runtime runs in an image of the copy.
33pub const CONTAINER_VARIABLE: &str = "SUPERCOV_CONTAINER";
34
35/// Compose options that take a value, before its subcommand.
36const VALUED_OPTIONS: &[&str] = &[
37    "-f",
38    "--file",
39    "-p",
40    "--project-name",
41    "--profile",
42    "--project-directory",
43    "--env-file",
44    "--ansi",
45    "--progress",
46    "--parallel",
47];
48
49/// Subcommands that create containers, the only moment a service's
50/// environment and mounts can be given.
51const CREATING: &[&str] = &["up", "run", "create"];
52
53/// What a `docker compose` command line holds: the options before
54/// `compose`, the compose options, the subcommand and what follows it.
55#[derive(Debug, PartialEq)]
56struct ComposeCommand {
57    docker_options: Vec<OsString>,
58    compose_options: Vec<OsString>,
59    subcommand: OsString,
60    rest: Vec<OsString>,
61}
62
63/// Split a command line given to `docker` (or, when `compose_binary`, to
64/// `docker-compose`). None for anything but compose.
65fn parse(arguments: &[OsString], compose_binary: bool) -> Option<ComposeCommand> {
66    let mut index = 0;
67    let mut docker_options = Vec::new();
68    if !compose_binary {
69        // Docker's own options before the command: `--context x`, `-H x`.
70        while index < arguments.len() {
71            let argument = arguments[index].to_str()?;
72            if argument == "compose" {
73                index += 1;
74                break;
75            }
76            if !argument.starts_with('-') {
77                return None;
78            }
79            docker_options.push(arguments[index].clone());
80            if matches!(
81                argument,
82                "--context" | "-c" | "-H" | "--host" | "--config" | "-l" | "--log-level"
83            ) {
84                index += 1;
85                docker_options.push(arguments.get(index)?.clone());
86            }
87            index += 1;
88        }
89        if index == 0 || arguments.get(index - 1).and_then(|word| word.to_str()) != Some("compose")
90        {
91            return None;
92        }
93    }
94    let mut compose_options = Vec::new();
95    while index < arguments.len() {
96        let argument = arguments[index].to_str()?;
97        if !argument.starts_with('-') {
98            return Some(ComposeCommand {
99                docker_options,
100                compose_options,
101                subcommand: arguments[index].clone(),
102                rest: arguments[index + 1..].to_vec(),
103            });
104        }
105        compose_options.push(arguments[index].clone());
106        if VALUED_OPTIONS.contains(&argument) {
107            index += 1;
108            compose_options.push(arguments.get(index)?.clone());
109        }
110        index += 1;
111    }
112    None
113}
114
115/// The project's configuration as compose resolves it, with each service
116/// built from `copy` given `environment` and `mount`, and the names of those
117/// services. None when no service is, or the configuration is not what
118/// compose prints.
119fn instrument(
120    mut config: Value,
121    copy: &Path,
122    mount: &Path,
123    environment: &[(String, String)],
124) -> Option<(String, Value, Vec<String>)> {
125    let name = config.get("name")?.as_str()?.to_owned();
126    let services = config.get_mut("services")?.as_object_mut()?;
127    let mut changed = Vec::new();
128    for (service_name, service) in services.iter_mut() {
129        let Some(context) = service
130            .get("build")
131            .and_then(|build| build.get("context"))
132            .and_then(Value::as_str)
133        else {
134            continue;
135        };
136        if !Path::new(context).starts_with(copy) {
137            continue;
138        }
139        let service = service.as_object_mut()?;
140        let variables = service
141            .entry("environment")
142            .or_insert_with(|| Value::Object(Default::default()));
143        let variables = variables.as_object_mut()?;
144        for (key, value) in environment {
145            variables.insert(key.clone(), Value::String(value.clone()));
146        }
147        // Tells a runtime it runs in an image of the copy, where the copy's
148        // files are at a path of the image's own (`/app`).
149        variables.insert(CONTAINER_VARIABLE.into(), Value::String("1".into()));
150        // Python writes the bytecode of Supercov's runtime beside it, in the
151        // mounted directory, as the container's root: the run could not
152        // remove it. A service's own setting stays.
153        variables
154            .entry("PYTHONPYCACHEPREFIX")
155            .or_insert_with(|| Value::String("/tmp/supercov-pycache".into()));
156        let volumes = service
157            .entry("volumes")
158            .or_insert_with(|| Value::Array(Vec::new()));
159        volumes.as_array_mut()?.push(serde_json::json!({
160            "type": "bind",
161            "source": mount.display().to_string(),
162            "target": mount.display().to_string(),
163        }));
164        changed.push(service_name.clone());
165    }
166    (!changed.is_empty()).then_some((name, config, changed))
167}
168
169/// What a variable a runtime needs is in a container whose image sets it
170/// too: the runtime's part of ours first, then the image's. Ours was made
171/// for the run's own processes and carries the host's value after it.
172fn joined_with_image(name: &str, ours: &str, image: Option<&str>) -> String {
173    let (runtime, separator) = match name {
174        // `<runtime>:<host's PYTHONPATH>`.
175        "PYTHONPATH" => (ours.split(':').next().unwrap_or(ours).to_owned(), ":"),
176        // `-r<runtime> <host's RUBYOPT>`.
177        _ => (
178            ours.split_whitespace()
179                .filter(|option| option.contains("supercov"))
180                .collect::<Vec<_>>()
181                .join(" "),
182            " ",
183        ),
184    };
185    match image.filter(|value| !value.is_empty()) {
186        Some(image) => format!("{runtime}{separator}{image}"),
187        None => runtime,
188    }
189}
190
191/// A variable the image of `service` sets, once built.
192fn image_variable(
193    docker: &Path,
194    docker_options: &[OsString],
195    image: &str,
196    name: &str,
197) -> Option<String> {
198    let output = Command::new(docker)
199        .args(docker_options)
200        .args([
201            "image",
202            "inspect",
203            "--format",
204            "{{json .Config.Env}}",
205            image,
206        ])
207        .stdin(Stdio::null())
208        .stderr(Stdio::null())
209        .output()
210        .ok()?;
211    serde_json::from_slice::<Vec<String>>(&output.stdout)
212        .ok()?
213        .into_iter()
214        .find_map(|entry| entry.strip_prefix(&format!("{name}=")).map(str::to_owned))
215}
216
217/// Run `docker` as the launcher was asked to, giving services built from the
218/// copy what their runtime needs. Returns the exit code.
219pub fn run(docker: &Path, arguments: &[OsString]) -> i32 {
220    let compose_binary = docker
221        .file_stem()
222        .is_some_and(|stem| stem == OsStr::new("docker-compose"));
223    let rewritten = parse(arguments, compose_binary)
224        .filter(|command| {
225            command
226                .subcommand
227                .to_str()
228                .is_some_and(|subcommand| CREATING.contains(&subcommand))
229        })
230        .and_then(|command| rewrite(docker, compose_binary, &command));
231    run_docker(docker, rewritten.as_deref().unwrap_or(arguments))
232}
233
234/// The command line that runs `command` on the instrumented configuration,
235/// recorded for the run to stop. None to run it as written.
236fn rewrite(docker: &Path, compose_binary: bool, command: &ComposeCommand) -> Option<Vec<OsString>> {
237    let state = PathBuf::from(std::env::var_os(STATE_VARIABLE)?);
238    let mount = PathBuf::from(std::env::var_os(MOUNT_VARIABLE)?);
239    let copy = PathBuf::from(std::env::var_os(COPY_VARIABLE)?);
240    let environment = std::env::var(ENVIRONMENT_VARIABLE)
241        .ok()?
242        .split(',')
243        .filter(|name| !name.is_empty())
244        .filter_map(|name| Some((name.to_owned(), std::env::var(name).ok()?)))
245        .collect::<Vec<_>>();
246    let prefix = |options: &[OsString]| {
247        let mut line = Vec::new();
248        if !compose_binary {
249            line.extend(command.docker_options.iter().cloned());
250            line.push(OsString::from("compose"));
251        }
252        line.extend(options.iter().cloned());
253        line
254    };
255    let mut config_line = prefix(&command.compose_options);
256    config_line.extend(["config", "--format", "json"].map(OsString::from));
257    let output = Command::new(docker)
258        .args(&config_line)
259        .stdin(Stdio::null())
260        .stderr(Stdio::null())
261        .output()
262        .ok()?;
263    if !output.status.success() {
264        return None;
265    }
266    let config = serde_json::from_slice::<Value>(&output.stdout).ok()?;
267    let (name, mut config, services) = instrument(config, &copy, &mount, &environment)?;
268    fs::create_dir_all(&state).ok()?;
269    let file = state.join(format!("{}.compose.json", sanitized(&name)));
270    fs::write(&file, serde_json::to_vec_pretty(&config).ok()?).ok()?;
271    // The resolved file holds every service of every file the command named,
272    // so it replaces them; the project keeps its name and its directory.
273    let mut options = vec![
274        OsString::from("-f"),
275        file.clone().into_os_string(),
276        OsString::from("-p"),
277        OsString::from(&name),
278    ];
279    let mut kept = command.compose_options.iter();
280    while let Some(option) = kept.next() {
281        match option.to_str() {
282            Some("-f" | "--file" | "-p" | "--project-name") => {
283                kept.next();
284            }
285            Some("--project-directory" | "--env-file" | "--profile") => {
286                options.push(option.clone());
287                options.extend(kept.next().cloned());
288            }
289            _ => options.push(option.clone()),
290        }
291    }
292    // Built from this copy before anything starts: `up` alone reuses an
293    // image that exists, which may hold the project as it was at another
294    // run, or no probes at all.
295    let mut build = prefix(&options);
296    build.push(OsString::from("build"));
297    build.extend(services.iter().map(OsString::from));
298    let _ = Command::new(docker)
299        .args(&build)
300        .stdin(Stdio::null())
301        .status();
302    // A Python or Ruby runtime goes before what the image itself sets.
303    let mut rewritten = false;
304    for service in &services {
305        let image = config["services"][service]["image"]
306            .as_str()
307            .map_or_else(|| format!("{name}-{service}"), str::to_owned);
308        for variable in ["PYTHONPATH", "RUBYOPT"] {
309            let Some(ours) = config["services"][service]["environment"][variable].as_str() else {
310                continue;
311            };
312            let value = joined_with_image(
313                variable,
314                ours,
315                image_variable(docker, &command.docker_options, &image, variable).as_deref(),
316            );
317            config["services"][service]["environment"][variable] = Value::String(value);
318            rewritten = true;
319        }
320    }
321    if rewritten {
322        fs::write(&file, serde_json::to_vec_pretty(&config).ok()?).ok()?;
323    }
324    let mut record = fs::OpenOptions::new()
325        .create(true)
326        .append(true)
327        .open(state.join("projects.jsonl"))
328        .ok()?;
329    let line = serde_json::json!({
330        "docker": docker.display().to_string(),
331        "composeBinary": compose_binary,
332        "dockerOptions": command.docker_options.iter().map(|option| option.to_string_lossy()).collect::<Vec<_>>(),
333        "file": file.display().to_string(),
334        "project": name,
335    });
336    writeln!(record, "{line}").ok()?;
337    let mut line = prefix(&options);
338    line.push(command.subcommand.clone());
339    line.extend(command.rest.iter().cloned());
340    Some(line)
341}
342
343fn sanitized(name: &str) -> String {
344    name.chars()
345        .map(|c| {
346            if c.is_ascii_alphanumeric() || c == '-' {
347                c
348            } else {
349                '_'
350            }
351        })
352        .collect()
353}
354
355#[cfg(unix)]
356fn run_docker(docker: &Path, arguments: &[OsString]) -> i32 {
357    use std::os::unix::process::CommandExt;
358    // The launcher becomes docker: a signal the suite sends its server
359    // reaches compose as it would without Supercov.
360    let error = Command::new(docker).args(arguments).exec();
361    eprintln!("[supercov] could not run {}: {error}", docker.display());
362    127
363}
364
365#[cfg(not(unix))]
366fn run_docker(docker: &Path, arguments: &[OsString]) -> i32 {
367    Command::new(docker)
368        .args(arguments)
369        .status()
370        .map_or(127, |status| status.code().unwrap_or(1))
371}
372
373/// Stop what the launcher started, so each server writes its last window
374/// before the run reads them. Stopped, never removed: the containers are
375/// the suite's. Quietly does nothing when nothing was started.
376pub fn stop_started(state: &Path) {
377    let Ok(text) = fs::read_to_string(state.join("projects.jsonl")) else {
378        return;
379    };
380    let mut stopped = std::collections::BTreeSet::new();
381    for line in text.lines() {
382        let Ok(project) = serde_json::from_str::<Value>(line) else {
383            continue;
384        };
385        let (Some(docker), Some(file), Some(name)) = (
386            project["docker"].as_str(),
387            project["file"].as_str(),
388            project["project"].as_str(),
389        ) else {
390            continue;
391        };
392        if !stopped.insert(name.to_owned()) {
393            continue;
394        }
395        let mut line = Vec::<String>::new();
396        if project["composeBinary"].as_bool() != Some(true) {
397            line.extend(
398                project["dockerOptions"]
399                    .as_array()
400                    .into_iter()
401                    .flatten()
402                    .filter_map(|option| option.as_str().map(str::to_owned)),
403            );
404            line.push("compose".into());
405        }
406        line.extend(["-f", file, "-p", name, "stop", "-t", "10"].map(str::to_owned));
407        let _ = Command::new(docker)
408            .args(&line)
409            .stdin(Stdio::null())
410            .stdout(Stdio::null())
411            .stderr(Stdio::null())
412            .status();
413    }
414}
415
416/// The launchers, `docker` and `docker-compose` for those on PATH, in
417/// `directory`, or None when neither is.
418pub fn write_launchers(directory: &Path, path: &OsStr) -> Option<()> {
419    let binary = crate::source_tools::launching_binary()?;
420    let mut found = false;
421    for name in ["docker", "docker-compose"] {
422        let Some(real) = std::env::split_paths(path)
423            .map(|entry| entry.join(name))
424            .find(|candidate| candidate.is_file())
425        else {
426            continue;
427        };
428        // A launcher of ours found on PATH is not the real one.
429        if real.starts_with(directory) {
430            continue;
431        }
432        fs::create_dir_all(directory).ok()?;
433        let quoted = |path: &Path| format!("'{}'", path.to_string_lossy().replace('\'', "'\\''"));
434        crate::source_tools::write_launcher(
435            &directory.join(name),
436            &format!(
437                "#!/bin/sh\n# Gives a server compose builds from Supercov's copy what its coverage needs; see `supercov --help`.\nexec {} {DOCKER_COMMAND} {} \"$@\"\n",
438                quoted(&binary),
439                quoted(&real),
440            ),
441        )
442        .ok()?;
443        found = true;
444    }
445    found.then_some(())
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    fn words(line: &str) -> Vec<OsString> {
453        line.split_whitespace().map(OsString::from).collect()
454    }
455
456    #[test]
457    fn a_compose_command_is_split_where_compose_reads_it() {
458        let command = parse(
459            &words("--context ci compose -f a.yml -p demo --profile e2e up -d web"),
460            false,
461        )
462        .unwrap();
463        assert_eq!(command.docker_options, words("--context ci"));
464        assert_eq!(
465            command.compose_options,
466            words("-f a.yml -p demo --profile e2e")
467        );
468        assert_eq!(command.subcommand, "up");
469        assert_eq!(command.rest, words("-d web"));
470        let binary = parse(&words("up --build"), true).unwrap();
471        assert!(binary.docker_options.is_empty() && binary.compose_options.is_empty());
472        assert_eq!(binary.subcommand, "up");
473        assert!(parse(&words("run --rm alpine true"), false).is_none());
474        assert!(parse(&words("build ."), false).is_none());
475    }
476
477    #[test]
478    fn only_services_built_from_the_copy_are_given_the_runtime() {
479        let config = serde_json::json!({
480            "name": "shop",
481            "services": {
482                "api": { "build": { "context": "/work/.supercov/workspaces/workspace/shop/api" }, "environment": { "PORT": "8080" } },
483                "db": { "image": "postgres:17" },
484                "other": { "build": { "context": "/elsewhere" } },
485            }
486        });
487        let (name, config, services) = instrument(
488            config,
489            Path::new("/work/.supercov/workspaces/workspace/shop"),
490            Path::new("/work/.supercov"),
491            &[(
492                "SUPERCOV_GO_SERVER_EVIDENCE_DIR".into(),
493                "/work/.supercov/work/run/go/evidence".into(),
494            )],
495        )
496        .unwrap();
497        assert_eq!(name, "shop");
498        assert_eq!(services, ["api"]);
499        assert_eq!(
500            config["services"]["api"]["environment"]["SUPERCOV_CONTAINER"],
501            "1"
502        );
503        let api = &config["services"]["api"];
504        assert_eq!(api["environment"]["PORT"], "8080");
505        assert_eq!(
506            api["environment"]["SUPERCOV_GO_SERVER_EVIDENCE_DIR"],
507            "/work/.supercov/work/run/go/evidence"
508        );
509        assert_eq!(api["volumes"][0]["source"], "/work/.supercov");
510        assert_eq!(api["volumes"][0]["target"], "/work/.supercov");
511        assert!(config["services"]["db"].get("volumes").is_none());
512        assert!(config["services"]["other"].get("environment").is_none());
513        // A runtime goes before what the image sets.
514        assert_eq!(
515            joined_with_image("PYTHONPATH", "/r/runtime:/host/lib", Some("/app")),
516            "/r/runtime:/app"
517        );
518        assert_eq!(
519            joined_with_image("PYTHONPATH", "/r/runtime", None),
520            "/r/runtime"
521        );
522        assert_eq!(
523            joined_with_image(
524                "RUBYOPT",
525                "-r/r/supercov_runtime.rb -W0",
526                Some("-rbundler/setup")
527            ),
528            "-r/r/supercov_runtime.rb -rbundler/setup"
529        );
530        // No service built from the copy: the command runs as written.
531        assert!(
532            instrument(
533                serde_json::json!({ "name": "x", "services": { "db": { "image": "postgres" } } }),
534                Path::new("/copy"),
535                Path::new("/m"),
536                &[]
537            )
538            .is_none()
539        );
540    }
541}