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)
36pub async fn create_container(
37    client: &DockerClient,
38    name: Option<&str>,
39    image: Option<&str>,
40    opencode_web_port: Option<u16>,
41    env_vars: Option<Vec<String>>,
42) -> Result<String, DockerError> {
43    let container_name = name.unwrap_or(CONTAINER_NAME);
44    let default_image = format!("{IMAGE_NAME_GHCR}:{IMAGE_TAG_DEFAULT}");
45    let image_name = image.unwrap_or(&default_image);
46    let port = opencode_web_port.unwrap_or(OPENCODE_WEB_PORT);
47
48    debug!(
49        "Creating container {} from image {} with port {}",
50        container_name, image_name, port
51    );
52
53    // Check if container already exists
54    if container_exists(client, container_name).await? {
55        return Err(DockerError::Container(format!(
56            "Container '{}' already exists. Remove it first with 'occ stop --remove' or use a different name.",
57            container_name
58        )));
59    }
60
61    // Check if image exists
62    let image_parts: Vec<&str> = image_name.split(':').collect();
63    let (image_repo, image_tag) = if image_parts.len() == 2 {
64        (image_parts[0], image_parts[1])
65    } else {
66        (image_name, "latest")
67    };
68
69    if !super::image::image_exists(client, image_repo, image_tag).await? {
70        return Err(DockerError::Container(format!(
71            "Image '{}' not found. Run 'occ pull' first to download the image.",
72            image_name
73        )));
74    }
75
76    // Create volume mounts
77    let mounts = vec![
78        Mount {
79            target: Some(MOUNT_SESSION.to_string()),
80            source: Some(VOLUME_SESSION.to_string()),
81            typ: Some(MountTypeEnum::VOLUME),
82            read_only: Some(false),
83            ..Default::default()
84        },
85        Mount {
86            target: Some(MOUNT_PROJECTS.to_string()),
87            source: Some(VOLUME_PROJECTS.to_string()),
88            typ: Some(MountTypeEnum::VOLUME),
89            read_only: Some(false),
90            ..Default::default()
91        },
92        Mount {
93            target: Some(MOUNT_CONFIG.to_string()),
94            source: Some(VOLUME_CONFIG.to_string()),
95            typ: Some(MountTypeEnum::VOLUME),
96            read_only: Some(false),
97            ..Default::default()
98        },
99    ];
100
101    // Create port bindings (localhost only for security)
102    let mut port_bindings: PortMap = HashMap::new();
103    port_bindings.insert(
104        "3000/tcp".to_string(),
105        Some(vec![PortBinding {
106            host_ip: Some("127.0.0.1".to_string()),
107            host_port: Some(port.to_string()),
108        }]),
109    );
110
111    // Create exposed ports map
112    let mut exposed_ports = HashMap::new();
113    exposed_ports.insert("3000/tcp".to_string(), HashMap::new());
114
115    // Create host config
116    let host_config = HostConfig {
117        mounts: Some(mounts),
118        port_bindings: Some(port_bindings),
119        auto_remove: Some(false),
120        ..Default::default()
121    };
122
123    // Create container config
124    let config = Config {
125        image: Some(image_name.to_string()),
126        hostname: Some(CONTAINER_NAME.to_string()),
127        working_dir: Some("/workspace".to_string()),
128        exposed_ports: Some(exposed_ports),
129        env: env_vars,
130        host_config: Some(host_config),
131        ..Default::default()
132    };
133
134    // Create container
135    let options = CreateContainerOptions {
136        name: container_name,
137        platform: None,
138    };
139
140    let response = client
141        .inner()
142        .create_container(Some(options), config)
143        .await
144        .map_err(|e| {
145            let msg = e.to_string();
146            if msg.contains("port is already allocated") || msg.contains("address already in use") {
147                DockerError::Container(format!(
148                    "Port {} is already in use. Stop the service using that port or use a different port with --port.",
149                    port
150                ))
151            } else {
152                DockerError::Container(format!("Failed to create container: {}", e))
153            }
154        })?;
155
156    debug!("Container created with ID: {}", response.id);
157    Ok(response.id)
158}
159
160/// Start an existing container
161pub async fn start_container(client: &DockerClient, name: &str) -> Result<(), DockerError> {
162    debug!("Starting container: {}", name);
163
164    client
165        .inner()
166        .start_container(name, None::<StartContainerOptions<String>>)
167        .await
168        .map_err(|e| {
169            DockerError::Container(format!("Failed to start container {}: {}", name, e))
170        })?;
171
172    debug!("Container {} started", name);
173    Ok(())
174}
175
176/// Stop a running container with graceful shutdown
177///
178/// # Arguments
179/// * `client` - Docker client
180/// * `name` - Container name
181/// * `timeout_secs` - Seconds to wait before force kill (default: 10)
182pub async fn stop_container(
183    client: &DockerClient,
184    name: &str,
185    timeout_secs: Option<i64>,
186) -> Result<(), DockerError> {
187    let timeout = timeout_secs.unwrap_or(10);
188    debug!("Stopping container {} with {}s timeout", name, timeout);
189
190    let options = StopContainerOptions { t: timeout };
191
192    client
193        .inner()
194        .stop_container(name, Some(options))
195        .await
196        .map_err(|e| {
197            let msg = e.to_string();
198            // "container already stopped" is not an error
199            if msg.contains("is not running") || msg.contains("304") {
200                debug!("Container {} was already stopped", name);
201                return DockerError::Container(format!("Container '{}' is not running", name));
202            }
203            DockerError::Container(format!("Failed to stop container {}: {}", name, e))
204        })?;
205
206    debug!("Container {} stopped", name);
207    Ok(())
208}
209
210/// Remove a container
211///
212/// # Arguments
213/// * `client` - Docker client
214/// * `name` - Container name
215/// * `force` - Remove even if running
216pub async fn remove_container(
217    client: &DockerClient,
218    name: &str,
219    force: bool,
220) -> Result<(), DockerError> {
221    debug!("Removing container {} (force={})", name, force);
222
223    let options = RemoveContainerOptions {
224        force,
225        v: false, // Don't remove volumes
226        link: false,
227    };
228
229    client
230        .inner()
231        .remove_container(name, Some(options))
232        .await
233        .map_err(|e| {
234            DockerError::Container(format!("Failed to remove container {}: {}", name, e))
235        })?;
236
237    debug!("Container {} removed", name);
238    Ok(())
239}
240
241/// Check if container exists
242pub async fn container_exists(client: &DockerClient, name: &str) -> Result<bool, DockerError> {
243    debug!("Checking if container exists: {}", name);
244
245    match client.inner().inspect_container(name, None).await {
246        Ok(_) => Ok(true),
247        Err(bollard::errors::Error::DockerResponseServerError {
248            status_code: 404, ..
249        }) => Ok(false),
250        Err(e) => Err(DockerError::Container(format!(
251            "Failed to inspect container {}: {}",
252            name, e
253        ))),
254    }
255}
256
257/// Check if container is running
258pub async fn container_is_running(client: &DockerClient, name: &str) -> Result<bool, DockerError> {
259    debug!("Checking if container is running: {}", name);
260
261    match client.inner().inspect_container(name, None).await {
262        Ok(info) => {
263            let running = info.state.and_then(|s| s.running).unwrap_or(false);
264            Ok(running)
265        }
266        Err(bollard::errors::Error::DockerResponseServerError {
267            status_code: 404, ..
268        }) => Ok(false),
269        Err(e) => Err(DockerError::Container(format!(
270            "Failed to inspect container {}: {}",
271            name, e
272        ))),
273    }
274}
275
276/// Get container state (running, stopped, etc.)
277pub async fn container_state(client: &DockerClient, name: &str) -> Result<String, DockerError> {
278    debug!("Getting container state: {}", name);
279
280    match client.inner().inspect_container(name, None).await {
281        Ok(info) => {
282            let state = info
283                .state
284                .and_then(|s| s.status)
285                .map(|s| s.to_string())
286                .unwrap_or_else(|| "unknown".to_string());
287            Ok(state)
288        }
289        Err(bollard::errors::Error::DockerResponseServerError {
290            status_code: 404, ..
291        }) => Err(DockerError::Container(format!(
292            "Container '{}' not found",
293            name
294        ))),
295        Err(e) => Err(DockerError::Container(format!(
296            "Failed to inspect container {}: {}",
297            name, e
298        ))),
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn container_constants_are_correct() {
308        assert_eq!(CONTAINER_NAME, "opencode-cloud");
309        assert_eq!(OPENCODE_WEB_PORT, 3000);
310    }
311
312    #[test]
313    fn default_image_format() {
314        let expected = format!("{IMAGE_NAME_GHCR}:{IMAGE_TAG_DEFAULT}");
315        assert_eq!(expected, "ghcr.io/prizz/opencode-cloud:latest");
316    }
317}