Skip to main content

opencode_cloud_core/docker/
container.rs

1//! Docker container lifecycle management
2//!
3//! This module provides functions to create, start, stop, and remove
4//! Docker containers for the opencode-cloud service.
5
6use super::dockerfile::{IMAGE_NAME_GHCR, IMAGE_TAG_DEFAULT};
7use super::volume::{
8    MOUNT_CONFIG, MOUNT_PROJECTS, MOUNT_SESSION, VOLUME_CONFIG, VOLUME_PROJECTS, VOLUME_SESSION,
9};
10use super::{DockerClient, DockerError};
11use bollard::container::{
12    Config, CreateContainerOptions, RemoveContainerOptions, StartContainerOptions,
13    StopContainerOptions,
14};
15use bollard::service::{HostConfig, Mount, MountTypeEnum, PortBinding, PortMap};
16use std::collections::HashMap;
17use tracing::debug;
18
19/// Default container name
20pub const CONTAINER_NAME: &str = "opencode-cloud";
21
22/// Default port for opencode web UI
23pub const OPENCODE_WEB_PORT: u16 = 3000;
24
25/// Create the opencode container with volume mounts
26///
27/// Does not start the container - use start_container after creation.
28/// Returns the container ID on success.
29///
30/// # Arguments
31/// * `client` - Docker client
32/// * `name` - Container name (defaults to CONTAINER_NAME)
33/// * `image` - Image to use (defaults to IMAGE_NAME_GHCR:IMAGE_TAG_DEFAULT)
34/// * `opencode_web_port` - Port to bind on host for opencode web UI (defaults to OPENCODE_WEB_PORT)
35/// * `env_vars` - Additional environment variables (optional)
36/// * `bind_address` - IP address to bind on host (defaults to "127.0.0.1")
37/// * `cockpit_port` - Port to bind on host for Cockpit (defaults to 9090)
38/// * `cockpit_enabled` - Whether to enable Cockpit port mapping (defaults to true)
39#[allow(clippy::too_many_arguments)]
40pub async fn create_container(
41    client: &DockerClient,
42    name: Option<&str>,
43    image: Option<&str>,
44    opencode_web_port: Option<u16>,
45    env_vars: Option<Vec<String>>,
46    bind_address: Option<&str>,
47    cockpit_port: Option<u16>,
48    cockpit_enabled: Option<bool>,
49) -> Result<String, DockerError> {
50    let container_name = name.unwrap_or(CONTAINER_NAME);
51    let default_image = format!("{IMAGE_NAME_GHCR}:{IMAGE_TAG_DEFAULT}");
52    let image_name = image.unwrap_or(&default_image);
53    let port = opencode_web_port.unwrap_or(OPENCODE_WEB_PORT);
54    let cockpit_port_val = cockpit_port.unwrap_or(9090);
55    let cockpit_enabled_val = cockpit_enabled.unwrap_or(true);
56
57    debug!(
58        "Creating container {} from image {} with port {} and cockpit_port {} (enabled: {})",
59        container_name, image_name, port, cockpit_port_val, cockpit_enabled_val
60    );
61
62    // Check if container already exists
63    if container_exists(client, container_name).await? {
64        return Err(DockerError::Container(format!(
65            "Container '{container_name}' already exists. Remove it first with 'occ stop --remove' or use a different name."
66        )));
67    }
68
69    // Check if image exists
70    let image_parts: Vec<&str> = image_name.split(':').collect();
71    let (image_repo, image_tag) = if image_parts.len() == 2 {
72        (image_parts[0], image_parts[1])
73    } else {
74        (image_name, "latest")
75    };
76
77    if !super::image::image_exists(client, image_repo, image_tag).await? {
78        return Err(DockerError::Container(format!(
79            "Image '{image_name}' not found. Run 'occ pull' first to download the image."
80        )));
81    }
82
83    // Create volume mounts
84    let mounts = vec![
85        Mount {
86            target: Some(MOUNT_SESSION.to_string()),
87            source: Some(VOLUME_SESSION.to_string()),
88            typ: Some(MountTypeEnum::VOLUME),
89            read_only: Some(false),
90            ..Default::default()
91        },
92        Mount {
93            target: Some(MOUNT_PROJECTS.to_string()),
94            source: Some(VOLUME_PROJECTS.to_string()),
95            typ: Some(MountTypeEnum::VOLUME),
96            read_only: Some(false),
97            ..Default::default()
98        },
99        Mount {
100            target: Some(MOUNT_CONFIG.to_string()),
101            source: Some(VOLUME_CONFIG.to_string()),
102            typ: Some(MountTypeEnum::VOLUME),
103            read_only: Some(false),
104            ..Default::default()
105        },
106    ];
107
108    // Create port bindings (default to localhost for security)
109    let bind_addr = bind_address.unwrap_or("127.0.0.1");
110    let mut port_bindings: PortMap = HashMap::new();
111
112    // opencode web port
113    port_bindings.insert(
114        "3000/tcp".to_string(),
115        Some(vec![PortBinding {
116            host_ip: Some(bind_addr.to_string()),
117            host_port: Some(port.to_string()),
118        }]),
119    );
120
121    // Cockpit port (if enabled)
122    // Container always listens on 9090, map to host's configured port
123    if cockpit_enabled_val {
124        port_bindings.insert(
125            "9090/tcp".to_string(),
126            Some(vec![PortBinding {
127                host_ip: Some(bind_addr.to_string()),
128                host_port: Some(cockpit_port_val.to_string()),
129            }]),
130        );
131    }
132
133    // Create exposed ports map
134    let mut exposed_ports = HashMap::new();
135    exposed_ports.insert("3000/tcp".to_string(), HashMap::new());
136    if cockpit_enabled_val {
137        exposed_ports.insert("9090/tcp".to_string(), HashMap::new());
138    }
139
140    // Create host config
141    // When Cockpit is enabled, add systemd-specific settings (requires Linux host)
142    // When Cockpit is disabled, use simpler tini-based config (works everywhere)
143    let host_config = if cockpit_enabled_val {
144        HostConfig {
145            mounts: Some(mounts),
146            port_bindings: Some(port_bindings),
147            auto_remove: Some(false),
148            // CAP_SYS_ADMIN required for systemd cgroup access
149            cap_add: Some(vec!["SYS_ADMIN".to_string()]),
150            // tmpfs for /run, /run/lock, and /tmp (required for systemd)
151            tmpfs: Some(HashMap::from([
152                ("/run".to_string(), "exec".to_string()),
153                ("/run/lock".to_string(), String::new()),
154                ("/tmp".to_string(), String::new()),
155            ])),
156            // cgroup mount (read-write for systemd)
157            binds: Some(vec!["/sys/fs/cgroup:/sys/fs/cgroup:rw".to_string()]),
158            // Use HOST cgroup namespace for systemd compatibility across Linux distros:
159            // - cgroups v2 (Amazon Linux 2023, Fedora 31+, Ubuntu 21.10+, Debian 11+): required
160            // - cgroups v1 (CentOS 7, Ubuntu 18.04, Debian 10): works fine
161            // - Docker Desktop (macOS/Windows VM): works fine
162            // Note: PRIVATE mode is more isolated but causes systemd to exit(255) on cgroups v2.
163            // Since we already use privileged mode, HOST namespace is acceptable.
164            cgroupns_mode: Some(bollard::models::HostConfigCgroupnsModeEnum::HOST),
165            // Privileged mode required for systemd to manage cgroups and system services
166            privileged: Some(true),
167            ..Default::default()
168        }
169    } else {
170        // Simple config for tini mode (works on macOS and Linux)
171        HostConfig {
172            mounts: Some(mounts),
173            port_bindings: Some(port_bindings),
174            auto_remove: Some(false),
175            ..Default::default()
176        }
177    };
178
179    // Build environment variables
180    // Add USE_SYSTEMD=1 when Cockpit is enabled to tell entrypoint to use systemd
181    let final_env = if cockpit_enabled_val {
182        let mut env = env_vars.unwrap_or_default();
183        env.push("USE_SYSTEMD=1".to_string());
184        Some(env)
185    } else {
186        env_vars
187    };
188
189    // Create container config
190    let config = Config {
191        image: Some(image_name.to_string()),
192        hostname: Some(CONTAINER_NAME.to_string()),
193        working_dir: Some("/workspace".to_string()),
194        exposed_ports: Some(exposed_ports),
195        env: final_env,
196        host_config: Some(host_config),
197        ..Default::default()
198    };
199
200    // Create container
201    let options = CreateContainerOptions {
202        name: container_name,
203        platform: None,
204    };
205
206    let response = client
207        .inner()
208        .create_container(Some(options), config)
209        .await
210        .map_err(|e| {
211            let msg = e.to_string();
212            if msg.contains("port is already allocated") || msg.contains("address already in use") {
213                DockerError::Container(format!(
214                    "Port {port} is already in use. Stop the service using that port or use a different port with --port."
215                ))
216            } else {
217                DockerError::Container(format!("Failed to create container: {e}"))
218            }
219        })?;
220
221    debug!("Container created with ID: {}", response.id);
222    Ok(response.id)
223}
224
225/// Start an existing container
226pub async fn start_container(client: &DockerClient, name: &str) -> Result<(), DockerError> {
227    debug!("Starting container: {}", name);
228
229    client
230        .inner()
231        .start_container(name, None::<StartContainerOptions<String>>)
232        .await
233        .map_err(|e| DockerError::Container(format!("Failed to start container {name}: {e}")))?;
234
235    debug!("Container {} started", name);
236    Ok(())
237}
238
239/// Stop a running container with graceful shutdown
240///
241/// # Arguments
242/// * `client` - Docker client
243/// * `name` - Container name
244/// * `timeout_secs` - Seconds to wait before force kill (default: 10)
245pub async fn stop_container(
246    client: &DockerClient,
247    name: &str,
248    timeout_secs: Option<i64>,
249) -> Result<(), DockerError> {
250    let timeout = timeout_secs.unwrap_or(10);
251    debug!("Stopping container {} with {}s timeout", name, timeout);
252
253    let options = StopContainerOptions { t: timeout };
254
255    client
256        .inner()
257        .stop_container(name, Some(options))
258        .await
259        .map_err(|e| {
260            let msg = e.to_string();
261            // "container already stopped" is not an error
262            if msg.contains("is not running") || msg.contains("304") {
263                debug!("Container {} was already stopped", name);
264                return DockerError::Container(format!("Container '{name}' is not running"));
265            }
266            DockerError::Container(format!("Failed to stop container {name}: {e}"))
267        })?;
268
269    debug!("Container {} stopped", name);
270    Ok(())
271}
272
273/// Remove a container
274///
275/// # Arguments
276/// * `client` - Docker client
277/// * `name` - Container name
278/// * `force` - Remove even if running
279pub async fn remove_container(
280    client: &DockerClient,
281    name: &str,
282    force: bool,
283) -> Result<(), DockerError> {
284    debug!("Removing container {} (force={})", name, force);
285
286    let options = RemoveContainerOptions {
287        force,
288        v: false, // Don't remove volumes
289        link: false,
290    };
291
292    client
293        .inner()
294        .remove_container(name, Some(options))
295        .await
296        .map_err(|e| DockerError::Container(format!("Failed to remove container {name}: {e}")))?;
297
298    debug!("Container {} removed", name);
299    Ok(())
300}
301
302/// Check if container exists
303pub async fn container_exists(client: &DockerClient, name: &str) -> Result<bool, DockerError> {
304    debug!("Checking if container exists: {}", name);
305
306    match client.inner().inspect_container(name, None).await {
307        Ok(_) => Ok(true),
308        Err(bollard::errors::Error::DockerResponseServerError {
309            status_code: 404, ..
310        }) => Ok(false),
311        Err(e) => Err(DockerError::Container(format!(
312            "Failed to inspect container {name}: {e}"
313        ))),
314    }
315}
316
317/// Check if container is running
318pub async fn container_is_running(client: &DockerClient, name: &str) -> Result<bool, DockerError> {
319    debug!("Checking if container is running: {}", name);
320
321    match client.inner().inspect_container(name, None).await {
322        Ok(info) => {
323            let running = info.state.and_then(|s| s.running).unwrap_or(false);
324            Ok(running)
325        }
326        Err(bollard::errors::Error::DockerResponseServerError {
327            status_code: 404, ..
328        }) => Ok(false),
329        Err(e) => Err(DockerError::Container(format!(
330            "Failed to inspect container {name}: {e}"
331        ))),
332    }
333}
334
335/// Get container state (running, stopped, etc.)
336pub async fn container_state(client: &DockerClient, name: &str) -> Result<String, DockerError> {
337    debug!("Getting container state: {}", name);
338
339    match client.inner().inspect_container(name, None).await {
340        Ok(info) => {
341            let state = info
342                .state
343                .and_then(|s| s.status)
344                .map(|s| s.to_string())
345                .unwrap_or_else(|| "unknown".to_string());
346            Ok(state)
347        }
348        Err(bollard::errors::Error::DockerResponseServerError {
349            status_code: 404, ..
350        }) => Err(DockerError::Container(format!(
351            "Container '{name}' not found"
352        ))),
353        Err(e) => Err(DockerError::Container(format!(
354            "Failed to inspect container {name}: {e}"
355        ))),
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    #[test]
364    fn container_constants_are_correct() {
365        assert_eq!(CONTAINER_NAME, "opencode-cloud");
366        assert_eq!(OPENCODE_WEB_PORT, 3000);
367    }
368
369    #[test]
370    fn default_image_format() {
371        let expected = format!("{IMAGE_NAME_GHCR}:{IMAGE_TAG_DEFAULT}");
372        assert_eq!(expected, "ghcr.io/prizz/opencode-cloud-sandbox:latest");
373    }
374}