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