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_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
141 if let Some(ref user_mounts) = bind_mounts {
143 for parsed in user_mounts {
144 mounts.push(parsed.to_bollard_mount());
145 }
146 }
147
148 let bind_addr = bind_address.unwrap_or("127.0.0.1");
150 let mut port_bindings: PortMap = HashMap::new();
151
152 port_bindings.insert(
154 "3000/tcp".to_string(),
155 Some(vec![PortBinding {
156 host_ip: Some(bind_addr.to_string()),
157 host_port: Some(port.to_string()),
158 }]),
159 );
160
161 if cockpit_enabled_val {
164 port_bindings.insert(
165 "9090/tcp".to_string(),
166 Some(vec![PortBinding {
167 host_ip: Some(bind_addr.to_string()),
168 host_port: Some(cockpit_port_val.to_string()),
169 }]),
170 );
171 }
172
173 let mut exposed_ports = vec!["3000/tcp".to_string()];
175 if cockpit_enabled_val {
176 exposed_ports.push("9090/tcp".to_string());
177 }
178
179 let host_config = if systemd_enabled_val {
183 HostConfig {
184 mounts: Some(mounts),
185 port_bindings: Some(port_bindings),
186 auto_remove: Some(false),
187 cap_add: Some(vec!["SYS_ADMIN".to_string()]),
189 tmpfs: Some(HashMap::from([
191 ("/run".to_string(), "exec".to_string()),
192 ("/run/lock".to_string(), String::new()),
193 ("/tmp".to_string(), String::new()),
194 ])),
195 binds: Some(vec!["/sys/fs/cgroup:/sys/fs/cgroup:rw".to_string()]),
197 cgroupns_mode: Some(bollard::models::HostConfigCgroupnsModeEnum::HOST),
204 privileged: Some(true),
206 ..Default::default()
207 }
208 } else {
209 HostConfig {
211 mounts: Some(mounts),
212 port_bindings: Some(port_bindings),
213 auto_remove: Some(false),
214 cap_add: Some(vec!["SETUID".to_string(), "SETGID".to_string()]),
217 ..Default::default()
218 }
219 };
220
221 let mut env = env_vars.unwrap_or_default();
223 if !has_env_key(&env, "XDG_DATA_HOME") {
224 env.push("XDG_DATA_HOME=/home/opencoder/.local/share".to_string());
225 }
226 if !has_env_key(&env, "XDG_STATE_HOME") {
227 env.push("XDG_STATE_HOME=/home/opencoder/.local/state".to_string());
228 }
229 if !has_env_key(&env, "XDG_CONFIG_HOME") {
230 env.push("XDG_CONFIG_HOME=/home/opencoder/.config".to_string());
231 }
232 if !has_env_key(&env, "XDG_CACHE_HOME") {
233 env.push("XDG_CACHE_HOME=/home/opencoder/.cache".to_string());
234 }
235 if systemd_enabled_val && !has_env_key(&env, "USE_SYSTEMD") {
237 env.push("USE_SYSTEMD=1".to_string());
238 }
239 let final_env = if env.is_empty() { None } else { Some(env) };
240
241 let mut labels = HashMap::from([("managed-by".to_string(), "opencode-cloud".to_string())]);
243 if let Some(instance_id) = names.instance_id.as_deref() {
244 labels.insert(INSTANCE_LABEL_KEY.to_string(), instance_id.to_string());
246 }
247
248 let config = ContainerCreateBody {
249 image: Some(image_name.to_string()),
250 hostname: Some(names.hostname),
251 working_dir: Some("/home/opencoder/workspace".to_string()),
252 exposed_ports: Some(exposed_ports),
253 env: final_env,
254 labels: Some(labels),
255 host_config: Some(host_config),
256 ..Default::default()
257 };
258
259 let options = CreateContainerOptions {
261 name: Some(container_name.clone()),
262 platform: String::new(),
263 };
264
265 let response = client
266 .inner()
267 .create_container(Some(options), config)
268 .await
269 .map_err(|e| {
270 let msg = e.to_string();
271 if msg.contains("port is already allocated") || msg.contains("address already in use") {
272 DockerError::Container(format!(
273 "Port {port} is already in use. Stop the service using that port or use a different port with --port."
274 ))
275 } else {
276 DockerError::Container(format!("Failed to create container: {e}"))
277 }
278 })?;
279
280 debug!("Container created with ID: {}", response.id);
281 Ok(response.id)
282}
283
284pub async fn start_container(client: &DockerClient, name: &str) -> Result<(), DockerError> {
286 let resolved_name = resolved_container_name(name);
287 debug!("Starting container: {}", resolved_name);
288
289 client
290 .inner()
291 .start_container(&resolved_name, None::<StartContainerOptions>)
292 .await
293 .map_err(|e| {
294 DockerError::Container(format!("Failed to start container {resolved_name}: {e}"))
295 })?;
296
297 debug!("Container {} started", resolved_name);
298 Ok(())
299}
300
301pub async fn stop_container(
308 client: &DockerClient,
309 name: &str,
310 timeout_secs: Option<i64>,
311) -> Result<(), DockerError> {
312 let resolved_name = resolved_container_name(name);
313 let timeout = timeout_secs.unwrap_or(10) as i32;
314 debug!(
315 "Stopping container {} with {}s timeout",
316 resolved_name, timeout
317 );
318
319 let options = StopContainerOptions {
320 signal: None,
321 t: Some(timeout),
322 };
323
324 client
325 .inner()
326 .stop_container(&resolved_name, Some(options))
327 .await
328 .map_err(|e| {
329 let msg = e.to_string();
330 if msg.contains("is not running") || msg.contains("304") {
332 debug!("Container {} was already stopped", resolved_name);
333 return DockerError::Container(format!(
334 "Container '{resolved_name}' is not running"
335 ));
336 }
337 DockerError::Container(format!("Failed to stop container {resolved_name}: {e}"))
338 })?;
339
340 debug!("Container {} stopped", resolved_name);
341 Ok(())
342}
343
344pub async fn remove_container(
351 client: &DockerClient,
352 name: &str,
353 force: bool,
354) -> Result<(), DockerError> {
355 let resolved_name = resolved_container_name(name);
356 debug!("Removing container {} (force={})", resolved_name, force);
357
358 let options = RemoveContainerOptions {
359 force,
360 v: false, link: false,
362 };
363
364 client
365 .inner()
366 .remove_container(&resolved_name, Some(options))
367 .await
368 .map_err(|e| {
369 DockerError::Container(format!("Failed to remove container {resolved_name}: {e}"))
370 })?;
371
372 debug!("Container {} removed", resolved_name);
373 Ok(())
374}
375
376pub async fn container_exists(client: &DockerClient, name: &str) -> Result<bool, DockerError> {
378 let resolved_name = resolved_container_name(name);
379 debug!("Checking if container exists: {}", resolved_name);
380
381 match client.inner().inspect_container(&resolved_name, None).await {
382 Ok(_) => Ok(true),
383 Err(bollard::errors::Error::DockerResponseServerError {
384 status_code: 404, ..
385 }) => Ok(false),
386 Err(e) => Err(DockerError::Container(format!(
387 "Failed to inspect container {resolved_name}: {e}"
388 ))),
389 }
390}
391
392pub async fn container_is_running(client: &DockerClient, name: &str) -> Result<bool, DockerError> {
394 let resolved_name = resolved_container_name(name);
395 debug!("Checking if container is running: {}", resolved_name);
396
397 match client.inner().inspect_container(&resolved_name, None).await {
398 Ok(info) => {
399 let running = info.state.and_then(|s| s.running).unwrap_or(false);
400 Ok(running)
401 }
402 Err(bollard::errors::Error::DockerResponseServerError {
403 status_code: 404, ..
404 }) => Ok(false),
405 Err(e) => Err(DockerError::Container(format!(
406 "Failed to inspect container {resolved_name}: {e}"
407 ))),
408 }
409}
410
411pub async fn container_state(client: &DockerClient, name: &str) -> Result<String, DockerError> {
413 let resolved_name = resolved_container_name(name);
414 debug!("Getting container state: {}", resolved_name);
415
416 match client.inner().inspect_container(&resolved_name, None).await {
417 Ok(info) => {
418 let state = info
419 .state
420 .and_then(|s| s.status)
421 .map(|s| s.to_string())
422 .unwrap_or_else(|| "unknown".to_string());
423 Ok(state)
424 }
425 Err(bollard::errors::Error::DockerResponseServerError {
426 status_code: 404, ..
427 }) => Err(DockerError::Container(format!(
428 "Container '{resolved_name}' not found"
429 ))),
430 Err(e) => Err(DockerError::Container(format!(
431 "Failed to inspect container {resolved_name}: {e}"
432 ))),
433 }
434}
435
436#[derive(Debug, Clone)]
438pub struct ContainerPorts {
439 pub opencode_port: Option<u16>,
441 pub cockpit_port: Option<u16>,
443}
444
445#[derive(Debug, Clone)]
447pub struct ContainerBindMount {
448 pub source: String,
450 pub target: String,
452 pub read_only: bool,
454}
455
456pub async fn get_container_ports(
461 client: &DockerClient,
462 name: &str,
463) -> Result<ContainerPorts, DockerError> {
464 let resolved_name = resolved_container_name(name);
465 debug!("Getting container ports: {}", resolved_name);
466
467 let info = client
468 .inner()
469 .inspect_container(&resolved_name, None)
470 .await
471 .map_err(|e| {
472 DockerError::Container(format!("Failed to inspect container {resolved_name}: {e}"))
473 })?;
474
475 let port_bindings = info
476 .host_config
477 .and_then(|hc| hc.port_bindings)
478 .unwrap_or_default();
479
480 let opencode_port = port_bindings
482 .get("3000/tcp")
483 .and_then(|bindings| bindings.as_ref())
484 .and_then(|bindings| bindings.first())
485 .and_then(|binding| binding.host_port.as_ref())
486 .and_then(|port_str| port_str.parse::<u16>().ok());
487
488 let cockpit_port = port_bindings
490 .get("9090/tcp")
491 .and_then(|bindings| bindings.as_ref())
492 .and_then(|bindings| bindings.first())
493 .and_then(|binding| binding.host_port.as_ref())
494 .and_then(|port_str| port_str.parse::<u16>().ok());
495
496 Ok(ContainerPorts {
497 opencode_port,
498 cockpit_port,
499 })
500}
501
502pub async fn get_container_bind_mounts(
506 client: &DockerClient,
507 name: &str,
508) -> Result<Vec<ContainerBindMount>, DockerError> {
509 let resolved_name = resolved_container_name(name);
510 debug!("Getting container bind mounts: {}", resolved_name);
511
512 let info = client
513 .inner()
514 .inspect_container(&resolved_name, None)
515 .await
516 .map_err(|e| {
517 DockerError::Container(format!("Failed to inspect container {resolved_name}: {e}"))
518 })?;
519
520 let mounts = info.mounts.unwrap_or_default();
521
522 let bind_mounts: Vec<ContainerBindMount> = mounts
524 .iter()
525 .filter(|m| m.typ == Some(MountPointTypeEnum::BIND))
526 .filter(|m| {
527 let target = m.destination.as_deref().unwrap_or("");
529 !target.starts_with("/sys/")
530 })
531 .map(|m| ContainerBindMount {
532 source: m.source.clone().unwrap_or_default(),
533 target: m.destination.clone().unwrap_or_default(),
534 read_only: m.rw.map(|rw| !rw).unwrap_or(false),
535 })
536 .collect();
537
538 Ok(bind_mounts)
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544 use crate::docker::IMAGE_TAG_DEFAULT;
545
546 #[test]
547 fn container_constants_are_correct() {
548 assert_eq!(CONTAINER_NAME, "opencode-cloud-sandbox");
549 assert_eq!(OPENCODE_WEB_PORT, 3000);
550 }
551
552 #[test]
553 fn default_image_format() {
554 let expected = format!("{IMAGE_NAME_GHCR}:{IMAGE_TAG_DEFAULT}");
555 assert_eq!(expected, "ghcr.io/prizz/opencode-cloud-sandbox:latest");
556 }
557}