1use super::dockerfile::IMAGE_NAME_GHCR;
7use super::mount::ParsedMount;
8use super::profile::{INSTANCE_LABEL_KEY, active_resource_names, remap_container_name};
9use super::volume::{
10 MOUNT_CACHE, MOUNT_CONFIG, MOUNT_PROJECTS, MOUNT_SESSION, MOUNT_SSH, MOUNT_STATE, MOUNT_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
34fn resolved_container_name(name: &str) -> String {
35 remap_container_name(name)
36}
37
38#[allow(clippy::too_many_arguments)]
55pub async fn create_container(
56 client: &DockerClient,
57 name: Option<&str>,
58 image: Option<&str>,
59 opencode_web_port: Option<u16>,
60 env_vars: Option<Vec<String>>,
61 bind_address: Option<&str>,
62 cockpit_port: Option<u16>,
63 cockpit_enabled: Option<bool>,
64 systemd_enabled: Option<bool>,
65 bind_mounts: Option<Vec<ParsedMount>>,
66) -> Result<String, DockerError> {
67 let names = active_resource_names();
68 let container_name = name
69 .map(resolved_container_name)
70 .unwrap_or(names.container_name);
71 let default_image = format!("{IMAGE_NAME_GHCR}:{}", names.image_tag);
72 let image_name = image.unwrap_or(&default_image);
73 let port = opencode_web_port.unwrap_or(OPENCODE_WEB_PORT);
74 let cockpit_port_val = cockpit_port.unwrap_or(9090);
75 let cockpit_enabled_val = cockpit_enabled.unwrap_or(false);
76 let systemd_enabled_val = systemd_enabled.unwrap_or(false);
77
78 debug!(
79 "Creating container {} from image {} with port {} and cockpit_port {} (enabled: {}, systemd: {})",
80 container_name,
81 image_name,
82 port,
83 cockpit_port_val,
84 cockpit_enabled_val,
85 systemd_enabled_val
86 );
87
88 if container_exists(client, &container_name).await? {
90 return Err(DockerError::Container(format!(
91 "Container '{container_name}' already exists. Remove it first with 'occ stop --remove' or use a different name."
92 )));
93 }
94
95 let image_parts: Vec<&str> = image_name.split(':').collect();
97 let (image_repo, image_tag) = if image_parts.len() == 2 {
98 (image_parts[0], image_parts[1])
99 } else {
100 (image_name, "latest")
101 };
102
103 if !super::image::image_exists(client, image_repo, image_tag).await? {
104 return Err(DockerError::Container(format!(
105 "Image '{image_name}' not found. Run 'occ pull' first to download the image."
106 )));
107 }
108
109 let mut bind_targets = HashSet::new();
110 if let Some(ref user_mounts) = bind_mounts {
111 for parsed in user_mounts {
112 bind_targets.insert(parsed.container_path.clone());
113 }
114 }
115
116 let mut mounts = Vec::new();
118 let mut add_volume_mount = |target: &str, source: &str| {
119 if bind_targets.contains(target) {
120 tracing::trace!(
121 "Skipping volume mount for {} (overridden by bind mount)",
122 target
123 );
124 return;
125 }
126 mounts.push(Mount {
127 target: Some(target.to_string()),
128 source: Some(source.to_string()),
129 typ: Some(MountTypeEnum::VOLUME),
130 read_only: Some(false),
131 ..Default::default()
132 });
133 };
134 add_volume_mount(MOUNT_SESSION, &names.volume_session);
135 add_volume_mount(MOUNT_STATE, &names.volume_state);
136 add_volume_mount(MOUNT_CACHE, &names.volume_cache);
137 add_volume_mount(MOUNT_PROJECTS, &names.volume_projects);
138 add_volume_mount(MOUNT_CONFIG, &names.volume_config);
139 add_volume_mount(MOUNT_USERS, &names.volume_users);
140 add_volume_mount(MOUNT_SSH, &names.volume_ssh);
141
142 if let Some(ref user_mounts) = bind_mounts {
144 for parsed in user_mounts {
145 mounts.push(parsed.to_bollard_mount());
146 }
147 }
148
149 let bind_addr = bind_address.unwrap_or("127.0.0.1");
151 let mut port_bindings: PortMap = HashMap::new();
152
153 port_bindings.insert(
155 "3000/tcp".to_string(),
156 Some(vec![PortBinding {
157 host_ip: Some(bind_addr.to_string()),
158 host_port: Some(port.to_string()),
159 }]),
160 );
161
162 if cockpit_enabled_val {
165 port_bindings.insert(
166 "9090/tcp".to_string(),
167 Some(vec![PortBinding {
168 host_ip: Some(bind_addr.to_string()),
169 host_port: Some(cockpit_port_val.to_string()),
170 }]),
171 );
172 }
173
174 let mut exposed_ports = vec!["3000/tcp".to_string()];
176 if cockpit_enabled_val {
177 exposed_ports.push("9090/tcp".to_string());
178 }
179
180 let host_config = if systemd_enabled_val {
184 HostConfig {
185 mounts: Some(mounts),
186 port_bindings: Some(port_bindings),
187 auto_remove: Some(false),
188 cap_add: Some(vec!["SYS_ADMIN".to_string()]),
190 tmpfs: Some(HashMap::from([
192 ("/run".to_string(), "exec".to_string()),
193 ("/run/lock".to_string(), String::new()),
194 ("/tmp".to_string(), String::new()),
195 ])),
196 binds: Some(vec!["/sys/fs/cgroup:/sys/fs/cgroup:rw".to_string()]),
198 cgroupns_mode: Some(bollard::models::HostConfigCgroupnsModeEnum::HOST),
205 privileged: Some(true),
207 ..Default::default()
208 }
209 } else {
210 HostConfig {
212 mounts: Some(mounts),
213 port_bindings: Some(port_bindings),
214 auto_remove: Some(false),
215 cap_add: Some(vec!["SETUID".to_string(), "SETGID".to_string()]),
218 ..Default::default()
219 }
220 };
221
222 let mut env = env_vars.unwrap_or_default();
224 if !has_env_key(&env, "XDG_DATA_HOME") {
225 env.push("XDG_DATA_HOME=/home/opencoder/.local/share".to_string());
226 }
227 if !has_env_key(&env, "XDG_STATE_HOME") {
228 env.push("XDG_STATE_HOME=/home/opencoder/.local/state".to_string());
229 }
230 if !has_env_key(&env, "XDG_CONFIG_HOME") {
231 env.push("XDG_CONFIG_HOME=/home/opencoder/.config".to_string());
232 }
233 if !has_env_key(&env, "XDG_CACHE_HOME") {
234 env.push("XDG_CACHE_HOME=/home/opencoder/.cache".to_string());
235 }
236 if systemd_enabled_val && !has_env_key(&env, "USE_SYSTEMD") {
238 env.push("USE_SYSTEMD=1".to_string());
239 }
240 let final_env = if env.is_empty() { None } else { Some(env) };
241
242 let mut labels = HashMap::from([("managed-by".to_string(), "opencode-cloud".to_string())]);
244 if let Some(instance_id) = names.instance_id.as_deref() {
245 labels.insert(INSTANCE_LABEL_KEY.to_string(), instance_id.to_string());
247 }
248
249 let config = ContainerCreateBody {
250 image: Some(image_name.to_string()),
251 hostname: Some(names.hostname),
252 working_dir: Some("/home/opencoder/workspace".to_string()),
253 exposed_ports: Some(exposed_ports),
254 env: final_env,
255 labels: Some(labels),
256 host_config: Some(host_config),
257 ..Default::default()
258 };
259
260 let options = CreateContainerOptions {
262 name: Some(container_name.clone()),
263 platform: String::new(),
264 };
265
266 let response = client
267 .inner()
268 .create_container(Some(options), config)
269 .await
270 .map_err(|e| {
271 let msg = e.to_string();
272 if msg.contains("port is already allocated") || msg.contains("address already in use") {
273 DockerError::Container(format!(
274 "Port {port} is already in use. Stop the service using that port or use a different port with --port."
275 ))
276 } else {
277 DockerError::Container(format!("Failed to create container: {e}"))
278 }
279 })?;
280
281 debug!("Container created with ID: {}", response.id);
282 Ok(response.id)
283}
284
285pub async fn start_container(client: &DockerClient, name: &str) -> Result<(), DockerError> {
287 let resolved_name = resolved_container_name(name);
288 debug!("Starting container: {}", resolved_name);
289
290 client
291 .inner()
292 .start_container(&resolved_name, None::<StartContainerOptions>)
293 .await
294 .map_err(|e| {
295 DockerError::Container(format!("Failed to start container {resolved_name}: {e}"))
296 })?;
297
298 debug!("Container {} started", resolved_name);
299 Ok(())
300}
301
302pub async fn stop_container(
309 client: &DockerClient,
310 name: &str,
311 timeout_secs: Option<i64>,
312) -> Result<(), DockerError> {
313 let resolved_name = resolved_container_name(name);
314 let timeout = timeout_secs.unwrap_or(10) as i32;
315 debug!(
316 "Stopping container {} with {}s timeout",
317 resolved_name, timeout
318 );
319
320 let options = StopContainerOptions {
321 signal: None,
322 t: Some(timeout),
323 };
324
325 client
326 .inner()
327 .stop_container(&resolved_name, Some(options))
328 .await
329 .map_err(|e| {
330 let msg = e.to_string();
331 if msg.contains("is not running") || msg.contains("304") {
333 debug!("Container {} was already stopped", resolved_name);
334 return DockerError::Container(format!(
335 "Container '{resolved_name}' is not running"
336 ));
337 }
338 DockerError::Container(format!("Failed to stop container {resolved_name}: {e}"))
339 })?;
340
341 debug!("Container {} stopped", resolved_name);
342 Ok(())
343}
344
345pub async fn remove_container(
352 client: &DockerClient,
353 name: &str,
354 force: bool,
355) -> Result<(), DockerError> {
356 let resolved_name = resolved_container_name(name);
357 debug!("Removing container {} (force={})", resolved_name, force);
358
359 let options = RemoveContainerOptions {
360 force,
361 v: false, link: false,
363 };
364
365 client
366 .inner()
367 .remove_container(&resolved_name, Some(options))
368 .await
369 .map_err(|e| {
370 DockerError::Container(format!("Failed to remove container {resolved_name}: {e}"))
371 })?;
372
373 debug!("Container {} removed", resolved_name);
374 Ok(())
375}
376
377pub async fn container_exists(client: &DockerClient, name: &str) -> Result<bool, DockerError> {
379 let resolved_name = resolved_container_name(name);
380 debug!("Checking if container exists: {}", resolved_name);
381
382 match client.inner().inspect_container(&resolved_name, None).await {
383 Ok(_) => Ok(true),
384 Err(bollard::errors::Error::DockerResponseServerError {
385 status_code: 404, ..
386 }) => Ok(false),
387 Err(e) => Err(DockerError::Container(format!(
388 "Failed to inspect container {resolved_name}: {e}"
389 ))),
390 }
391}
392
393pub async fn container_is_running(client: &DockerClient, name: &str) -> Result<bool, DockerError> {
395 let resolved_name = resolved_container_name(name);
396 debug!("Checking if container is running: {}", resolved_name);
397
398 match client.inner().inspect_container(&resolved_name, None).await {
399 Ok(info) => {
400 let running = info.state.and_then(|s| s.running).unwrap_or(false);
401 Ok(running)
402 }
403 Err(bollard::errors::Error::DockerResponseServerError {
404 status_code: 404, ..
405 }) => Ok(false),
406 Err(e) => Err(DockerError::Container(format!(
407 "Failed to inspect container {resolved_name}: {e}"
408 ))),
409 }
410}
411
412pub async fn container_state(client: &DockerClient, name: &str) -> Result<String, DockerError> {
414 let resolved_name = resolved_container_name(name);
415 debug!("Getting container state: {}", resolved_name);
416
417 match client.inner().inspect_container(&resolved_name, None).await {
418 Ok(info) => {
419 let state = info
420 .state
421 .and_then(|s| s.status)
422 .map(|s| s.to_string())
423 .unwrap_or_else(|| "unknown".to_string());
424 Ok(state)
425 }
426 Err(bollard::errors::Error::DockerResponseServerError {
427 status_code: 404, ..
428 }) => Err(DockerError::Container(format!(
429 "Container '{resolved_name}' not found"
430 ))),
431 Err(e) => Err(DockerError::Container(format!(
432 "Failed to inspect container {resolved_name}: {e}"
433 ))),
434 }
435}
436
437#[derive(Debug, Clone)]
439pub struct ContainerPorts {
440 pub opencode_port: Option<u16>,
442 pub cockpit_port: Option<u16>,
444}
445
446#[derive(Debug, Clone)]
448pub struct ContainerBindMount {
449 pub source: String,
451 pub target: String,
453 pub read_only: bool,
455}
456
457pub async fn get_container_ports(
462 client: &DockerClient,
463 name: &str,
464) -> Result<ContainerPorts, DockerError> {
465 let resolved_name = resolved_container_name(name);
466 debug!("Getting container ports: {}", resolved_name);
467
468 let info = client
469 .inner()
470 .inspect_container(&resolved_name, None)
471 .await
472 .map_err(|e| {
473 DockerError::Container(format!("Failed to inspect container {resolved_name}: {e}"))
474 })?;
475
476 let port_bindings = info
477 .host_config
478 .and_then(|hc| hc.port_bindings)
479 .unwrap_or_default();
480
481 let opencode_port = port_bindings
483 .get("3000/tcp")
484 .and_then(|bindings| bindings.as_ref())
485 .and_then(|bindings| bindings.first())
486 .and_then(|binding| binding.host_port.as_ref())
487 .and_then(|port_str| port_str.parse::<u16>().ok());
488
489 let cockpit_port = port_bindings
491 .get("9090/tcp")
492 .and_then(|bindings| bindings.as_ref())
493 .and_then(|bindings| bindings.first())
494 .and_then(|binding| binding.host_port.as_ref())
495 .and_then(|port_str| port_str.parse::<u16>().ok());
496
497 Ok(ContainerPorts {
498 opencode_port,
499 cockpit_port,
500 })
501}
502
503pub async fn get_container_bind_mounts(
507 client: &DockerClient,
508 name: &str,
509) -> Result<Vec<ContainerBindMount>, DockerError> {
510 let resolved_name = resolved_container_name(name);
511 debug!("Getting container bind mounts: {}", resolved_name);
512
513 let info = client
514 .inner()
515 .inspect_container(&resolved_name, None)
516 .await
517 .map_err(|e| {
518 DockerError::Container(format!("Failed to inspect container {resolved_name}: {e}"))
519 })?;
520
521 let mounts = info.mounts.unwrap_or_default();
522
523 let bind_mounts: Vec<ContainerBindMount> = mounts
525 .iter()
526 .filter(|m| m.typ == Some(MountPointTypeEnum::BIND))
527 .filter(|m| {
528 let target = m.destination.as_deref().unwrap_or("");
530 !target.starts_with("/sys/")
531 })
532 .map(|m| ContainerBindMount {
533 source: m.source.clone().unwrap_or_default(),
534 target: m.destination.clone().unwrap_or_default(),
535 read_only: m.rw.map(|rw| !rw).unwrap_or(false),
536 })
537 .collect();
538
539 Ok(bind_mounts)
540}
541
542#[cfg(test)]
543mod tests {
544 use super::*;
545 use crate::docker::IMAGE_TAG_DEFAULT;
546
547 #[test]
548 fn container_constants_are_correct() {
549 assert_eq!(CONTAINER_NAME, "opencode-cloud-sandbox");
550 assert_eq!(OPENCODE_WEB_PORT, 3000);
551 }
552
553 #[test]
554 fn default_image_format() {
555 let expected = format!("{IMAGE_NAME_GHCR}:{IMAGE_TAG_DEFAULT}");
556 assert_eq!(expected, "ghcr.io/prizz/opencode-cloud-sandbox:latest");
557 }
558}