opencode-cloud-core 25.1.3

Core library for opencode-cloud - config management, singleton enforcement, and shared utilities
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
//! Docker container lifecycle management
//!
//! This module provides functions to create, start, stop, and remove
//! Docker containers for the opencode-cloud service.

use super::dockerfile::IMAGE_NAME_GHCR;
use super::mount::ParsedMount;
use super::profile::{INSTANCE_LABEL_KEY, active_resource_names, remap_container_name};
use super::volume::{
    MOUNT_CACHE, MOUNT_CONFIG, MOUNT_PROJECTS, MOUNT_SESSION, MOUNT_SSH, MOUNT_STATE, MOUNT_USERS,
};
use super::{DockerClient, DockerError};
use bollard::models::ContainerCreateBody;
use bollard::query_parameters::{
    CreateContainerOptions, RemoveContainerOptions, StartContainerOptions, StopContainerOptions,
};
use bollard::service::{
    HostConfig, Mount, MountPointTypeEnum, MountTypeEnum, PortBinding, PortMap,
};
use std::collections::{HashMap, HashSet};
use tracing::debug;

/// Default container name
pub const CONTAINER_NAME: &str = "opencode-cloud-sandbox";

/// Default port for opencode web UI
pub const OPENCODE_WEB_PORT: u16 = 3000;

fn has_env_key(env: &[String], key: &str) -> bool {
    let prefix = format!("{key}=");
    env.iter().any(|entry| entry.starts_with(&prefix))
}

fn resolved_container_name(name: &str) -> String {
    remap_container_name(name)
}

