opencode_cloud_core/docker/
container.rs1use super::dockerfile::{IMAGE_NAME_GHCR, IMAGE_TAG_DEFAULT};
7use super::volume::{
8 MOUNT_CONFIG, MOUNT_PROJECTS, MOUNT_SESSION, VOLUME_CONFIG, VOLUME_PROJECTS, VOLUME_SESSION,
9};
10use super::{DockerClient, DockerError};
11use bollard::container::{
12 Config, CreateContainerOptions, RemoveContainerOptions, StartContainerOptions,
13 StopContainerOptions,
14};
15use bollard::service::{HostConfig, Mount, MountTypeEnum, PortBinding, PortMap};
16use std::collections::HashMap;
17use tracing::debug;
18
19pub const CONTAINER_NAME: &str = "opencode-cloud";
21
22pub const OPENCODE_WEB_PORT: u16 = 3000;
24
25pub async fn create_container(
37 client: &DockerClient,
38 name: Option<&str>,
39 image: Option<&str>,
40 opencode_web_port: Option<u16>,
41 env_vars: Option<Vec<String>>,
42) -> Result<String, DockerError> {
43 let container_name = name.unwrap_or(CONTAINER_NAME);
44 let default_image = format!("{IMAGE_NAME_GHCR}:{IMAGE_TAG_DEFAULT}");
45 let image_name = image.unwrap_or(&default_image);
46 let port = opencode_web_port.unwrap_or(OPENCODE_WEB_PORT);
47
48 debug!(
49 "Creating container {} from image {} with port {}",
50 container_name, image_name, port
51 );
52
53 if container_exists(client, container_name).await? {
55 return Err(DockerError::Container(format!(
56 "Container '{}' already exists. Remove it first with 'occ stop --remove' or use a different name.",
57 container_name
58 )));
59 }
60
61 let image_parts: Vec<&str> = image_name.split(':').collect();
63 let (image_repo, image_tag) = if image_parts.len() == 2 {
64 (image_parts[0], image_parts[1])
65 } else {
66 (image_name, "latest")
67 };
68
69 if !super::image::image_exists(client, image_repo, image_tag).await? {
70 return Err(DockerError::Container(format!(
71 "Image '{}' not found. Run 'occ pull' first to download the image.",
72 image_name
73 )));
74 }
75
76 let mounts = vec![
78 Mount {
79 target: Some(MOUNT_SESSION.to_string()),
80 source: Some(VOLUME_SESSION.to_string()),
81 typ: Some(MountTypeEnum::VOLUME),
82 read_only: Some(false),
83 ..Default::default()
84 },
85 Mount {
86 target: Some(MOUNT_PROJECTS.to_string()),
87 source: Some(VOLUME_PROJECTS.to_string()),
88 typ: Some(MountTypeEnum::VOLUME),
89 read_only: Some(false),
90 ..Default::default()
91 },
92 Mount {
93 target: Some(MOUNT_CONFIG.to_string()),
94 source: Some(VOLUME_CONFIG.to_string()),
95 typ: Some(MountTypeEnum::VOLUME),
96 read_only: Some(false),
97 ..Default::default()
98 },
99 ];
100
101 let mut port_bindings: PortMap = HashMap::new();
103 port_bindings.insert(
104 "3000/tcp".to_string(),
105 Some(vec![PortBinding {
106 host_ip: Some("127.0.0.1".to_string()),
107 host_port: Some(port.to_string()),
108 }]),
109 );
110
111 let mut exposed_ports = HashMap::new();
113 exposed_ports.insert("3000/tcp".to_string(), HashMap::new());
114
115 let host_config = HostConfig {
117 mounts: Some(mounts),
118 port_bindings: Some(port_bindings),
119 auto_remove: Some(false),
120 ..Default::default()
121 };
122
123 let config = Config {
125 image: Some(image_name.to_string()),
126 hostname: Some(CONTAINER_NAME.to_string()),
127 working_dir: Some("/workspace".to_string()),
128 exposed_ports: Some(exposed_ports),
129 env: env_vars,
130 host_config: Some(host_config),
131 ..Default::default()
132 };
133
134 let options = CreateContainerOptions {
136 name: container_name,
137 platform: None,
138 };
139
140 let response = client
141 .inner()
142 .create_container(Some(options), config)
143 .await
144 .map_err(|e| {
145 let msg = e.to_string();
146 if msg.contains("port is already allocated") || msg.contains("address already in use") {
147 DockerError::Container(format!(
148 "Port {} is already in use. Stop the service using that port or use a different port with --port.",
149 port
150 ))
151 } else {
152 DockerError::Container(format!("Failed to create container: {}", e))
153 }
154 })?;
155
156 debug!("Container created with ID: {}", response.id);
157 Ok(response.id)
158}
159
160pub async fn start_container(client: &DockerClient, name: &str) -> Result<(), DockerError> {
162 debug!("Starting container: {}", name);
163
164 client
165 .inner()
166 .start_container(name, None::<StartContainerOptions<String>>)
167 .await
168 .map_err(|e| {
169 DockerError::Container(format!("Failed to start container {}: {}", name, e))
170 })?;
171
172 debug!("Container {} started", name);
173 Ok(())
174}
175
176pub async fn stop_container(
183 client: &DockerClient,
184 name: &str,
185 timeout_secs: Option<i64>,
186) -> Result<(), DockerError> {
187 let timeout = timeout_secs.unwrap_or(10);
188 debug!("Stopping container {} with {}s timeout", name, timeout);
189
190 let options = StopContainerOptions { t: timeout };
191
192 client
193 .inner()
194 .stop_container(name, Some(options))
195 .await
196 .map_err(|e| {
197 let msg = e.to_string();
198 if msg.contains("is not running") || msg.contains("304") {
200 debug!("Container {} was already stopped", name);
201 return DockerError::Container(format!("Container '{}' is not running", name));
202 }
203 DockerError::Container(format!("Failed to stop container {}: {}", name, e))
204 })?;
205
206 debug!("Container {} stopped", name);
207 Ok(())
208}
209
210pub async fn remove_container(
217 client: &DockerClient,
218 name: &str,
219 force: bool,
220) -> Result<(), DockerError> {
221 debug!("Removing container {} (force={})", name, force);
222
223 let options = RemoveContainerOptions {
224 force,
225 v: false, link: false,
227 };
228
229 client
230 .inner()
231 .remove_container(name, Some(options))
232 .await
233 .map_err(|e| {
234 DockerError::Container(format!("Failed to remove container {}: {}", name, e))
235 })?;
236
237 debug!("Container {} removed", name);
238 Ok(())
239}
240
241pub async fn container_exists(client: &DockerClient, name: &str) -> Result<bool, DockerError> {
243 debug!("Checking if container exists: {}", name);
244
245 match client.inner().inspect_container(name, None).await {
246 Ok(_) => Ok(true),
247 Err(bollard::errors::Error::DockerResponseServerError {
248 status_code: 404, ..
249 }) => Ok(false),
250 Err(e) => Err(DockerError::Container(format!(
251 "Failed to inspect container {}: {}",
252 name, e
253 ))),
254 }
255}
256
257pub async fn container_is_running(client: &DockerClient, name: &str) -> Result<bool, DockerError> {
259 debug!("Checking if container is running: {}", name);
260
261 match client.inner().inspect_container(name, None).await {
262 Ok(info) => {
263 let running = info.state.and_then(|s| s.running).unwrap_or(false);
264 Ok(running)
265 }
266 Err(bollard::errors::Error::DockerResponseServerError {
267 status_code: 404, ..
268 }) => Ok(false),
269 Err(e) => Err(DockerError::Container(format!(
270 "Failed to inspect container {}: {}",
271 name, e
272 ))),
273 }
274}
275
276pub async fn container_state(client: &DockerClient, name: &str) -> Result<String, DockerError> {
278 debug!("Getting container state: {}", name);
279
280 match client.inner().inspect_container(name, None).await {
281 Ok(info) => {
282 let state = info
283 .state
284 .and_then(|s| s.status)
285 .map(|s| s.to_string())
286 .unwrap_or_else(|| "unknown".to_string());
287 Ok(state)
288 }
289 Err(bollard::errors::Error::DockerResponseServerError {
290 status_code: 404, ..
291 }) => Err(DockerError::Container(format!(
292 "Container '{}' not found",
293 name
294 ))),
295 Err(e) => Err(DockerError::Container(format!(
296 "Failed to inspect container {}: {}",
297 name, e
298 ))),
299 }
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 #[test]
307 fn container_constants_are_correct() {
308 assert_eq!(CONTAINER_NAME, "opencode-cloud");
309 assert_eq!(OPENCODE_WEB_PORT, 3000);
310 }
311
312 #[test]
313 fn default_image_format() {
314 let expected = format!("{IMAGE_NAME_GHCR}:{IMAGE_TAG_DEFAULT}");
315 assert_eq!(expected, "ghcr.io/prizz/opencode-cloud:latest");
316 }
317}