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