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