use super::dockerfile::IMAGE_NAME_GHCR;
use super::mount::ParsedMount;
use super::profile::{INSTANCE_LABEL_KEY, active_resource_names, remap_container_name};
use super::volume::{
MOUNT_CACHE, MOUNT_CONFIG, MOUNT_PROJECTS, MOUNT_SESSION, MOUNT_SSH, MOUNT_STATE, MOUNT_USERS,
};
use super::{DockerClient, DockerError};
use bollard::models::ContainerCreateBody;
use bollard::query_parameters::{
CreateContainerOptions, RemoveContainerOptions, StartContainerOptions, StopContainerOptions,
};
use bollard::service::{
HostConfig, Mount, MountPointTypeEnum, MountTypeEnum, PortBinding, PortMap,
};
use std::collections::{HashMap, HashSet};
use tracing::debug;
pub const CONTAINER_NAME: &str = "opencode-cloud-sandbox";
pub const OPENCODE_WEB_PORT: u16 = 3000;
fn has_env_key(env: &[String], key: &str) -> bool {
let prefix = format!("{key}=");
env.iter().any(|entry| entry.starts_with(&prefix))
}
fn resolved_container_name(name: &str) -> String {
remap_container_name(name)
}
#[allow(clippy::too_many_arguments)]
pub async fn create_container(
client: &DockerClient,
name: Option<&str>,
image: Option<&str>,
opencode_web_port: Option<u16>,
env_vars: Option<Vec<String>>,
bind_address: Option<&str>,
cockpit_port: Option<u16>,
cockpit_enabled: Option<bool>,
systemd_enabled: Option<bool>,
bind_mounts: Option<Vec<ParsedMount>>,
) -> Result<String, DockerError> {
let names = active_resource_names();
let container_name = name
.map(resolved_container_name)
.unwrap_or(names.container_name);
let default_image = format!("{IMAGE_NAME_GHCR}:{}", names.image_tag);
let image_name = image.unwrap_or(&default_image);
let port = opencode_web_port.unwrap_or(OPENCODE_WEB_PORT);
let cockpit_port_val = cockpit_port.unwrap_or(9090);
let cockpit_enabled_val = cockpit_enabled.unwrap_or(false);
let systemd_enabled_val = systemd_enabled.unwrap_or(false);
debug!(
"Creating container {} from image {} with port {} and cockpit_port {} (enabled: {}, systemd: {})",
container_name,
image_name,
port,
cockpit_port_val,
cockpit_enabled_val,
systemd_enabled_val
);
if container_exists(client, &container_name).await? {
return Err(DockerError::Container(format!(
"Container '{container_name}' already exists. Remove it first with 'occ stop --remove' or use a different name."
)));
}
let image_parts: Vec<&str> = image_name.split(':').collect();
let (image_repo, image_tag) = if image_parts.len() == 2 {
(image_parts[0], image_parts[1])
} else {
(image_name, "latest")
};
if !super::image::image_exists(client, image_repo, image_tag).await? {
return Err(DockerError::Container(format!(
"Image '{image_name}' not found. Run 'occ pull' first to download the image."
)));
}
let mut bind_targets = HashSet::new();
if let Some(ref user_mounts) = bind_mounts {
for parsed in user_mounts {
bind_targets.insert(parsed.container_path.clone());
}
}
let mut mounts = Vec::new();
let mut add_volume_mount = |target: &str, source: &str| {
if bind_targets.contains(target) {
tracing::trace!(
"Skipping volume mount for {} (overridden by bind mount)",
target
);
return;
}
mounts.push(Mount {
target: Some(target.to_string()),
source: Some(source.to_string()),
typ: Some(MountTypeEnum::VOLUME),
read_only: Some(false),
..Default::default()
});
};
add_volume_mount(MOUNT_SESSION, &names.volume_session);
add_volume_mount(MOUNT_STATE, &names.volume_state);
add_volume_mount(MOUNT_CACHE, &names.volume_cache);
add_volume_mount(MOUNT_PROJECTS, &names.volume_projects);
add_volume_mount(MOUNT_CONFIG, &names.volume_config);
add_volume_mount(MOUNT_USERS, &names.volume_users);
add_volume_mount(MOUNT_SSH, &names.volume_ssh);
if let Some(ref user_mounts) = bind_mounts {
for parsed in user_mounts {
mounts.push(parsed.to_bollard_mount());
}
}
let bind_addr = bind_address.unwrap_or("127.0.0.1");
let mut port_bindings: PortMap = HashMap::new();
port_bindings.insert(
"3000/tcp".to_string(),
Some(vec![PortBinding {
host_ip: Some(bind_addr.to_string()),
host_port: Some(port.to_string()),
}]),
);
if cockpit_enabled_val {
port_bindings.insert(
"9090/tcp".to_string(),
Some(vec![PortBinding {
host_ip: Some(bind_addr.to_string()),
host_port: Some(cockpit_port_val.to_string()),
}]),
);
}
let mut exposed_ports = vec!["3000/tcp".to_string()];
if cockpit_enabled_val {
exposed_ports.push("9090/tcp".to_string());
}
let host_config = if systemd_enabled_val {
HostConfig {
mounts: Some(mounts),
port_bindings: Some(port_bindings),
auto_remove: Some(false),
cap_add: Some(vec!["SYS_ADMIN".to_string()]),
tmpfs: Some(HashMap::from([
("/run".to_string(), "exec".to_string()),
("/run/lock".to_string(), String::new()),
("/tmp".to_string(), String::new()),
])),
binds: Some(vec!["/sys/fs/cgroup:/sys/fs/cgroup:rw".to_string()]),
cgroupns_mode: Some(bollard::models::HostConfigCgroupnsModeEnum::HOST),
privileged: Some(true),
..Default::default()
}
} else {
HostConfig {
mounts: Some(mounts),
port_bindings: Some(port_bindings),
auto_remove: Some(false),
cap_add: Some(vec!["SETUID".to_string(), "SETGID".to_string()]),
..Default::default()
}
};
let mut env = env_vars.unwrap_or_default();
if !has_env_key(&env, "XDG_DATA_HOME") {
env.push("XDG_DATA_HOME=/home/opencoder/.local/share".to_string());
}
if !has_env_key(&env, "XDG_STATE_HOME") {
env.push("XDG_STATE_HOME=/home/opencoder/.local/state".to_string());
}
if !has_env_key(&env, "XDG_CONFIG_HOME") {
env.push("XDG_CONFIG_HOME=/home/opencoder/.config".to_string());
}
if !has_env_key(&env, "XDG_CACHE_HOME") {
env.push("XDG_CACHE_HOME=/home/opencoder/.cache".to_string());
}
if systemd_enabled_val && !has_env_key(&env, "USE_SYSTEMD") {
env.push("USE_SYSTEMD=1".to_string());
}
let final_env = if env.is_empty() { None } else { Some(env) };
let mut labels = HashMap::from([("managed-by".to_string(), "opencode-cloud".to_string())]);
if let Some(instance_id) = names.instance_id.as_deref() {
labels.insert(INSTANCE_LABEL_KEY.to_string(), instance_id.to_string());
}
let config = ContainerCreateBody {
image: Some(image_name.to_string()),
hostname: Some(names.hostname),
working_dir: Some("/home/opencoder/workspace".to_string()),
exposed_ports: Some(exposed_ports),
env: final_env,
labels: Some(labels),
host_config: Some(host_config),
..Default::default()
};
let options = CreateContainerOptions {
name: Some(container_name.clone()),
platform: String::new(),
};
let response = client
.inner()
.create_container(Some(options), config)
.await
.map_err(|e| {
let msg = e.to_string();
if msg.contains("port is already allocated") || msg.contains("address already in use") {
DockerError::Container(format!(
"Port {port} is already in use. Stop the service using that port or use a different port with --port."
))
} else {
DockerError::Container(format!("Failed to create container: {e}"))
}
})?;
debug!("Container created with ID: {}", response.id);
Ok(response.id)
}
pub async fn start_container(client: &DockerClient, name: &str) -> Result<(), DockerError> {
let resolved_name = resolved_container_name(name);
debug!("Starting container: {}", resolved_name);
client
.inner()
.start_container(&resolved_name, None::<StartContainerOptions>)
.await
.map_err(|e| {
DockerError::Container(format!("Failed to start container {resolved_name}: {e}"))
})?;
debug!("Container {} started", resolved_name);
Ok(())
}
pub async fn stop_container(
client: &DockerClient,
name: &str,
timeout_secs: Option<i64>,
) -> Result<(), DockerError> {
let resolved_name = resolved_container_name(name);
let timeout = timeout_secs.unwrap_or(10) as i32;
debug!(
"Stopping container {} with {}s timeout",
resolved_name, timeout
);
let options = StopContainerOptions {
signal: None,
t: Some(timeout),
};
client
.inner()
.stop_container(&resolved_name, Some(options))
.await
.map_err(|e| {
let msg = e.to_string();
if msg.contains("is not running") || msg.contains("304") {
debug!("Container {} was already stopped", resolved_name);
return DockerError::Container(format!(
"Container '{resolved_name}' is not running"
));
}
DockerError::Container(format!("Failed to stop container {resolved_name}: {e}"))
})?;
debug!("Container {} stopped", resolved_name);
Ok(())
}
pub async fn remove_container(
client: &DockerClient,
name: &str,
force: bool,
) -> Result<(), DockerError> {
let resolved_name = resolved_container_name(name);
debug!("Removing container {} (force={})", resolved_name, force);
let options = RemoveContainerOptions {
force,
v: false, link: false,
};
client
.inner()
.remove_container(&resolved_name, Some(options))
.await
.map_err(|e| {
DockerError::Container(format!("Failed to remove container {resolved_name}: {e}"))
})?;
debug!("Container {} removed", resolved_name);
Ok(())
}
pub async fn container_exists(client: &DockerClient, name: &str) -> Result<bool, DockerError> {
let resolved_name = resolved_container_name(name);
debug!("Checking if container exists: {}", resolved_name);
match client.inner().inspect_container(&resolved_name, None).await {
Ok(_) => Ok(true),
Err(bollard::errors::Error::DockerResponseServerError {
status_code: 404, ..
}) => Ok(false),
Err(e) => Err(DockerError::Container(format!(
"Failed to inspect container {resolved_name}: {e}"
))),
}
}
pub async fn container_is_running(client: &DockerClient, name: &str) -> Result<bool, DockerError> {
let resolved_name = resolved_container_name(name);
debug!("Checking if container is running: {}", resolved_name);
match client.inner().inspect_container(&resolved_name, None).await {
Ok(info) => {
let running = info.state.and_then(|s| s.running).unwrap_or(false);
Ok(running)
}
Err(bollard::errors::Error::DockerResponseServerError {
status_code: 404, ..
}) => Ok(false),
Err(e) => Err(DockerError::Container(format!(
"Failed to inspect container {resolved_name}: {e}"
))),
}
}
pub async fn container_state(client: &DockerClient, name: &str) -> Result<String, DockerError> {
let resolved_name = resolved_container_name(name);
debug!("Getting container state: {}", resolved_name);
match client.inner().inspect_container(&resolved_name, None).await {
Ok(info) => {
let state = info
.state
.and_then(|s| s.status)
.map(|s| s.to_string())
.unwrap_or_else(|| "unknown".to_string());
Ok(state)
}
Err(bollard::errors::Error::DockerResponseServerError {
status_code: 404, ..
}) => Err(DockerError::Container(format!(
"Container '{resolved_name}' not found"
))),
Err(e) => Err(DockerError::Container(format!(
"Failed to inspect container {resolved_name}: {e}"
))),
}
}
#[derive(Debug, Clone)]
pub struct ContainerPorts {
pub opencode_port: Option<u16>,
pub cockpit_port: Option<u16>,
}
#[derive(Debug, Clone)]
pub struct ContainerBindMount {
pub source: String,
pub target: String,
pub read_only: bool,
}
pub async fn get_container_ports(
client: &DockerClient,
name: &str,
) -> Result<ContainerPorts, DockerError> {
let resolved_name = resolved_container_name(name);
debug!("Getting container ports: {}", resolved_name);
let info = client
.inner()
.inspect_container(&resolved_name, None)
.await
.map_err(|e| {
DockerError::Container(format!("Failed to inspect container {resolved_name}: {e}"))
})?;
let port_bindings = info
.host_config
.and_then(|hc| hc.port_bindings)
.unwrap_or_default();
let opencode_port = port_bindings
.get("3000/tcp")
.and_then(|bindings| bindings.as_ref())
.and_then(|bindings| bindings.first())
.and_then(|binding| binding.host_port.as_ref())
.and_then(|port_str| port_str.parse::<u16>().ok());
let cockpit_port = port_bindings
.get("9090/tcp")
.and_then(|bindings| bindings.as_ref())
.and_then(|bindings| bindings.first())
.and_then(|binding| binding.host_port.as_ref())
.and_then(|port_str| port_str.parse::<u16>().ok());
Ok(ContainerPorts {
opencode_port,
cockpit_port,
})
}
pub async fn get_container_bind_mounts(
client: &DockerClient,
name: &str,
) -> Result<Vec<ContainerBindMount>, DockerError> {
let resolved_name = resolved_container_name(name);
debug!("Getting container bind mounts: {}", resolved_name);
let info = client
.inner()
.inspect_container(&resolved_name, None)
.await
.map_err(|e| {
DockerError::Container(format!("Failed to inspect container {resolved_name}: {e}"))
})?;
let mounts = info.mounts.unwrap_or_default();
let bind_mounts: Vec<ContainerBindMount> = mounts
.iter()
.filter(|m| m.typ == Some(MountPointTypeEnum::BIND))
.filter(|m| {
let target = m.destination.as_deref().unwrap_or("");
!target.starts_with("/sys/")
})
.map(|m| ContainerBindMount {
source: m.source.clone().unwrap_or_default(),
target: m.destination.clone().unwrap_or_default(),
read_only: m.rw.map(|rw| !rw).unwrap_or(false),
})
.collect();
Ok(bind_mounts)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::docker::IMAGE_TAG_DEFAULT;
#[test]
fn container_constants_are_correct() {
assert_eq!(CONTAINER_NAME, "opencode-cloud-sandbox");
assert_eq!(OPENCODE_WEB_PORT, 3000);
}
#[test]
fn default_image_format() {
let expected = format!("{IMAGE_NAME_GHCR}:{IMAGE_TAG_DEFAULT}");
assert_eq!(expected, "ghcr.io/prizz/opencode-cloud-sandbox:latest");
}
}