// Container lifecycle management
///|
/// Check if a string is a valid hexadecimal string (for container ID validation)
fn is_hex_string(s : String) -> Bool {
if s.length() < 12 {
// Container IDs are at least 12 characters (short form)
return false
}
for i = 0; i < s.length(); i = i + 1 {
let c = s[i]
if not(
(c >= '0' && c <= '9') ||
(c >= 'a' && c <= 'f') ||
(c >= 'A' && c <= 'F'),
) {
return false
}
}
true
}
///|
/// Start a container based on the image configuration
pub async fn ContainerImage::start(
self : ContainerImage,
) -> Result[Container, TestContainerError] noraise {
// Build docker run command arguments
let args : Array[String] = ["run", "-d"]
// Add port mappings (--expose + -P for random host ports)
for i = 0; i < self.exposed_ports.length(); i = i + 1 {
args.push("--expose")
args.push(self.exposed_ports[i].to_string())
}
if self.exposed_ports.length() > 0 {
args.push("-P")
}
// Add volume mounts
for i = 0; i < self.volumes.length(); i = i + 1 {
let volume = self.volumes[i]
args.push("-v")
if volume.read_only {
args.push("\{volume.host_path}:\{volume.container_path}:ro")
} else {
args.push("\{volume.host_path}:\{volume.container_path}")
}
}
// Add environment variables
for i = 0; i < self.env_vars.length(); i = i + 1 {
let (key, value) = self.env_vars[i]
args.push("-e")
args.push("\{key}=\{value}")
}
// Add image name and tag
args.push("\{self.name}:\{self.tag}")
// Add command if specified
for i = 0; i < self.cmd.length(); i = i + 1 {
args.push(self.cmd[i])
}
let exec_result : Result[String, TestContainerError] = exec_docker(args)
let container_id = match exec_result {
Err(e) =>
return Err(
DockerCommandFailed(
"Failed to start container for image \{self.name}:\n\{e.to_string()}",
),
)
Ok(output) => {
// Extract container ID from output
// docker run -d may output multiple lines including pull progress
// Container ID is a hexadecimal string (at least 12 chars, usually 64)
let lines = output.split("\n").to_array()
let mut container_id = ""
// First, try to find a line that looks like a container ID
for i = 0; i < lines.length(); i = i + 1 {
let line_view = lines[i].trim()
let line = "\{line_view}"
if is_hex_string(line) {
container_id = line
break
}
}
// Fallback: use last non-empty line
if container_id == "" {
for i = lines.length() - 1; i >= 0; i = i - 1 {
let line_view = lines[i].trim()
let line = "\{line_view}"
if line.length() > 0 {
container_id = line
break
}
}
}
container_id
}
}
let container = { id: container_id, image: self, port_mappings: [] }
guard container.update_port_mappings() is Ok(_) else {
return Err(
DockerCommandFailed(
"Failed to get port mappings for container \{container_id}",
),
)
}
println(
"Started container \{container_id} for image \{self.name}:\{self.tag}",
)
guard self.wait_strategy is Some(strategy) else { return Ok(container) }
match wait_for_container(container, strategy, self.wait_timeout) {
Ok(_) => ()
Err(wait_error) => {
let _ = container.cleanup() catch {
_ => {
println(
"Warning: Failed to cleanup container \{container_id} after wait failure",
)
return Err(wait_error)
}
}
return Err(wait_error)
}
}
Ok(container)
}
///|
/// Update port mappings by querying Docker
async fn Container::update_port_mappings(
self : Container,
) -> Result[Unit, TestContainerError] noraise {
if self.image.exposed_ports.length() == 0 {
return Ok(())
}
// Get port mappings using docker port command
for i = 0; i < self.image.exposed_ports.length(); i = i + 1 {
let container_port = self.image.exposed_ports[i]
let args = ["port", self.id, container_port.to_string()]
// Retry logic for temporary port mapping errors
let max_retries = 5
let mut attempt = 0
let mut last_error : TestContainerError? = None
let mut success = false
while attempt < max_retries && not(success) {
attempt = attempt + 1
match exec_docker(args) {
Ok(output) =>
// Parse output like "0.0.0.0:32768"
// Format: [host]:port or 0.0.0.0:port
match parse_port_mapping(output) {
Some(host_port) => {
self.port_mappings.push((container_port, host_port))
success = true
}
None =>
last_error = Some(
PortMappingFailed("Failed to parse port mapping: \{output}"),
)
}
Err(e) => {
// Check if this is a temporary error (404 or not found)
let error_msg = e.to_string()
let is_temporary = error_msg.contains("404") ||
error_msg.contains("not found")
if is_temporary && attempt < max_retries {
// Retry after 1 second
println(
"Warning: Port mapping failed (attempt \{attempt}/\{max_retries}): \{error_msg}",
)
@async.sleep(1000) catch { // 1 second
_ =>
return Err(
DockerCommandFailed("Sleep failed during port mapping retry"),
)
} // 1 second in nanoseconds
last_error = Some(e)
} else {
// Permanent error or max retries reached
return Err(e)
}
}
}
}
// If we exhausted retries without success, return the last error
if not(success) {
match last_error {
Some(e) => return Err(e)
None =>
return Err(
PortMappingFailed(
"Failed to get port mapping after \{max_retries} retries",
),
)
}
}
}
Ok(())
}
///|
/// Get the host port mapped to a container port
pub fn Container::get_host_port(self : Container, container_port : Int) -> Int? {
for i = 0; i < self.port_mappings.length(); i = i + 1 {
let (cport, hport) = self.port_mappings[i]
if cport == container_port {
return Some(hport)
}
}
None
}
///|
/// Get the container ID
pub fn Container::get_id(self : Container) -> String {
self.id
}
///|
/// Stop the container
pub async fn Container::stop(
self : Container,
) -> Result[Unit, TestContainerError] {
let args = ["stop", self.id]
exec_docker(args).map(_ => ())
}
///|
/// Remove the container
pub async fn Container::remove(
self : Container,
) -> Result[Unit, TestContainerError] {
let args = ["rm", self.id]
exec_docker(args).map(_ => ())
}
///|
/// Stop and remove the container (cleanup)
pub async fn Container::cleanup(
self : Container,
) -> Result[Unit, TestContainerError] {
// Try to stop first, then remove
// Ignore errors from stop (container might already be stopped)
let _ = self.stop()
self.remove()
}