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)]
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 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 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 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 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 let bind_addr = bind_address.unwrap_or("127.0.0.1");
135 let mut port_bindings: PortMap = HashMap::new();
136
137 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 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 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 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_add: Some(vec!["SYS_ADMIN".to_string()]),
174 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 binds: Some(vec!["/sys/fs/cgroup:/sys/fs/cgroup:rw".to_string()]),
182 cgroupns_mode: Some(bollard::models::HostConfigCgroupnsModeEnum::HOST),
189 privileged: Some(true),
191 ..Default::default()
192 }
193 } else {
194 HostConfig {
196 mounts: Some(mounts),
197 port_bindings: Some(port_bindings),
198 auto_remove: Some(false),
199 cap_add: Some(vec!["SETUID".to_string(), "SETGID".to_string()]),
202 ..Default::default()
203 }
204 };
205
206 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 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 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 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
262pub 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
276pub 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 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
313pub 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, 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
342pub 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
357pub 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
375pub 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#[derive(Debug, Clone)]
401pub struct ContainerPorts {
402 pub opencode_port: Option<u16>,
404 pub cockpit_port: Option<u16>,
406}
407
408#[derive(Debug, Clone)]
410pub struct ContainerBindMount {
411 pub source: String,
413 pub target: String,
415 pub read_only: bool,
417}
418
419pub 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 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 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
462pub 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 let bind_mounts: Vec<ContainerBindMount> = mounts
481 .iter()
482 .filter(|m| m.typ == Some(MountPointTypeEnum::BIND))
483 .filter(|m| {
484 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}