Skip to main content

stakpak_shared/
container.rs

1use std::collections::HashMap;
2use std::net::TcpListener;
3use std::process::Command;
4
5// ── Stakpak agent container constants ──────────────────────────────────────
6
7/// The container image used for sandboxed agent sessions.
8pub fn stakpak_agent_image() -> String {
9    format!("ghcr.io/stakpak/agent:v{}", env!("CARGO_PKG_VERSION"))
10}
11
12/// Default volume mounts for the stakpak agent container.
13///
14/// Single source of truth for every path the container needs.
15/// Used by `WardenConfig::readonly_profile()`, `prepare_volumes()`,
16/// and `build_dynamic_subagent_command()`.
17pub fn stakpak_agent_default_mounts() -> Vec<String> {
18    vec![
19        // Stakpak config & credentials
20        "~/.stakpak/config.toml:/home/agent/.stakpak/config.toml:ro".to_string(),
21        "~/.stakpak/auth.toml:/home/agent/.stakpak/auth.toml:ro".to_string(),
22        "~/.stakpak/data/local.db:/home/agent/.stakpak/data/local.db".to_string(),
23        "~/.agent-board/data.db:/home/agent/.agent-board/data.db".to_string(),
24        // Working directory
25        "./:/agent:ro".to_string(),
26        "./.stakpak:/agent/.stakpak".to_string(),
27        // AWS — config read-only, SSO/STS cache writable for token refresh
28        "~/.aws/config:/home/agent/.aws/config:ro".to_string(),
29        "~/.aws/credentials:/home/agent/.aws/credentials:ro".to_string(),
30        "~/.aws/sso:/home/agent/.aws/sso".to_string(),
31        "~/.aws/cli:/home/agent/.aws/cli".to_string(),
32        // GCP — credential files read-only, cache/logs/db writable for gcloud to function
33        "~/.config/gcloud/active_config:/home/agent/.config/gcloud/active_config:ro".to_string(),
34        "~/.config/gcloud/configurations:/home/agent/.config/gcloud/configurations:ro".to_string(),
35        "~/.config/gcloud/application_default_credentials.json:/home/agent/.config/gcloud/application_default_credentials.json:ro".to_string(),
36        "~/.config/gcloud/credentials.db:/home/agent/.config/gcloud/credentials.db:ro".to_string(),
37        "~/.config/gcloud/access_tokens.db:/home/agent/.config/gcloud/access_tokens.db:ro".to_string(),
38        "~/.config/gcloud/logs:/home/agent/.config/gcloud/logs".to_string(),
39        "~/.config/gcloud/cache:/home/agent/.config/gcloud/cache".to_string(),
40        // Azure — config read-only, MSAL token cache and session writable
41        "~/.azure/config:/home/agent/.azure/config:ro".to_string(),
42        "~/.azure/clouds.config:/home/agent/.azure/clouds.config:ro".to_string(),
43        "~/.azure/azureProfile.json:/home/agent/.azure/azureProfile.json:ro".to_string(),
44        "~/.azure/msal_token_cache.json:/home/agent/.azure/msal_token_cache.json".to_string(),
45        "~/.azure/msal_http_cache.bin:/home/agent/.azure/msal_http_cache.bin".to_string(),
46        "~/.azure/logs:/home/agent/.azure/logs".to_string(),
47        // DigitalOcean & Kubernetes
48        "~/.digitalocean:/home/agent/.digitalocean:ro".to_string(),
49        "~/.kube:/home/agent/.kube:ro".to_string(),
50        // SSH — config and keys read-only (useful for host aliases and remote connections)
51        "~/.ssh:/home/agent/.ssh:ro".to_string(),
52        // Aqua tool cache (named volume — persists downloaded CLIs across runs)
53        "stakpak-aqua-cache:/home/agent/.local/share/aquaproj-aqua".to_string(),
54    ]
55}
56
57/// Expand `~` to `$HOME` in a volume mount string.
58pub fn expand_volume_path(volume: &str) -> String {
59    if (volume.starts_with("~/") || volume.starts_with("~:"))
60        && let Ok(home_dir) = std::env::var("HOME")
61    {
62        return volume.replacen("~", &home_dir, 1);
63    }
64    volume.to_string()
65}
66
67/// Check whether the host-side part of a volume mount is a Docker named volume
68/// (as opposed to a bind mount path).
69///
70/// Named volumes don't start with `/`, `.`, or `~` and contain no `/`.
71pub fn is_named_volume(host_part: &str) -> bool {
72    !host_part.starts_with('/')
73        && !host_part.starts_with('.')
74        && !host_part.starts_with('~')
75        && !host_part.contains('/')
76}
77
78/// Pre-create any Docker named volumes found in [`stakpak_agent_default_mounts`].
79///
80/// Running `docker volume create` is idempotent and prevents a race condition
81/// when multiple sandbox containers first-use the same named volume in parallel.
82pub fn ensure_named_volumes_exist() {
83    for vol in stakpak_agent_default_mounts() {
84        let host_part = vol.split(':').next().unwrap_or(&vol);
85        if is_named_volume(host_part) {
86            let _ = Command::new("docker")
87                .args(["volume", "create", host_part])
88                .stdout(std::process::Stdio::null())
89                .stderr(std::process::Stdio::null())
90                .status();
91        }
92    }
93}
94
95#[derive(Debug, Clone)]
96pub struct ContainerConfig {
97    pub image: String,
98    pub env_vars: HashMap<String, String>,
99    pub ports: Vec<String>,       // Format: "host_port:container_port"
100    pub extra_hosts: Vec<String>, // Format: "host:ip"
101    pub volumes: Vec<String>,     // Format: "host_path:container_path"
102}
103
104pub fn find_available_port() -> Option<u16> {
105    match TcpListener::bind("0.0.0.0:0") {
106        Ok(listener) => listener.local_addr().ok().map(|addr| addr.port()),
107        Err(_) => None,
108    }
109}
110
111/// Checks if Docker is installed and accessible
112pub fn is_docker_available() -> bool {
113    Command::new("docker")
114        .arg("--version")
115        .output()
116        .map(|output| output.status.success())
117        .unwrap_or(false)
118}
119
120/// Checks if a Docker image exists locally
121pub fn image_exists_locally(image: &str) -> Result<bool, String> {
122    let output = Command::new("docker")
123        .args(["images", "-q", image])
124        .output()
125        .map_err(|e| format!("Failed to execute docker images command: {}", e))?;
126
127    if output.status.success() {
128        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
129        Ok(!stdout.is_empty())
130    } else {
131        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
132        Err(format!("Docker images command failed: {}", stderr))
133    }
134}
135
136pub fn run_container_detached(config: ContainerConfig) -> Result<String, String> {
137    let mut cmd = Command::new("docker");
138
139    cmd.arg("run").arg("-d").arg("--rm");
140
141    // Add ports
142    for port_mapping in &config.ports {
143        cmd.arg("-p").arg(port_mapping);
144    }
145
146    // Add environment variables
147    for (key, value) in &config.env_vars {
148        cmd.arg("-e").arg(format!("{}={}", key, value));
149    }
150
151    // Add extra hosts
152    for host_mapping in &config.extra_hosts {
153        cmd.arg("--add-host").arg(host_mapping);
154    }
155
156    // Add volumes
157    for volume_mapping in &config.volumes {
158        cmd.arg("-v").arg(volume_mapping);
159    }
160
161    // Add image
162    cmd.arg(&config.image);
163
164    let output = cmd
165        .output()
166        .map_err(|e| format!("Failed to execute docker command: {}", e))?;
167
168    if output.status.success() {
169        let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
170        Ok(container_id)
171    } else {
172        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
173        Err(format!("Docker command failed: {}", stderr))
174    }
175}
176
177pub fn stop_container(container_id: &str) -> Result<(), String> {
178    let output = Command::new("docker")
179        .arg("stop")
180        .arg(container_id)
181        .output()
182        .map_err(|e| format!("Failed to execute docker stop: {}", e))?;
183
184    if output.status.success() {
185        Ok(())
186    } else {
187        let stderr = String::from_utf8_lossy(&output.stderr);
188        if stderr.contains("No such container") {
189            Ok(())
190        } else {
191            Err(format!("Failed to stop container: {}", stderr))
192        }
193    }
194}
195
196pub fn remove_container(
197    container_id: &str,
198    force: bool,
199    remove_volumes: bool,
200) -> Result<(), String> {
201    let mut cmd = Command::new("docker");
202
203    cmd.arg("rm");
204
205    if force {
206        cmd.arg("-f");
207    }
208
209    if remove_volumes {
210        cmd.arg("-v");
211    }
212
213    cmd.arg(container_id);
214
215    let output = cmd
216        .output()
217        .map_err(|e| format!("Failed to execute docker rm: {}", e))?;
218
219    if output.status.success() {
220        Ok(())
221    } else {
222        let stderr = String::from_utf8_lossy(&output.stderr);
223        if stderr.contains("No such container") {
224            Ok(())
225        } else {
226            Err(format!("Failed to remove container: {}", stderr))
227        }
228    }
229}
230
231pub fn get_container_host_port(container_id: &str, container_port: u16) -> Result<u16, String> {
232    let output = Command::new("docker")
233        .arg("port")
234        .arg(container_id)
235        .arg(container_port.to_string())
236        .output()
237        .map_err(|e| format!("Failed to get container port: {}", e))?;
238
239    if output.status.success() {
240        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
241        let port = stdout.split(':').next_back().unwrap_or("");
242        Ok(port.parse().unwrap())
243    } else {
244        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
245        Err(format!("Failed to get container port: {}", stderr))
246    }
247}