1use super::dockerfile::{IMAGE_NAME_GHCR, IMAGE_TAG_DEFAULT};
7use super::mount::ParsedMount;
8use super::volume::{
9 MOUNT_CONFIG, MOUNT_PROJECTS, MOUNT_SESSION, VOLUME_CONFIG, VOLUME_PROJECTS, VOLUME_SESSION,
10};
11use super::{DockerClient, DockerError};
12use bollard::container::{
13 Config, CreateContainerOptions, RemoveContainerOptions, StartContainerOptions,
14 StopContainerOptions,
15};
16use bollard::service::{
17 HostConfig, Mount, MountPointTypeEnum, MountTypeEnum, PortBinding, PortMap,
18};
19use std::collections::HashMap;
20use tracing::debug;
21
22pub const CONTAINER_NAME: &str = "opencode-cloud";
24
25pub const OPENCODE_WEB_PORT: u16 = 3000;
27
28#[allow(clippy::too_many_arguments)]
44pub async fn create_container(
45 client: &DockerClient,
46 name: Option<&str>,
47 image: Option<&str>,
48 opencode_web_port: Option<u16>,
49 env_vars: Option<Vec<String>>,
50 bind_address: Option<&str>,
51 cockpit_port: Option<u16>,
52 cockpit_enabled: Option<bool>,
53 bind_mounts: Option<Vec<ParsedMount>>,
54) -> Result<String, DockerError> {
55 let container_name = name.unwrap_or(CONTAINER_NAME);
56 let default_image = format!("{IMAGE_NAME_GHCR}:{IMAGE_TAG_DEFAULT}");
57 let image_name = image.unwrap_or(&default_image);
58 let port = opencode_web_port.unwrap_or(OPENCODE_WEB_PORT);
59 let cockpit_port_val = cockpit_port.unwrap_or(9090);
60 let cockpit_enabled_val = cockpit_enabled.unwrap_or(true);
61
62 debug!(
63 "Creating container {} from image {} with port {} and cockpit_port {} (enabled: {})",
64 container_name, image_name, port, cockpit_port_val, cockpit_enabled_val
65 );
66
67 if container_exists(client, container_name).await? {
69 return Err(DockerError::Container(format!(
70 "Container '{container_name}' already exists. Remove it first with 'occ stop --remove' or use a different name."
71 )));
72 }
73
74 let image_parts: Vec<&str> = image_name.split(':').collect();
76 let (image_repo, image_tag) = if image_parts.len() == 2 {
77 (image_parts[0], image_parts[1])
78 } else {
79 (image_name, "latest")
80 };
81
82 if !super::image::image_exists(client, image_repo, image_tag).await? {
83 return Err(DockerError::Container(format!(
84 "Image '{image_name}' not found. Run 'occ pull' first to download the image."
85 )));
86 }
87
88 let mut mounts = vec![
90 Mount {
91 target: Some(MOUNT_SESSION.to_string()),
92 source: Some(VOLUME_SESSION.to_string()),
93 typ: Some(MountTypeEnum::VOLUME),
94 read_only: Some(false),
95 ..Default::default()
96 },
97 Mount {
98 target: Some(MOUNT_PROJECTS.to_string()),
99 source: Some(VOLUME_PROJECTS.to_string()),
100 typ: Some(MountTypeEnum::VOLUME),
101 read_only: Some(false),
102 ..Default::default()
103 },
104 Mount {
105 target: Some(MOUNT_CONFIG.to_string()),
106 source: Some(VOLUME_CONFIG.to_string()),
107 typ: Some(MountTypeEnum::VOLUME),
108 read_only: Some(false),
109 ..Default::default()
110 },
111 ];
112
113 if let Some(ref user_mounts) = bind_mounts {
115 for parsed in user_mounts {
116 mounts.push(parsed.to_bollard_mount());
117 }
118 }
119
120 let bind_addr = bind_address.unwrap_or("127.0.0.1");
122 let mut port_bindings: PortMap = HashMap::new();
123
124 port_bindings.insert(
126 "3000/tcp".to_string(),
127 Some(vec![PortBinding {
128 host_ip: Some(bind_addr.to_string()),
129 host_port: Some(port.to_string()),
130 }]),
131 );
132
133 if cockpit_enabled_val {
136 port_bindings.insert(
137 "9090/tcp".to_string(),
138 Some(vec![PortBinding {
139 host_ip: Some(bind_addr.to_string()),
140 host_port: Some(cockpit_port_val.to_string()),
141 }]),
142 );
143 }
144
145 let mut exposed_ports = HashMap::new();
147 exposed_ports.insert("3000/tcp".to_string(), HashMap::new());
148 if cockpit_enabled_val {
149 exposed_ports.insert("9090/tcp".to_string(), HashMap::new());
150 }
151
152 let host_config = if cockpit_enabled_val {
156 HostConfig {
157 mounts: Some(mounts),
158 port_bindings: Some(port_bindings),
159 auto_remove: Some(false),
160 cap_add: Some(vec!["SYS_ADMIN".to_string()]),
162 tmpfs: Some(HashMap::from([
164 ("/run".to_string(), "exec".to_string()),
165 ("/run/lock".to_string(), String::new()),
166 ("/tmp".to_string(), String::new()),
167 ])),
168 binds: Some(vec!["/sys/fs/cgroup:/sys/fs/cgroup:rw".to_string()]),
170 cgroupns_mode: Some(bollard::models::HostConfigCgroupnsModeEnum::HOST),
177 privileged: Some(true),
179 ..Default::default()
180 }
181 } else {
182 HostConfig {
184 mounts: Some(mounts),
185 port_bindings: Some(port_bindings),
186 auto_remove: Some(false),
187 ..Default::default()
188 }
189 };
190
191 let final_env = if cockpit_enabled_val {
194 let mut env = env_vars.unwrap_or_default();
195 env.push("USE_SYSTEMD=1".to_string());
196 Some(env)
197 } else {
198 env_vars
199 };
200
201 let config = Config {
203 image: Some(image_name.to_string()),
204 hostname: Some(CONTAINER_NAME.to_string()),
205 working_dir: Some("/workspace".to_string()),
206 exposed_ports: Some(exposed_ports),
207 env: final_env,
208 host_config: Some(host_config),
209 ..Default::default()
210 };
211
212 let options = CreateContainerOptions {
214 name: container_name,
215 platform: None,
216 };
217
218 let response = client
219 .inner()
220 .create_container(Some(options), config)
221 .await
222 .map_err(|e| {
223 let msg = e.to_string();
224 if msg.contains("port is already allocated") || msg.contains("address already in use") {
225 DockerError::Container(format!(
226 "Port {port} is already in use. Stop the service using that port or use a different port with --port."
227 ))
228 } else {
229 DockerError::Container(format!("Failed to create container: {e}"))
230 }
231 })?;
232
233 debug!("Container created with ID: {}", response.id);
234 Ok(response.id)
235}
236
237pub async fn start_container(client: &DockerClient, name: &str) -> Result<(), DockerError> {
239 debug!("Starting container: {}", name);
240
241 client
242 .inner()
243 .start_container(name, None::<StartContainerOptions<String>>)
244 .await
245 .map_err(|e| DockerError::Container(format!("Failed to start container {name}: {e}")))?;
246
247 debug!("Container {} started", name);
248 Ok(())
249}
250
251pub async fn stop_container(
258 client: &DockerClient,
259 name: &str,
260 timeout_secs: Option<i64>,
261) -> Result<(), DockerError> {
262 let timeout = timeout_secs.unwrap_or(10);
263 debug!("Stopping container {} with {}s timeout", name, timeout);
264
265 let options = StopContainerOptions { t: timeout };
266
267 client
268 .inner()
269 .stop_container(name, Some(options))
270 .await
271 .map_err(|e| {
272 let msg = e.to_string();
273 if msg.contains("is not running") || msg.contains("304") {
275 debug!("Container {} was already stopped", name);
276 return DockerError::Container(format!("Container '{name}' is not running"));
277 }
278 DockerError::Container(format!("Failed to stop container {name}: {e}"))
279 })?;
280
281 debug!("Container {} stopped", name);
282 Ok(())
283}
284
285pub async fn remove_container(
292 client: &DockerClient,
293 name: &str,
294 force: bool,
295) -> Result<(), DockerError> {
296 debug!("Removing container {} (force={})", name, force);
297
298 let options = RemoveContainerOptions {
299 force,
300 v: false, link: false,
302 };
303
304 client
305 .inner()
306 .remove_container(name, Some(options))
307 .await
308 .map_err(|e| DockerError::Container(format!("Failed to remove container {name}: {e}")))?;
309
310 debug!("Container {} removed", name);
311 Ok(())
312}
313
314pub async fn container_exists(client: &DockerClient, name: &str) -> Result<bool, DockerError> {
316 debug!("Checking if container exists: {}", name);
317
318 match client.inner().inspect_container(name, None).await {
319 Ok(_) => Ok(true),
320 Err(bollard::errors::Error::DockerResponseServerError {
321 status_code: 404, ..
322 }) => Ok(false),
323 Err(e) => Err(DockerError::Container(format!(
324 "Failed to inspect container {name}: {e}"
325 ))),
326 }
327}
328
329pub async fn container_is_running(client: &DockerClient, name: &str) -> Result<bool, DockerError> {
331 debug!("Checking if container is running: {}", name);
332
333 match client.inner().inspect_container(name, None).await {
334 Ok(info) => {
335 let running = info.state.and_then(|s| s.running).unwrap_or(false);
336 Ok(running)
337 }
338 Err(bollard::errors::Error::DockerResponseServerError {
339 status_code: 404, ..
340 }) => Ok(false),
341 Err(e) => Err(DockerError::Container(format!(
342 "Failed to inspect container {name}: {e}"
343 ))),
344 }
345}
346
347pub async fn container_state(client: &DockerClient, name: &str) -> Result<String, DockerError> {
349 debug!("Getting container state: {}", name);
350
351 match client.inner().inspect_container(name, None).await {
352 Ok(info) => {
353 let state = info
354 .state
355 .and_then(|s| s.status)
356 .map(|s| s.to_string())
357 .unwrap_or_else(|| "unknown".to_string());
358 Ok(state)
359 }
360 Err(bollard::errors::Error::DockerResponseServerError {
361 status_code: 404, ..
362 }) => Err(DockerError::Container(format!(
363 "Container '{name}' not found"
364 ))),
365 Err(e) => Err(DockerError::Container(format!(
366 "Failed to inspect container {name}: {e}"
367 ))),
368 }
369}
370
371#[derive(Debug, Clone)]
373pub struct ContainerPorts {
374 pub opencode_port: Option<u16>,
376 pub cockpit_port: Option<u16>,
378}
379
380#[derive(Debug, Clone)]
382pub struct ContainerBindMount {
383 pub source: String,
385 pub target: String,
387 pub read_only: bool,
389}
390
391pub async fn get_container_ports(
396 client: &DockerClient,
397 name: &str,
398) -> Result<ContainerPorts, DockerError> {
399 debug!("Getting container ports: {}", name);
400
401 let info = client
402 .inner()
403 .inspect_container(name, None)
404 .await
405 .map_err(|e| DockerError::Container(format!("Failed to inspect container {name}: {e}")))?;
406
407 let port_bindings = info
408 .host_config
409 .and_then(|hc| hc.port_bindings)
410 .unwrap_or_default();
411
412 let opencode_port = port_bindings
414 .get("3000/tcp")
415 .and_then(|bindings| bindings.as_ref())
416 .and_then(|bindings| bindings.first())
417 .and_then(|binding| binding.host_port.as_ref())
418 .and_then(|port_str| port_str.parse::<u16>().ok());
419
420 let cockpit_port = port_bindings
422 .get("9090/tcp")
423 .and_then(|bindings| bindings.as_ref())
424 .and_then(|bindings| bindings.first())
425 .and_then(|binding| binding.host_port.as_ref())
426 .and_then(|port_str| port_str.parse::<u16>().ok());
427
428 Ok(ContainerPorts {
429 opencode_port,
430 cockpit_port,
431 })
432}
433
434pub async fn get_container_bind_mounts(
438 client: &DockerClient,
439 name: &str,
440) -> Result<Vec<ContainerBindMount>, DockerError> {
441 debug!("Getting container bind mounts: {}", name);
442
443 let info = client
444 .inner()
445 .inspect_container(name, None)
446 .await
447 .map_err(|e| DockerError::Container(format!("Failed to inspect container {name}: {e}")))?;
448
449 let mounts = info.mounts.unwrap_or_default();
450
451 let bind_mounts: Vec<ContainerBindMount> = mounts
453 .iter()
454 .filter(|m| m.typ == Some(MountPointTypeEnum::BIND))
455 .filter(|m| {
456 let target = m.destination.as_deref().unwrap_or("");
458 !target.starts_with("/sys/")
459 })
460 .map(|m| ContainerBindMount {
461 source: m.source.clone().unwrap_or_default(),
462 target: m.destination.clone().unwrap_or_default(),
463 read_only: m.rw.map(|rw| !rw).unwrap_or(false),
464 })
465 .collect();
466
467 Ok(bind_mounts)
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473
474 #[test]
475 fn container_constants_are_correct() {
476 assert_eq!(CONTAINER_NAME, "opencode-cloud");
477 assert_eq!(OPENCODE_WEB_PORT, 3000);
478 }
479
480 #[test]
481 fn default_image_format() {
482 let expected = format!("{IMAGE_NAME_GHCR}:{IMAGE_TAG_DEFAULT}");
483 assert_eq!(expected, "ghcr.io/prizz/opencode-cloud-sandbox:latest");
484 }
485}