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::container::{
14 Config, CreateContainerOptions, RemoveContainerOptions, StartContainerOptions,
15 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 = HashMap::new();
160 exposed_ports.insert("3000/tcp".to_string(), HashMap::new());
161 if cockpit_enabled_val {
162 exposed_ports.insert("9090/tcp".to_string(), HashMap::new());
163 }
164
165 let host_config = if cockpit_enabled_val {
169 HostConfig {
170 mounts: Some(mounts),
171 port_bindings: Some(port_bindings),
172 auto_remove: Some(false),
173 cap_add: Some(vec!["SYS_ADMIN".to_string()]),
175 tmpfs: Some(HashMap::from([
177 ("/run".to_string(), "exec".to_string()),
178 ("/run/lock".to_string(), String::new()),
179 ("/tmp".to_string(), String::new()),
180 ])),
181 binds: Some(vec!["/sys/fs/cgroup:/sys/fs/cgroup:rw".to_string()]),
183 cgroupns_mode: Some(bollard::models::HostConfigCgroupnsModeEnum::HOST),
190 privileged: Some(true),
192 ..Default::default()
193 }
194 } else {
195 HostConfig {
197 mounts: Some(mounts),
198 port_bindings: Some(port_bindings),
199 auto_remove: Some(false),
200 ..Default::default()
201 }
202 };
203
204 let mut env = env_vars.unwrap_or_default();
206 if !has_env_key(&env, "XDG_DATA_HOME") {
207 env.push("XDG_DATA_HOME=/home/opencode/.local/share".to_string());
208 }
209 if !has_env_key(&env, "XDG_STATE_HOME") {
210 env.push("XDG_STATE_HOME=/home/opencode/.local/state".to_string());
211 }
212 if !has_env_key(&env, "XDG_CONFIG_HOME") {
213 env.push("XDG_CONFIG_HOME=/home/opencode/.config".to_string());
214 }
215 if !has_env_key(&env, "XDG_CACHE_HOME") {
216 env.push("XDG_CACHE_HOME=/home/opencode/.cache".to_string());
217 }
218 if cockpit_enabled_val && !has_env_key(&env, "USE_SYSTEMD") {
220 env.push("USE_SYSTEMD=1".to_string());
221 }
222 let final_env = if env.is_empty() { None } else { Some(env) };
223
224 let config = Config {
226 image: Some(image_name.to_string()),
227 hostname: Some(CONTAINER_NAME.to_string()),
228 working_dir: Some("/home/opencode/workspace".to_string()),
229 exposed_ports: Some(exposed_ports),
230 env: final_env,
231 host_config: Some(host_config),
232 ..Default::default()
233 };
234
235 let options = CreateContainerOptions {
237 name: container_name,
238 platform: None,
239 };
240
241 let response = client
242 .inner()
243 .create_container(Some(options), config)
244 .await
245 .map_err(|e| {
246 let msg = e.to_string();
247 if msg.contains("port is already allocated") || msg.contains("address already in use") {
248 DockerError::Container(format!(
249 "Port {port} is already in use. Stop the service using that port or use a different port with --port."
250 ))
251 } else {
252 DockerError::Container(format!("Failed to create container: {e}"))
253 }
254 })?;
255
256 debug!("Container created with ID: {}", response.id);
257 Ok(response.id)
258}
259
260pub async fn start_container(client: &DockerClient, name: &str) -> Result<(), DockerError> {
262 debug!("Starting container: {}", name);
263
264 client
265 .inner()
266 .start_container(name, None::<StartContainerOptions<String>>)
267 .await
268 .map_err(|e| DockerError::Container(format!("Failed to start container {name}: {e}")))?;
269
270 debug!("Container {} started", name);
271 Ok(())
272}
273
274pub async fn stop_container(
281 client: &DockerClient,
282 name: &str,
283 timeout_secs: Option<i64>,
284) -> Result<(), DockerError> {
285 let timeout = timeout_secs.unwrap_or(10);
286 debug!("Stopping container {} with {}s timeout", name, timeout);
287
288 let options = StopContainerOptions { t: timeout };
289
290 client
291 .inner()
292 .stop_container(name, Some(options))
293 .await
294 .map_err(|e| {
295 let msg = e.to_string();
296 if msg.contains("is not running") || msg.contains("304") {
298 debug!("Container {} was already stopped", name);
299 return DockerError::Container(format!("Container '{name}' is not running"));
300 }
301 DockerError::Container(format!("Failed to stop container {name}: {e}"))
302 })?;
303
304 debug!("Container {} stopped", name);
305 Ok(())
306}
307
308pub async fn remove_container(
315 client: &DockerClient,
316 name: &str,
317 force: bool,
318) -> Result<(), DockerError> {
319 debug!("Removing container {} (force={})", name, force);
320
321 let options = RemoveContainerOptions {
322 force,
323 v: false, link: false,
325 };
326
327 client
328 .inner()
329 .remove_container(name, Some(options))
330 .await
331 .map_err(|e| DockerError::Container(format!("Failed to remove container {name}: {e}")))?;
332
333 debug!("Container {} removed", name);
334 Ok(())
335}
336
337pub async fn container_exists(client: &DockerClient, name: &str) -> Result<bool, DockerError> {
339 debug!("Checking if container exists: {}", name);
340
341 match client.inner().inspect_container(name, None).await {
342 Ok(_) => Ok(true),
343 Err(bollard::errors::Error::DockerResponseServerError {
344 status_code: 404, ..
345 }) => Ok(false),
346 Err(e) => Err(DockerError::Container(format!(
347 "Failed to inspect container {name}: {e}"
348 ))),
349 }
350}
351
352pub async fn container_is_running(client: &DockerClient, name: &str) -> Result<bool, DockerError> {
354 debug!("Checking if container is running: {}", name);
355
356 match client.inner().inspect_container(name, None).await {
357 Ok(info) => {
358 let running = info.state.and_then(|s| s.running).unwrap_or(false);
359 Ok(running)
360 }
361 Err(bollard::errors::Error::DockerResponseServerError {
362 status_code: 404, ..
363 }) => Ok(false),
364 Err(e) => Err(DockerError::Container(format!(
365 "Failed to inspect container {name}: {e}"
366 ))),
367 }
368}
369
370pub async fn container_state(client: &DockerClient, name: &str) -> Result<String, DockerError> {
372 debug!("Getting container state: {}", name);
373
374 match client.inner().inspect_container(name, None).await {
375 Ok(info) => {
376 let state = info
377 .state
378 .and_then(|s| s.status)
379 .map(|s| s.to_string())
380 .unwrap_or_else(|| "unknown".to_string());
381 Ok(state)
382 }
383 Err(bollard::errors::Error::DockerResponseServerError {
384 status_code: 404, ..
385 }) => Err(DockerError::Container(format!(
386 "Container '{name}' not found"
387 ))),
388 Err(e) => Err(DockerError::Container(format!(
389 "Failed to inspect container {name}: {e}"
390 ))),
391 }
392}
393
394#[derive(Debug, Clone)]
396pub struct ContainerPorts {
397 pub opencode_port: Option<u16>,
399 pub cockpit_port: Option<u16>,
401}
402
403#[derive(Debug, Clone)]
405pub struct ContainerBindMount {
406 pub source: String,
408 pub target: String,
410 pub read_only: bool,
412}
413
414pub async fn get_container_ports(
419 client: &DockerClient,
420 name: &str,
421) -> Result<ContainerPorts, DockerError> {
422 debug!("Getting container ports: {}", name);
423
424 let info = client
425 .inner()
426 .inspect_container(name, None)
427 .await
428 .map_err(|e| DockerError::Container(format!("Failed to inspect container {name}: {e}")))?;
429
430 let port_bindings = info
431 .host_config
432 .and_then(|hc| hc.port_bindings)
433 .unwrap_or_default();
434
435 let opencode_port = port_bindings
437 .get("3000/tcp")
438 .and_then(|bindings| bindings.as_ref())
439 .and_then(|bindings| bindings.first())
440 .and_then(|binding| binding.host_port.as_ref())
441 .and_then(|port_str| port_str.parse::<u16>().ok());
442
443 let cockpit_port = port_bindings
445 .get("9090/tcp")
446 .and_then(|bindings| bindings.as_ref())
447 .and_then(|bindings| bindings.first())
448 .and_then(|binding| binding.host_port.as_ref())
449 .and_then(|port_str| port_str.parse::<u16>().ok());
450
451 Ok(ContainerPorts {
452 opencode_port,
453 cockpit_port,
454 })
455}
456
457pub async fn get_container_bind_mounts(
461 client: &DockerClient,
462 name: &str,
463) -> Result<Vec<ContainerBindMount>, DockerError> {
464 debug!("Getting container bind mounts: {}", name);
465
466 let info = client
467 .inner()
468 .inspect_container(name, None)
469 .await
470 .map_err(|e| DockerError::Container(format!("Failed to inspect container {name}: {e}")))?;
471
472 let mounts = info.mounts.unwrap_or_default();
473
474 let bind_mounts: Vec<ContainerBindMount> = mounts
476 .iter()
477 .filter(|m| m.typ == Some(MountPointTypeEnum::BIND))
478 .filter(|m| {
479 let target = m.destination.as_deref().unwrap_or("");
481 !target.starts_with("/sys/")
482 })
483 .map(|m| ContainerBindMount {
484 source: m.source.clone().unwrap_or_default(),
485 target: m.destination.clone().unwrap_or_default(),
486 read_only: m.rw.map(|rw| !rw).unwrap_or(false),
487 })
488 .collect();
489
490 Ok(bind_mounts)
491}
492
493#[cfg(test)]
494mod tests {
495 use super::*;
496
497 #[test]
498 fn container_constants_are_correct() {
499 assert_eq!(CONTAINER_NAME, "opencode-cloud-sandbox");
500 assert_eq!(OPENCODE_WEB_PORT, 3000);
501 }
502
503 #[test]
504 fn default_image_format() {
505 let expected = format!("{IMAGE_NAME_GHCR}:{IMAGE_TAG_DEFAULT}");
506 assert_eq!(expected, "ghcr.io/prizz/opencode-cloud-sandbox:latest");
507 }
508}