/// Create the opencode container with volume mounts
///
/// Does not start the container - use start_container after creation.
/// Returns the container ID on success.
///
/// # Arguments
/// * `client` - Docker client
/// * `name` - Container name (defaults to CONTAINER_NAME)
/// * `image` - Image to use (defaults to IMAGE_NAME_GHCR:IMAGE_TAG_DEFAULT)
/// * `opencode_web_port` - Port to bind on host for opencode web UI (defaults to OPENCODE_WEB_PORT)
/// * `env_vars` - Additional environment variables (optional)
/// * `bind_address` - IP address to bind on host (defaults to "127.0.0.1")
/// * `cockpit_port` - Port to bind on host for Cockpit (defaults to 9090)
/// * `cockpit_enabled` - Whether to enable Cockpit port mapping (defaults to false)
/// * `systemd_enabled` - Whether to use systemd as init (defaults to false)
/// * `bind_mounts` - User-defined bind mounts from config and CLI flags (optional)
#[allow(clippy::too_many_arguments)]
pub async fn create_container(
    client: &DockerClient,
    name: Option<&str>,
    image: Option<&str>,
    opencode_web_port: Option<u16>,
    env_vars: Option<Vec<String>>,
    bind_address: Option<&str>,
    cockpit_port: Option<u16>,
    cockpit_enabled: Option<bool>,
    systemd_enabled: Option<bool>,
    bind_mounts: Option<Vec<ParsedMount>>,
) -> Result<String, DockerError> {
    let names = active_resource_names();
    let container_name = name
        .map(resolved_container_name)
        .unwrap_or(names.container_name);
    let default_image = format!("{IMAGE_NAME_GHCR}:{}", names.image_tag);
    let image_name = image.unwrap_or(&default_image);
    let port = opencode_web_port.unwrap_or(OPENCODE_WEB_PORT);
    let cockpit_port_val = cockpit_port.unwrap_or(9090);
    let cockpit_enabled_val = cockpit_enabled.unwrap_or(false);
    let systemd_enabled_val = systemd_enabled.unwrap_or(false);

    debug!(
        "Creating container {} from image {} with port {} and cockpit_port {} (enabled: {}, systemd: {})",
        container_name,
        image_name,
        port,
        cockpit_port_val,
        cockpit_enabled_val,
        systemd_enabled_val
    );

    // Check if container already exists
    if container_exists(client, &container_name).await? {
        return Err(DockerError::Container(format!(
            "Container '{container_name}' already exists. Remove it first with 'occ stop --remove' or use a different name."
        )));
    }

    // Check if image exists
    let image_parts: Vec<&str> = image_name.split(':').collect();
    let (image_repo, image_tag) = if image_parts.len() == 2 {
        (image_parts[0], image_parts[1])
    } else {
        (image_name, "latest")
    };

    if !super::image::image_exists(client, image_repo, image_tag).await? {
        return Err(DockerError::Container(format!(
            "Image '{image_name}' not found. Run 'occ pull' first to download the image."
        )));
    }

    let mut bind_targets = HashSet::new();
    if let Some(ref user_mounts) = bind_mounts {
        for parsed in user_mounts {
            bind_targets.insert(parsed.container_path.clone());
        }
    }

    // Create volume mounts (skip if overridden by bind mounts)
    let mut mounts = Vec::new();
    let mut add_volume_mount = |target: &str, source: &str| {
        if bind_targets.contains(target) {
            tracing::trace!(
                "Skipping volume mount for {} (overridden by bind mount)",
                target
            );
            return;
        }
        mounts.push(Mount {
            target: Some(target.to_string()),
            source: Some(source.to_string()),
            typ: Some(MountTypeEnum::VOLUME),
            read_only: Some(false),
            ..Default::default()
        });
    };
    add_volume_mount(MOUNT_SESSION, &names.volume_session);
    add_volume_mount(MOUNT_STATE, &names.volume_state);
    add_volume_mount(MOUNT_CACHE, &names.volume_cache);
    add_volume_mount(MOUNT_PROJECTS, &names.volume_projects);
    add_volume_mount(MOUNT_CONFIG, &names.volume_config);
    add_volume_mount(MOUNT_USERS, &names.volume_users);
    add_volume_mount(MOUNT_SSH, &names.volume_ssh);

    // Add user-defined bind mounts from config/CLI
    if let Some(ref user_mounts) = bind_mounts {
        for parsed in user_mounts {
            mounts.push(parsed.to_bollard_mount());
        }
    }

    // Create port bindings (default to localhost for security)
    let bind_addr = bind_address.unwrap_or("127.0.0.1");
    let mut port_bindings: PortMap = HashMap::new();

    // opencode web port
    port_bindings.insert(
        "3000/tcp".to_string(),
        Some(vec![PortBinding {
            host_ip: Some(bind_addr.to_string()),
            host_port: Some(port.to_string()),
        }]),
    );

    // Cockpit port (if enabled)
    // Container always listens on 9090, map to host's configured port
    if cockpit_enabled_val {
        port_bindings.insert(
            "9090/tcp".to_string(),
            Some(vec![PortBinding {
                host_ip: Some(bind_addr.to_string()),
                host_port: Some(cockpit_port_val.to_string()),
            }]),
        );
    }

    // Create exposed ports list (bollard v0.20+ uses Vec<String>)
    let mut exposed_ports = vec!["3000/tcp".to_string()];
    if cockpit_enabled_val {
        exposed_ports.push("9090/tcp".to_string());
    }

    // Create host config
    // When systemd is enabled, add systemd-specific settings (requires Linux host)
    // When systemd is disabled, use simpler tini-based config (works everywhere)
    let host_config = if systemd_enabled_val {
        HostConfig {
            mounts: Some(mounts),
            port_bindings: Some(port_bindings),
            auto_remove: Some(false),
            // CAP_SYS_ADMIN required for systemd cgroup access
            cap_add: Some(vec!["SYS_ADMIN".to_string()]),
            // tmpfs for /run, /run/lock, and /tmp (required for systemd)
            tmpfs: Some(HashMap::from([
                ("/run".to_string(), "exec".to_string()),
                ("/run/lock".to_string(), String::new()),
                ("/tmp".to_string(), String::new()),
            ])),
            // cgroup mount (read-write for systemd)
            binds: Some(vec!["/sys/fs/cgroup:/sys/fs/cgroup:rw".to_string()]),
            // Use HOST cgroup namespace for systemd compatibility across Linux distros:
            // - cgroups v2 (Amazon Linux 2023, Fedora 31+, Ubuntu 21.10+, Debian 11+): required
            // - cgroups v1 (CentOS 7, Ubuntu 18.04, Debian 10): works fine
            // - Docker Desktop (macOS/Windows VM): works fine
            // Note: PRIVATE mode is more isolated but causes systemd to exit(255) on cgroups v2.
            // Since we already use privileged mode, HOST namespace is acceptable.
            cgroupns_mode: Some(bollard::models::HostConfigCgroupnsModeEnum::HOST),
            // Privileged mode required for systemd to manage cgroups and system services
            privileged: Some(true),
            ..Default::default()
        }
    } else {
        // Simple config for tini mode (works on macOS and Linux)
        HostConfig {
            mounts: Some(mounts),
            port_bindings: Some(port_bindings),
            auto_remove: Some(false),
            // CAP_SETUID and CAP_SETGID required for opencode-broker to spawn
            // PTY processes as different users via setuid/setgid syscalls
            cap_add: Some(vec!["SETUID".to_string(), "SETGID".to_string()]),
            ..Default::default()
        }
    };

    // Build environment variables
    let mut env = env_vars.unwrap_or_default();
    if !has_env_key(&env, "XDG_DATA_HOME") {
        env.push("XDG_DATA_HOME=/home/opencoder/.local/share".to_string());
    }
    if !has_env_key(&env, "XDG_STATE_HOME") {
        env.push("XDG_STATE_HOME=/home/opencoder/.local/state".to_string());
    }
    if !has_env_key(&env, "XDG_CONFIG_HOME") {
        env.push("XDG_CONFIG_HOME=/home/opencoder/.config".to_string());
    }
    if !has_env_key(&env, "XDG_CACHE_HOME") {
        env.push("XDG_CACHE_HOME=/home/opencoder/.cache".to_string());
    }
    // Add USE_SYSTEMD=1 when systemd is enabled to tell entrypoint to use systemd
    if systemd_enabled_val && !has_env_key(&env, "USE_SYSTEMD") {
        env.push("USE_SYSTEMD=1".to_string());
    }
    let final_env = if env.is_empty() { None } else { Some(env) };

    // Create container config (bollard v0.20+ uses ContainerCreateBody)
    let mut labels = HashMap::from([("managed-by".to_string(), "opencode-cloud".to_string())]);
    if let Some(instance_id) = names.instance_id.as_deref() {
        // This label helps profile-aware cleanup target only the active isolated resources.
        labels.insert(INSTANCE_LABEL_KEY.to_string(), instance_id.to_string());
    }

    let config = ContainerCreateBody {
        image: Some(image_name.to_string()),
        hostname: Some(names.hostname),
        working_dir: Some("/home/opencoder/workspace".to_string()),
        exposed_ports: Some(exposed_ports),
        env: final_env,
        labels: Some(labels),
        host_config: Some(host_config),
        ..Default::default()
    };

    // Create container
    let options = CreateContainerOptions {
        name: Some(container_name.clone()),
        platform: String::new(),
    };

    let response = client
        .inner()
        .create_container(Some(options), config)
        .await
        .map_err(|e| {
            let msg = e.to_string();
            if msg.contains("port is already allocated") || msg.contains("address already in use") {
                DockerError::Container(format!(
                    "Port {port} is already in use. Stop the service using that port or use a different port with --port."
                ))
            } else {
                DockerError::Container(format!("Failed to create container: {e}"))
            }
        })?;

    debug!("Container created with ID: {}", response.id);
    Ok(response.id)
}

/// Start an existing container
pub async fn start_container(client: &DockerClient, name: &str) -> Result<(), DockerError> {
    let resolved_name = resolved_container_name(name);
    debug!("Starting container: {}", resolved_name);

    client
        .inner()
        .start_container(&resolved_name, None::<StartContainerOptions>)
        .await
        .map_err(|e| {
            DockerError::Container(format!("Failed to start container {resolved_name}: {e}"))
        })?;

    debug!("Container {} started", resolved_name);
    Ok(())
}

/// Stop a running container with graceful shutdown
///
/// # Arguments
/// * `client` - Docker client
/// * `name` - Container name
/// * `timeout_secs` - Seconds to wait before force kill (default: 10)
pub async fn stop_container(
    client: &DockerClient,
    name: &str,
    timeout_secs: Option<i64>,
) -> Result<(), DockerError> {
    let resolved_name = resolved_container_name(name);
    let timeout = timeout_secs.unwrap_or(10) as i32;
    debug!(
        "Stopping container {} with {}s timeout",
        resolved_name, timeout
    );

    let options = StopContainerOptions {
        signal: None,
        t: Some(timeout),
    };

    client
        .inner()
        .stop_container(&resolved_name, Some(options))
        .await
        .map_err(|e| {
            let msg = e.to_string();
            // "container already stopped" is not an error
            if msg.contains("is not running") || msg.contains("304") {
                debug!("Container {} was already stopped", resolved_name);
                return DockerError::Container(format!(
                    "Container '{resolved_name}' is not running"
                ));
            }
            DockerError::Container(format!("Failed to stop container {resolved_name}: {e}"))
        })?;

    debug!("Container {} stopped", resolved_name);
    Ok(())
}

/// Remove a container
///
/// # Arguments
/// * `client` - Docker client
/// * `name` - Container name
/// * `force` - Remove even if running
pub async fn remove_container(
    client: &DockerClient,
    name: &str,
    force: bool,
) -> Result<(), DockerError> {
    let resolved_name = resolved_container_name(name);
    debug!("Removing container {} (force={})", resolved_name, force);

    let options = RemoveContainerOptions {
        force,
        v: false, // Don't remove volumes
        link: false,
    };

    client
        .inner()
        .remove_container(&resolved_name, Some(options))
        .await
        .map_err(|e| {
            DockerError::Container(format!("Failed to remove container {resolved_name}: {e}"))
        })?;

    debug!("Container {} removed", resolved_name);
    Ok(())
}

/// Check if container exists
pub async fn container_exists(client: &DockerClient, name: &str) -> Result<bool, DockerError> {
    let resolved_name = resolved_container_name(name);
    debug!("Checking if container exists: {}", resolved_name);

    match client.inner().inspect_container(&resolved_name, None).await {
        Ok(_) => Ok(true),
        Err(bollard::errors::Error::DockerResponseServerError {
            status_code: 404, ..
        }) => Ok(false),
        Err(e) => Err(DockerError::Container(format!(
            "Failed to inspect container {resolved_name}: {e}"
        ))),
    }
}

/// Check if container is running
pub async fn container_is_running(client: &DockerClient, name: &str) -> Result<bool, DockerError> {
    let resolved_name = resolved_container_name(name);
    debug!("Checking if container is running: {}", resolved_name);

    match client.inner().inspect_container(&resolved_name, None).await {
        Ok(info) => {
            let running = info.state.and_then(|s| s.running).unwrap_or(false);
            Ok(running)
        }
        Err(bollard::errors::Error::DockerResponseServerError {
            status_code: 404, ..
        }) => Ok(false),
        Err(e) => Err(DockerError::Container(format!(
            "Failed to inspect container {resolved_name}: {e}"
        ))),
    }
}

/// Get container state (running, stopped, etc.)
pub async fn container_state(client: &DockerClient, name: &str) -> Result<String, DockerError> {
    let resolved_name = resolved_container_name(name);
    debug!("Getting container state: {}", resolved_name);

    match client.inner().inspect_container(&resolved_name, None).await {
        Ok(info) => {
            let state = info
                .state
                .and_then(|s| s.status)
                .map(|s| s.to_string())
                .unwrap_or_else(|| "unknown".to_string());
            Ok(state)
        }
        Err(bollard::errors::Error::DockerResponseServerError {
            status_code: 404, ..
        }) => Err(DockerError::Container(format!(
            "Container '{resolved_name}' not found"
        ))),
        Err(e) => Err(DockerError::Container(format!(
            "Failed to inspect container {resolved_name}: {e}"
        ))),
    }
}

/// Container port configuration
#[derive(Debug, Clone)]
pub struct ContainerPorts {
    /// Host port for opencode web UI (mapped from container port 3000)
    pub opencode_port: Option<u16>,
    /// Host port for Cockpit (mapped from container port 9090)
    pub cockpit_port: Option<u16>,
}

/// A bind mount from an existing container
#[derive(Debug, Clone)]
pub struct ContainerBindMount {
    /// Source path on host
    pub source: String,
    /// Target path in container
    pub target: String,
    /// Read-only flag
    pub read_only: bool,
}

/// Get the port bindings from an existing container
///
/// Returns the host ports that the container's internal ports are mapped to.
/// Returns None for ports that aren't mapped.
pub async fn get_container_ports(
    client: &DockerClient,
    name: &str,
) -> Result<ContainerPorts, DockerError> {
    let resolved_name = resolved_container_name(name);
    debug!("Getting container ports: {}", resolved_name);

    let info = client
        .inner()
        .inspect_container(&resolved_name, None)
        .await
        .map_err(|e| {
            DockerError::Container(format!("Failed to inspect container {resolved_name}: {e}"))
        })?;

    let port_bindings = info
        .host_config
        .and_then(|hc| hc.port_bindings)
        .unwrap_or_default();

    // Extract opencode port (3000/tcp -> host port)
    let opencode_port = port_bindings
        .get("3000/tcp")
        .and_then(|bindings| bindings.as_ref())
        .and_then(|bindings| bindings.first())
        .and_then(|binding| binding.host_port.as_ref())
        .and_then(|port_str| port_str.parse::<u16>().ok());

    // Extract cockpit port (9090/tcp -> host port)
    let cockpit_port = port_bindings
        .get("9090/tcp")
        .and_then(|bindings| bindings.as_ref())
        .and_then(|bindings| bindings.first())
        .and_then(|binding| binding.host_port.as_ref())
        .and_then(|port_str| port_str.parse::<u16>().ok());

    Ok(ContainerPorts {
        opencode_port,
        cockpit_port,
    })
}

/// Get bind mounts from an existing container
///
/// Returns only user-defined bind mounts (excludes system mounts like cgroup).
pub async fn get_container_bind_mounts(
    client: &DockerClient,
    name: &str,
) -> Result<Vec<ContainerBindMount>, DockerError> {
    let resolved_name = resolved_container_name(name);
    debug!("Getting container bind mounts: {}", resolved_name);

    let info = client
        .inner()
        .inspect_container(&resolved_name, None)
        .await
        .map_err(|e| {
            DockerError::Container(format!("Failed to inspect container {resolved_name}: {e}"))
        })?;

    let mounts = info.mounts.unwrap_or_default();

    // Filter to only bind mounts, excluding system paths
    let bind_mounts: Vec<ContainerBindMount> = mounts
        .iter()
        .filter(|m| m.typ == Some(MountPointTypeEnum::BIND))
        .filter(|m| {
            // Exclude system mounts (cgroup, etc.)
            let target = m.destination.as_deref().unwrap_or("");
            !target.starts_with("/sys/")
        })
        .map(|m| ContainerBindMount {
            source: m.source.clone().unwrap_or_default(),
            target: m.destination.clone().unwrap_or_default(),
            read_only: m.rw.map(|rw| !rw).unwrap_or(false),
        })
        .collect();

    Ok(bind_mounts)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::docker::IMAGE_TAG_DEFAULT;

    #[test]
    fn container_constants_are_correct() {
        assert_eq!(CONTAINER_NAME, "opencode-cloud-sandbox");
        assert_eq!(OPENCODE_WEB_PORT, 3000);
    }

    #[test]
    fn default_image_format() {
        let expected = format!("{IMAGE_NAME_GHCR}:{IMAGE_TAG_DEFAULT}");
        assert_eq!(expected, "ghcr.io/prizz/opencode-cloud-sandbox:latest");
    }
}