///|
/// Result of running a docker CLI command.
///
/// `stdout` and `stderr` are kept separate on purpose: `docker run -d` prints
/// the container id on stdout while image pull progress goes to stderr. Mixing
/// them (as a `2>&1` shell would) is the classic source of "container id is not
/// hex" bugs.
pub(all) struct CommandOutput {
code : Int32
stdout : String
stderr : String
}
///|
/// Escape one argument for display in error messages. This is only used for
/// humans; commands are always executed without a shell.
fn quote_arg(arg : String) -> String {
let needs_quotes = arg == "" ||
arg.contains(" ") ||
arg.contains("\t") ||
arg.contains("\"") ||
arg.contains("'") ||
arg.contains("$") ||
arg.contains("\\")
if !needs_quotes {
return arg
}
let mut out = "\""
for c in arg.to_array() {
if c == '"' || c == '\\' || c == '$' {
out = out + "\\"
}
out = out + c.to_string()
}
out + "\""
}
///|
/// Render a command line for error messages.
pub fn render_command(program : String, args : ArrayView[String]) -> String {
let parts : Array[String] = [program]
let mut redact_next = false
for arg in args {
if redact_next {
parts.push(quote_arg(redact_env_arg(arg)))
redact_next = false
} else {
parts.push(quote_arg(arg))
}
if arg == "--env" || arg == "-e" {
redact_next = true
}
}
parts.join(" ")
}
///|
/// Hide the value of `NAME=value` so credentials passed with `--env` do not end
/// up in error messages and test logs.
fn redact_env_arg(arg : String) -> String {
let parts = arg.split("=").to_array()
if parts.length() >= 2 {
parts[0].to_owned() + "=***"
} else {
"***"
}
}
///|
/// Run the `docker` CLI. Arguments are passed directly to the process, never
/// through a shell, so spaces and quotes in env values and commands are safe.
///
/// Returns the exit code and both output streams; a non-zero exit code is not
/// turned into an error here because some callers (wait strategies, `exec`)
/// treat it as data.
pub async fn docker_command(
args : ArrayView[String],
) -> CommandOutput raise TestContainerError {
let (code, stdout, stderr) = @process.collect_output("docker", args) catch {
e => raise DockerNotAvailable(e.to_string())
}
let stdout = stdout.text() catch {
e => raise UnexpectedOutput("cannot read docker stdout: \{e.to_string()}")
}
let stderr = stderr.text() catch {
e => raise UnexpectedOutput("cannot read docker stderr: \{e.to_string()}")
}
// A missing docker binary raises from `collect_output` and is mapped to
// `DockerNotAvailable` above. Do not try to detect it from exit codes: a
// `docker exec` whose command is missing inside the container also exits with
// 127 and must stay a normal command failure.
let output : CommandOutput = { code, stdout, stderr, }
output
}
///|
/// Run a docker command and fail with `DockerCommandFailed` on a non-zero exit
/// code. Returns stdout.
pub async fn docker(
args : ArrayView[String],
) -> String raise TestContainerError {
let output = docker_command(args)
if output.code != 0 {
raise DockerCommandFailed(
command=render_command("docker", args),
code=output.code,
stderr=command_error_text(output),
)
}
output.stdout
}
///|
/// Pick the most useful stream for an error message. docker is inconsistent:
/// most failures go to stderr, but some (`docker port` on an unknown container
/// on old versions, for example) go to stdout.
pub fn command_error_text(output : CommandOutput) -> String {
let stderr = output.stderr.trim().to_owned()
let stdout = output.stdout.trim().to_owned()
if stderr != "" {
stderr
} else if stdout != "" {
stdout
} else {
"(no output)"
}
}
///|
/// `docker inspect` for a container, parsed as JSON.
pub async fn inspect_container(id : String) -> Json raise TestContainerError {
let stdout = docker(["inspect", "--type", "container", id])
@json.parse(stdout) catch {
e =>
raise UnexpectedOutput(
"cannot parse `docker inspect \{short_id(id)}` output: \{e.to_string()}",
)
}
}
///|
/// `docker inspect` for an image. Returns `false` when the image is not present
/// locally.
pub async fn image_exists(image : String) -> Bool raise TestContainerError {
let output = docker_command(["image", "inspect", "--format", "{{.Id}}", image])
if output.code == 0 {
return true
}
let text = command_error_text(output)
if text.contains("No such image") || text.contains("No such object") {
return false
}
// A broken docker installation should not be reported as "image missing".
raise DockerCommandFailed(
command=render_command("docker", ["image", "inspect", image]),
code=output.code,
stderr=text,
)
}
///|
/// Pull `image`, unless it is already present locally and the request allows
/// reusing it.
pub async fn pull_image(image : String) -> Unit raise TestContainerError {
let output = docker_command(["pull", image])
if output.code != 0 {
raise ImagePullFailed(image~, stderr=command_error_text(output))
}
}