🛰️ export from upstream (9298ca3)

This commit is contained in:
2026-05-24 21:19:38 +02:00
parent 2e0c340768
commit 4b56c25709
5 changed files with 63 additions and 20 deletions
+19 -1
View File
@@ -80,6 +80,8 @@ struct RawConfig {
services: Option<HashMap<String, ServiceOverride>>, services: Option<HashMap<String, ServiceOverride>>,
#[serde(default)] #[serde(default)]
default_services: Option<Vec<String>>, default_services: Option<Vec<String>>,
#[serde(default)]
image: Option<String>,
/// Absorb everything else so we can warn about unknown fields. /// Absorb everything else so we can warn about unknown fields.
#[serde(flatten)] #[serde(flatten)]
@@ -121,13 +123,15 @@ pub struct SandcageConfig {
pub services: Option<HashMap<String, ServiceOverride>>, pub services: Option<HashMap<String, ServiceOverride>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub default_services: Option<Vec<String>>, pub default_services: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Known keys — used to filter the flattened `extra` map // Known keys — used to filter the flattened `extra` map
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const KNOWN_KEYS: &[&str] = &["env", "packages", "toolchains", "mounts", "shell", "justfile", "dockerfiles", "trusted_projects", "container_workspace", "agent_args", "ssh_mode", "ssh_keys", "services", "default_services"]; const KNOWN_KEYS: &[&str] = &["env", "packages", "toolchains", "mounts", "shell", "justfile", "dockerfiles", "trusted_projects", "container_workspace", "agent_args", "ssh_mode", "ssh_keys", "services", "default_services", "image"];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Validation helpers // Validation helpers
@@ -184,6 +188,7 @@ fn from_raw(raw: RawConfig) -> SandcageConfig {
ssh_keys: raw.ssh_keys, ssh_keys: raw.ssh_keys,
services: raw.services, services: raw.services,
default_services: raw.default_services, default_services: raw.default_services,
image: raw.image,
} }
} }
@@ -718,4 +723,17 @@ services:
"project should override global" "project should override global"
); );
} }
#[test]
fn parse_image_field() {
let yaml = "image: my-custom\n";
let cfg = load_from_str(yaml).expect("parse image field");
assert_eq!(cfg.image.as_deref(), Some("my-custom"));
}
#[test]
fn image_defaults_to_none() {
let cfg = load_from_str("").expect("parse empty");
assert!(cfg.image.is_none());
}
} }
+11 -18
View File
@@ -190,12 +190,15 @@ pub fn build_compose_context(workspace: &Path, config: &SandcageConfig) -> Resul
None => default_container_dir(workspace), None => default_container_dir(workspace),
}; };
let image = config.image.as_deref().unwrap_or("sandcage").to_string();
Ok(crate::service::ComposeContext { Ok(crate::service::ComposeContext {
uid, uid,
gid, gid,
workspace: workspace.to_path_buf(), workspace: workspace.to_path_buf(),
container_dir, container_dir,
sandcage_home, sandcage_home,
image,
}) })
} }
@@ -220,10 +223,6 @@ fn write_compose_tempfile(content: &str) -> Result<tempfile::NamedTempFile> {
Ok(tmp) Ok(tmp)
} }
fn image_for_service(_service: &str) -> &'static str {
"sandcage"
}
fn require_image(docker: &Path, image: &str) -> Result<()> { fn require_image(docker: &Path, image: &str) -> Result<()> {
let output = Command::new(docker) let output = Command::new(docker)
.args(["image", "inspect", image]) .args(["image", "inspect", image])
@@ -355,8 +354,9 @@ pub fn run_service(
}); });
} }
let image = image_for_service(service); let ctx = build_compose_context(workspace, config)?;
require_image(&docker, image)?; let image_tag = format!("{}:latest", ctx.image);
require_image(&docker, &image_tag)?;
if config.ssh_mode.as_deref() == Some("volume") { if config.ssh_mode.as_deref() == Some("volume") {
let vol_check = Command::new(&docker) let vol_check = Command::new(&docker)
@@ -369,7 +369,6 @@ pub fn run_service(
} }
} }
let ctx = build_compose_context(workspace, config)?;
let compose_content = crate::service::compose::generate_compose(registry, &ctx); let compose_content = crate::service::compose::generate_compose(registry, &ctx);
let compose_file = write_compose_tempfile(&compose_content)?; let compose_file = write_compose_tempfile(&compose_content)?;
let compose_path = compose_file.path().to_string_lossy().into_owned(); let compose_path = compose_file.path().to_string_lossy().into_owned();
@@ -605,8 +604,9 @@ pub fn build_images(force: bool, service_filter: &[String]) -> Result<()> {
} }
let images: &[(&str, &str)] = &[ let image_name = global_cfg.image.as_deref().unwrap_or("sandcage");
("sandcage", DOCKERFILE), let images: Vec<(&str, &str)> = vec![
(image_name, DOCKERFILE),
]; ];
let hashes_path = build_hashes_path()?; let hashes_path = build_hashes_path()?;
@@ -622,7 +622,7 @@ pub fn build_images(force: bool, service_filter: &[String]) -> Result<()> {
sha256_hex(bundled_dockerfile) sha256_hex(bundled_dockerfile)
}; };
let needs_build = force || stored.get(*image) != Some(&current_hash); let needs_build = force || stored.get(image) != Some(&current_hash);
if needs_build { if needs_build {
if force { if force {
@@ -672,6 +672,7 @@ mod tests {
workspace: PathBuf::from("/tmp/test"), workspace: PathBuf::from("/tmp/test"),
container_dir: "/workspace/test".to_string(), container_dir: "/workspace/test".to_string(),
sandcage_home: PathBuf::from("/home/user/.sandcage"), sandcage_home: PathBuf::from("/home/user/.sandcage"),
image: "sandcage".to_string(),
}; };
let yaml = generate_compose(&registry, &ctx); let yaml = generate_compose(&registry, &ctx);
@@ -860,14 +861,6 @@ mod tests {
assert!(path.exists(), "hash file should have been created"); assert!(path.exists(), "hash file should have been created");
} }
#[test]
fn image_for_service_maps_correctly() {
assert_eq!(image_for_service("claude"), "sandcage");
assert_eq!(image_for_service("codex"), "sandcage");
assert_eq!(image_for_service("shell"), "sandcage");
assert_eq!(image_for_service("anything-else"), "sandcage");
}
#[test] #[test]
fn require_image_fails_for_nonexistent_image() { fn require_image_fails_for_nonexistent_image() {
let docker = match require_docker() { let docker = match require_docker() {
+1
View File
@@ -37,6 +37,7 @@ mod tests {
workspace: PathBuf::from("/home/user/projects/my-app"), workspace: PathBuf::from("/home/user/projects/my-app"),
container_dir: "/workspace/my-app".to_string(), container_dir: "/workspace/my-app".to_string(),
sandcage_home: PathBuf::from("/home/user/.sandcage"), sandcage_home: PathBuf::from("/home/user/.sandcage"),
image: "sandcage".to_string(),
} }
} }
+12 -1
View File
@@ -17,6 +17,7 @@ pub struct ComposeContext {
pub workspace: PathBuf, pub workspace: PathBuf,
pub container_dir: String, pub container_dir: String,
pub sandcage_home: PathBuf, pub sandcage_home: PathBuf,
pub image: String,
} }
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
@@ -96,7 +97,7 @@ impl Service for ServiceDef {
}; };
ComposeServiceDef { ComposeServiceDef {
image: "sandcage:latest".to_string(), image: format!("{}:latest", ctx.image),
entrypoint, entrypoint,
working_dir: ctx.container_dir.clone(), working_dir: ctx.container_dir.clone(),
user: format!("{}:{}", ctx.uid, ctx.gid), user: format!("{}:{}", ctx.uid, ctx.gid),
@@ -130,6 +131,7 @@ mod tests {
workspace: PathBuf::from("/home/user/projects/my-app"), workspace: PathBuf::from("/home/user/projects/my-app"),
container_dir: "/workspace/my-app".to_string(), container_dir: "/workspace/my-app".to_string(),
sandcage_home: PathBuf::from("/home/user/.sandcage"), sandcage_home: PathBuf::from("/home/user/.sandcage"),
image: "sandcage".to_string(),
} }
} }
@@ -231,4 +233,13 @@ mod tests {
let compose = svc.compose_service(&ctx); let compose = svc.compose_service(&ctx);
assert_eq!(compose.entrypoint, vec!["/bin/zsh"]); assert_eq!(compose.entrypoint, vec!["/bin/zsh"]);
} }
#[test]
fn compose_service_uses_configured_image() {
let svc = agent_service();
let mut ctx = test_context();
ctx.image = "custom-img".to_string();
let compose = svc.compose_service(&ctx);
assert_eq!(compose.image, "custom-img:latest");
}
} }
+20
View File
@@ -267,6 +267,26 @@ default_services:
--- ---
### image
The Docker image name used for building and running containers.
- **Type:** String
- **Valid in:** Global, Project, Local
- **Default:** `"sandcage"`
```yaml
# .sandcage.yml
image: my-custom-sandcage
```
```toml
# ~/.sandcage/config.toml
image = "my-custom-sandcage"
```
---
## Services ## Services
Sandcage ships with four built-in services, all enabled by default: Sandcage ships with four built-in services, all enabled by default: