1use 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
23pub const CONTAINER_NAME: &str = "opencode-cloud-sandbox";
25
26pub 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#[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 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 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 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 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 let bind_addr = bind_address.unwrap_or("127.0.0.1");
143 let mut port_bindings: PortMap = HashMap::new();
144
145 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 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 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 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_add: Some(vec!["SYS_ADMIN".to_string()]),
182 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 binds: Some(vec!["/sys/fs/cgroup:/sys/fs/cgroup:rw".to_string()]),
190 cgroupns_mode: Some(bollard::models::HostConfigCgroupnsModeEnum::HOST),
197 privileged: Some(true),
199 ..Default::default()
200 }
201 } else {
202 HostConfig {
204 mounts: Some(mounts),
205 port_bindings: Some(port_bindings),
206 auto_remove: Some(false),
207 cap_add: Some(vec!["SETUID".to_string(), "SETGID".to_string()]),
210 ..Default::default()
211 }
212 };
213
214 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 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 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 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
270pub 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
284pub 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 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
321pub 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, 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
350pub 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
365pub 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
383pub 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#[derive(Debug, Clone)]
409pub struct ContainerPorts {
410 pub opencode_port: Option<u16>,
412 pub cockpit_port: Option<u16>,
414}
415
416#[derive(Debug, Clone)]
418pub struct ContainerBindMount {
419 pub source: String,
421 pub target: String,
423 pub read_only: bool,
425}
426
427pub 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 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 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
470pub 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 let bind_mounts: Vec<ContainerBindMount> = mounts
489 .iter()
490 .filter(|m| m.typ == Some(MountPointTypeEnum::BIND))
491 .filter(|m| {
492 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}