///|
/// One disposable Compose project. Serialize operations on the same handle.
struct DockerCompose {
config : String
env : String?
force_build : Bool
cwd : String?
mut project : String
mut active : Bool
mut ready : Bool
mut host : String
mut services : Array[String]
}
///|
pub(all) enum Client {
Local
}
///|
/// `config` is a Compose file path. `env` is a Compose `--env-file` path.
/// `force_build=false` keeps Compose's normal policy, rather than passing `--no-build`.
pub fn DockerCompose::DockerCompose(
config~ : String,
env? : String,
client~ : Client = Local,
force_build~ : Bool = false,
) -> DockerCompose {
println("[TestDock] Running docker compose ...")
match client {
Local =>
{
config,
env,
force_build,
cwd: @env.current_dir(),
project: "",
active: false,
ready: false,
host: "",
services: [],
}
}
}
///|
/// Wait for Compose's running/healthy state. Without a healthcheck, running is
/// sufficient. `timeout` is in milliseconds; omit it for no startup deadline.
/// Failed or timed-out startup attempts to remove the partially created project.
pub async fn DockerCompose::up(self : Self, timeout? : Int32) -> Unit {
guard !self.active else {
raise DockerError::InvalidConfig(
"call down before starting this project again",
)
}
// Run cleanup outside the timeout's cancelled task.
errdefer self.down()
if timeout is Some(timeout) {
@async.with_timeout(timeout, () => self.start())
} else {
self.start()
}
}
///|
async fn DockerCompose::start(self : Self) -> Unit {
guard self.config != "" else {
raise DockerError::InvalidConfig("config must be a Compose file path")
}
guard @env.rand(16) is Some(random) else {
raise DockerError::InvalidConfig("could not generate a unique project name")
}
self.project = "testdock-" +
[ for byte in random => byte.to_uint().to_string(radix=16) ].join("-")
// Delegate interpolation, includes and merges to Compose, then inspect its YAML.
let config = self.run(["config"])
self.services = compose_services(@utf8.decode(config.0))
self.host = docker_host()
let args = ["up", "--detach", "--wait"]
if self.force_build {
args.push("--build")
}
self.active = true
self.run(args) |> ignore
self.ready = true
}
///|
/// Remove this project's containers, networks and volumes, keeping images.
/// External volumes and bind-mounted files are not removed. Idempotent.
pub async fn DockerCompose::down(self : Self) -> Unit {
if self.active {
self.run(["down", "--volumes", "--remove-orphans"]) |> ignore
self.active = false
self.ready = false
self.services = []
}
}
///|
/// Look up one service container, including completed init containers.
/// A replicated service raises an ambiguity error instead of choosing a replica.
pub async fn DockerCompose::service(self : Self, name : String) -> Container {
guard self.ready else { raise DockerError::NotStarted }
guard self.services.contains(name) else {
raise DockerError::ServiceNotFound(name)
}
let output = self.run(["ps", "--all", "--quiet", name])
let ids = @utf8.decode(output.0)
.split("\n")
.filter_map(line => {
let line = line.trim()
if line.is_empty() {
None
} else {
Some(line.to_owned())
}
})
.to_array()
match ids {
[id] => { container_id: id, host: self.host, }
[] => raise DockerError::ServiceNotFound(name)
_ => raise DockerError::AmbiguousService(name, ids.length())
}
}
///|
async fn DockerCompose::run(
self : Self,
command : Array[String],
) -> (Bytes, Bytes) {
let args = ["compose", "--project-name", self.project, "--file", self.config]
if self.env is Some(env) {
args.push("--env-file")
args.push(env)
}
args.append(command)
let cwd = self.cwd
docker_checked(args, cwd?)
}
///|
fn compose_services(source : String) -> Array[String] raise {
let documents = @yaml.Yaml::load_from_string(source)
guard documents is [@yaml.Map(root)] &&
root.get("services") is Some(@yaml.Map(services)) else {
raise DockerError::InvalidConfig(
"expected one Compose mapping with services",
)
}
services.keys().to_array()
}