opencode_cloud_core/docker/
container.rs1use 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
19pub const CONTAINER_NAME: &str = "opencode-cloud";
21
22pub const OPENCODE_WEB_PORT: u16 = 3000;
24
25#[allow(clippy::too_many_arguments)]
40pub async fn create_container(
41 client: &DockerClient,
42 name: Option<&str>,
43 image: Option<&str>,
44 opencode_web_port: Option<u16>,
45 env_vars: Option<Vec<String>>,
46 bind_address: Option<&str>,
47 cockpit_port: Option<u16>,
48 cockpit_enabled: Option<bool>,
49) -> Result<String, DockerError> {
50 let container_name = name.unwrap_or(CONTAINER_NAME);
51 let default_image = format!("{IMAGE_NAME_GHCR}:{IMAGE_TAG_DEFAULT}");
52 let image_name = image.unwrap_or(&default_image);
53 let port = opencode_web_port.unwrap_or(OPENCODE_WEB_PORT);
54 let cockpit_port_val = cockpit_port.unwrap_or(9090);
55 let cockpit_enabled_val = cockpit_enabled.unwrap_or(true);
56
57 debug!(
58 "Creating container {} from image {} with port {} and cockpit_port {} (enabled: {})",
59 container_name, image_name, port, cockpit_port_val, cockpit_enabled_val
60 );
61
62 if container_exists(client, container_name).await? {
64 return Err(DockerError::Container(format!(
65 "Container '{container_name}' already exists. Remove it first with 'occ stop --remove' or use a different name."
66 )));
67 }
68
69 let image_parts: Vec<&str> = image_name.split(':').collect();
71 let (image_repo, image_tag) = if image_parts.len() == 2 {
72 (image_parts[0], image_parts[1])
73 } else {
74 (image_name, "latest")
75 };
76
77 if !super::image::image_exists(client, image_repo, image_tag).await? {
78 return Err(DockerError::Container(format!(
79 "Image '{image_name}' not found. Run 'occ pull' first to download the image."
80 )));
81 }
82
83 let mounts = vec![
85 Mount {
86 target: Some(MOUNT_SESSION.to_string()),
87 source: Some(VOLUME_SESSION.to_string()),
88 typ: Some(MountTypeEnum::VOLUME),
89 read_only: Some(false),
90 ..Default::default()
91 },
92 Mount {
93 target: Some(MOUNT_PROJECTS.to_string()),
94 source: Some(VOLUME_PROJECTS.to_string()),
95 typ: Some(MountTypeEnum::VOLUME),
96 read_only: Some(false),
97 ..Default::default()
98 },
99 Mount {
100 target: Some(MOUNT_CONFIG.to_string()),
101 source: Some(VOLUME_CONFIG.to_string()),
102 typ: Some(MountTypeEnum::VOLUME),
103 read_only: Some(false),
104 ..Default::default()
105 },
106 ];
107
108 let bind_addr = bind_address.unwrap_or("127.0.0.1");
110 let mut port_bindings: PortMap = HashMap::new();
111
112 port_bindings.insert(
114 "3000/tcp".to_string(),
115 Some(vec![PortBinding {
116 host_ip: Some(bind_addr.to_string()),
117 host_port: Some(port.to_string()),
118 }]),
119 );
120
121 if cockpit_enabled_val {
124 port_bindings.insert(
125 "9090/tcp".to_string(),
126 Some(vec![PortBinding {
127 host_ip: Some(bind_addr.to_string()),
128 host_port: Some(cockpit_port_val.to_string()),
129 }]),
130 );
131 }
132
133 let mut exposed_ports = HashMap::new();
135 exposed_ports.insert("3000/tcp".to_string(), HashMap::new());
136 if cockpit_enabled_val {
137 exposed_ports.insert("9090/tcp".to_string(), HashMap::new());
138 }
139
140 let host_config = if cockpit_enabled_val {
144 HostConfig {
145 mounts: Some(mounts),
146 port_bindings: Some(port_bindings),
147 auto_remove: Some(false),
148 cap_add: Some(vec!["SYS_ADMIN".to_string()]),
150 tmpfs: Some(HashMap::from([
152 ("/run".to_string(), "exec".to_string()),
153 ("/run/lock".to_string(), String::new()),
154 ("/tmp".to_string(), String::new()),
155 ])),
156 binds: Some(vec!["/sys/fs/cgroup:/sys/fs/cgroup:rw".to_string()]),
158 cgroupns_mode: Some(bollard::models::HostConfigCgroupnsModeEnum::HOST),
165 privileged: Some(true),
167 ..Default::default()
168 }
169 } else {
170 HostConfig {
172 mounts: Some(mounts),
173 port_bindings: Some(port_bindings),
174 auto_remove: Some(false),
175 ..Default::default()
176 }
177 };
178
179 let final_env = if cockpit_enabled_val {
182 let mut env = env_vars.unwrap_or_default();
183 env.push("USE_SYSTEMD=1".to_string());
184 Some(env)
185 } else {
186 env_vars
187 };
188
189 let config = Config {
191 image: Some(image_name.to_string()),
192 hostname: Some(CONTAINER_NAME.to_string()),
193 working_dir: Some("/workspace".to_string()),
194 exposed_ports: Some(exposed_ports),
195 env: final_env,
196 host_config: Some(host_config),
197 ..Default::default()
198 };
199
200 let options = CreateContainerOptions {
202 name: container_name,
203 platform: None,
204 };
205
206 let response = client
207 .inner()
208 .create_container(Some(options), config)
209 .await
210 .map_err(|e| {
211 let msg = e.to_string();
212 if msg.contains("port is already allocated") || msg.contains("address already in use") {
213 DockerError::Container(format!(
214 "Port {port} is already in use. Stop the service using that port or use a different port with --port."
215 ))
216 } else {
217 DockerError::Container(format!("Failed to create container: {e}"))
218 }
219 })?;
220
221 debug!("Container created with ID: {}", response.id);
222 Ok(response.id)
223}
224
225pub async fn start_container(client: &DockerClient, name: &str) -> Result<(), DockerError> {
227 debug!("Starting container: {}", name);
228
229 client
230 .inner()
231 .start_container(name, None::<StartContainerOptions<String>>)
232 .await
233 .map_err(|e| DockerError::Container(format!("Failed to start container {name}: {e}")))?;
234
235 debug!("Container {} started", name);
236 Ok(())
237}
238
239pub async fn stop_container(
246 client: &DockerClient,
247 name: &str,
248 timeout_secs: Option<i64>,
249) -> Result<(), DockerError> {
250 let timeout = timeout_secs.unwrap_or(10);
251 debug!("Stopping container {} with {}s timeout", name, timeout);
252
253 let options = StopContainerOptions { t: timeout };
254
255 client
256 .inner()
257 .stop_container(name, Some(options))
258 .await
259 .map_err(|e| {
260 let msg = e.to_string();
261 if msg.contains("is not running") || msg.contains("304") {
263 debug!("Container {} was already stopped", name);
264 return DockerError::Container(format!("Container '{name}' is not running"));
265 }
266 DockerError::Container(format!("Failed to stop container {name}: {e}"))
267 })?;
268
269 debug!("Container {} stopped", name);
270 Ok(())
271}
272
273pub async fn remove_container(
280 client: &DockerClient,
281 name: &str,
282 force: bool,
283) -> Result<(), DockerError> {
284 debug!("Removing container {} (force={})", name, force);
285
286 let options = RemoveContainerOptions {
287 force,
288 v: false, link: false,
290 };
291
292 client
293 .inner()
294 .remove_container(name, Some(options))
295 .await
296 .map_err(|e| DockerError::Container(format!("Failed to remove container {name}: {e}")))?;
297
298 debug!("Container {} removed", name);
299 Ok(())
300}
301
302pub async fn container_exists(client: &DockerClient, name: &str) -> Result<bool, DockerError> {
304 debug!("Checking if container exists: {}", name);
305
306 match client.inner().inspect_container(name, None).await {
307 Ok(_) => Ok(true),
308 Err(bollard::errors::Error::DockerResponseServerError {
309 status_code: 404, ..
310 }) => Ok(false),
311 Err(e) => Err(DockerError::Container(format!(
312 "Failed to inspect container {name}: {e}"
313 ))),
314 }
315}
316
317pub async fn container_is_running(client: &DockerClient, name: &str) -> Result<bool, DockerError> {
319 debug!("Checking if container is running: {}", name);
320
321 match client.inner().inspect_container(name, None).await {
322 Ok(info) => {
323 let running = info.state.and_then(|s| s.running).unwrap_or(false);
324 Ok(running)
325 }
326 Err(bollard::errors::Error::DockerResponseServerError {
327 status_code: 404, ..
328 }) => Ok(false),
329 Err(e) => Err(DockerError::Container(format!(
330 "Failed to inspect container {name}: {e}"
331 ))),
332 }
333}
334
335pub async fn container_state(client: &DockerClient, name: &str) -> Result<String, DockerError> {
337 debug!("Getting container state: {}", name);
338
339 match client.inner().inspect_container(name, None).await {
340 Ok(info) => {
341 let state = info
342 .state
343 .and_then(|s| s.status)
344 .map(|s| s.to_string())
345 .unwrap_or_else(|| "unknown".to_string());
346 Ok(state)
347 }
348 Err(bollard::errors::Error::DockerResponseServerError {
349 status_code: 404, ..
350 }) => Err(DockerError::Container(format!(
351 "Container '{name}' not found"
352 ))),
353 Err(e) => Err(DockerError::Container(format!(
354 "Failed to inspect container {name}: {e}"
355 ))),
356 }
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362
363 #[test]
364 fn container_constants_are_correct() {
365 assert_eq!(CONTAINER_NAME, "opencode-cloud");
366 assert_eq!(OPENCODE_WEB_PORT, 3000);
367 }
368
369 #[test]
370 fn default_image_format() {
371 let expected = format!("{IMAGE_NAME_GHCR}:{IMAGE_TAG_DEFAULT}");
372 assert_eq!(expected, "ghcr.io/prizz/opencode-cloud-sandbox:latest");
373 }
374}