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