🥇 export from upstream (fe643f3)
This commit is contained in:
@@ -1,6 +0,0 @@
|
||||
def main():
|
||||
print("Hello from sandcage!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+41
-17
@@ -19,11 +19,12 @@ pub enum ConfigError {
|
||||
|
||||
#[error("Failed to parse YAML: {0}")]
|
||||
#[diagnostic(code(sandcage::config::parse_failed_str))]
|
||||
#[allow(dead_code)]
|
||||
ParseFailedStr(#[source] serde_yaml::Error),
|
||||
|
||||
#[error("Failed to merge configuration layers: {0}")]
|
||||
#[diagnostic(code(sandcage::config::merge_failed))]
|
||||
MergeFailed(#[source] figment::Error),
|
||||
MergeFailed(#[source] Box<figment::Error>),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, ConfigError>;
|
||||
@@ -47,6 +48,10 @@ struct RawConfig {
|
||||
shell: Option<String>,
|
||||
#[serde(default)]
|
||||
justfile: Option<PathBuf>,
|
||||
#[serde(default)]
|
||||
dockerfiles: Option<HashMap<String, PathBuf>>,
|
||||
#[serde(default)]
|
||||
trusted_projects: Option<Vec<PathBuf>>,
|
||||
|
||||
/// Absorb everything else so we can warn about unknown fields.
|
||||
#[serde(flatten)]
|
||||
@@ -72,13 +77,17 @@ pub struct SandcageConfig {
|
||||
pub shell: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub justfile: Option<PathBuf>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub dockerfiles: Option<HashMap<String, PathBuf>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub trusted_projects: Option<Vec<PathBuf>>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Known keys — used to filter the flattened `extra` map
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const KNOWN_KEYS: &[&str] = &["env", "packages", "toolchains", "mounts", "shell", "justfile"];
|
||||
const KNOWN_KEYS: &[&str] = &["env", "packages", "toolchains", "mounts", "shell", "justfile", "dockerfiles", "trusted_projects"];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation helpers
|
||||
@@ -127,6 +136,8 @@ fn from_raw(raw: RawConfig) -> SandcageConfig {
|
||||
mounts: raw.mounts,
|
||||
shell: raw.shell,
|
||||
justfile: raw.justfile,
|
||||
dockerfiles: raw.dockerfiles,
|
||||
trusted_projects: raw.trusted_projects,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +157,7 @@ pub fn load(path: &Path) -> Result<SandcageConfig> {
|
||||
Ok(from_raw(raw))
|
||||
}
|
||||
|
||||
/// Parse a `.sandcage.yml` from an in-memory string (useful for testing).
|
||||
#[cfg(test)]
|
||||
pub fn load_from_str(content: &str) -> Result<SandcageConfig> {
|
||||
// Empty / whitespace-only content → all fields None
|
||||
if content.trim().is_empty() {
|
||||
@@ -160,6 +171,21 @@ pub fn load_from_str(content: &str) -> Result<SandcageConfig> {
|
||||
Ok(from_raw(raw))
|
||||
}
|
||||
|
||||
/// Load trusted_projects from the global config only.
|
||||
/// This must never be read from project-level config (a project cannot self-trust).
|
||||
pub fn load_trusted_projects(global_config_path: &Path) -> Vec<PathBuf> {
|
||||
if !global_config_path.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let cfg: SandcageConfig = Figment::from(Serialized::defaults(SandcageConfig::default()))
|
||||
.merge(Toml::file(global_config_path))
|
||||
.extract()
|
||||
.unwrap_or_default();
|
||||
|
||||
cfg.trusted_projects.unwrap_or_default()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layered config resolution via figment
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -180,24 +206,22 @@ pub fn resolve_config(
|
||||
// Start with compiled defaults — all fields None.
|
||||
let mut figment = Figment::from(Serialized::defaults(SandcageConfig::default()));
|
||||
|
||||
// Layer 2: global TOML config (if provided).
|
||||
if let Some(global_path) = global_config_path {
|
||||
if global_path.exists() {
|
||||
figment = figment.merge(Toml::file(global_path));
|
||||
}
|
||||
if let Some(global_path) = global_config_path
|
||||
&& global_path.exists()
|
||||
{
|
||||
figment = figment.merge(Toml::file(global_path));
|
||||
}
|
||||
|
||||
// Layer 3: project YAML config (if provided).
|
||||
// figment has no built-in YAML provider, so we parse with serde_yaml first
|
||||
// and inject via Serialized.
|
||||
if let Some(project_path) = project_config_path {
|
||||
if project_path.exists() {
|
||||
let project_cfg = load(project_path)?;
|
||||
figment = figment.merge(Serialized::defaults(project_cfg));
|
||||
}
|
||||
if let Some(project_path) = project_config_path
|
||||
&& project_path.exists()
|
||||
{
|
||||
let project_cfg = load(project_path)?;
|
||||
figment = figment.merge(Serialized::defaults(project_cfg));
|
||||
}
|
||||
|
||||
figment.extract().map_err(ConfigError::MergeFailed)
|
||||
figment
|
||||
.extract()
|
||||
.map_err(|e| ConfigError::MergeFailed(Box::new(e)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+112
-14
@@ -1,3 +1,4 @@
|
||||
#![allow(unused_assignments)]
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -285,10 +286,8 @@ fn write_stored_hashes(path: &Path, hashes: &HashMap<String, String>) -> Result<
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build a single image.
|
||||
/// Writes the `dockerfile_content` to a temp directory, then invokes
|
||||
/// `docker build -t <image>:latest <temp_dir>`.
|
||||
fn build_one_image(docker: &Path, image: &str, dockerfile_content: &str) -> Result<()> {
|
||||
/// Build a single image from inline Dockerfile content (temp build context).
|
||||
fn build_one_image_from_content(docker: &Path, image: &str, dockerfile_content: &str) -> Result<()> {
|
||||
let tmp_dir = tempfile::Builder::new()
|
||||
.prefix("sandcage-build-")
|
||||
.tempdir()
|
||||
@@ -298,14 +297,44 @@ fn build_one_image(docker: &Path, image: &str, dockerfile_content: &str) -> Resu
|
||||
std::fs::write(&dockerfile_path, dockerfile_content)
|
||||
.map_err(DockerError::DockerfileWriteFailed)?;
|
||||
|
||||
let status = Command::new(docker)
|
||||
.args(["build", "-t", &format!("{image}:latest")])
|
||||
.arg(tmp_dir.path())
|
||||
run_docker_build(docker, image, tmp_dir.path(), None)
|
||||
}
|
||||
|
||||
/// Build a single image from a user-provided path.
|
||||
/// If the path is a directory, it's used as the build context.
|
||||
/// If the path is a file, it's used as the Dockerfile with a temp context.
|
||||
fn build_one_image_from_path(docker: &Path, image: &str, override_path: &Path) -> Result<()> {
|
||||
if override_path.is_dir() {
|
||||
run_docker_build(docker, image, override_path, None)
|
||||
} else {
|
||||
let tmp_dir = tempfile::Builder::new()
|
||||
.prefix("sandcage-build-")
|
||||
.tempdir()
|
||||
.map_err(DockerError::TempDirFailed)?;
|
||||
|
||||
run_docker_build(docker, image, tmp_dir.path(), Some(override_path))
|
||||
}
|
||||
}
|
||||
|
||||
fn run_docker_build(
|
||||
docker: &Path,
|
||||
image: &str,
|
||||
context: &Path,
|
||||
dockerfile: Option<&Path>,
|
||||
) -> Result<()> {
|
||||
let mut cmd = Command::new(docker);
|
||||
cmd.args(["build", "-t", &format!("{image}:latest")]);
|
||||
|
||||
if let Some(df) = dockerfile {
|
||||
cmd.args(["-f", &df.to_string_lossy()]);
|
||||
}
|
||||
|
||||
cmd.arg(context)
|
||||
.stdin(std::process::Stdio::inherit())
|
||||
.stdout(std::process::Stdio::inherit())
|
||||
.stderr(std::process::Stdio::inherit())
|
||||
.status()
|
||||
.map_err(DockerError::SpawnFailed)?;
|
||||
.stderr(std::process::Stdio::inherit());
|
||||
|
||||
let status = cmd.status().map_err(DockerError::SpawnFailed)?;
|
||||
|
||||
if !status.success() {
|
||||
return Err(DockerError::BuildFailed {
|
||||
@@ -317,14 +346,67 @@ fn build_one_image(docker: &Path, image: &str, dockerfile_content: &str) -> Resu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the Dockerfile content for hashing purposes.
|
||||
/// Returns the content string for override files/dirs, or None if the path is invalid.
|
||||
fn read_dockerfile_at(path: &Path) -> Result<String> {
|
||||
let dockerfile_path = if path.is_dir() {
|
||||
path.join("Dockerfile")
|
||||
} else {
|
||||
path.to_path_buf()
|
||||
};
|
||||
std::fs::read_to_string(&dockerfile_path).map_err(DockerError::HashReadFailed)
|
||||
}
|
||||
|
||||
/// Build all three sandcage images with cache awareness.
|
||||
///
|
||||
/// * Resolves layered config to check for Dockerfile overrides.
|
||||
/// * When `force` is `true`, every image is rebuilt regardless of the stored hash.
|
||||
/// * Otherwise, an image is skipped when its Dockerfile hash matches the stored value.
|
||||
/// * After a successful build the hash is updated in `~/.sandcage/.build-hashes`.
|
||||
pub fn build_images(force: bool) -> Result<()> {
|
||||
let docker = require_docker()?;
|
||||
|
||||
let home = dirs::home_dir().ok_or(DockerError::NoHomeDir)?;
|
||||
let global_config = home.join(".sandcage").join("config.toml");
|
||||
let project_dir = std::env::current_dir().unwrap_or_default();
|
||||
let project_config = project_dir.join(".sandcage.yml");
|
||||
|
||||
// Global overrides are always trusted.
|
||||
let global_cfg = crate::config::resolve_config(Some(&global_config), None)
|
||||
.unwrap_or_default();
|
||||
let mut overrides = global_cfg.dockerfiles.unwrap_or_default();
|
||||
|
||||
// Project overrides require the project to be in trusted_projects.
|
||||
let project_cfg = if project_config.exists() {
|
||||
crate::config::load(&project_config).ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(ref pcfg) = project_cfg
|
||||
&& pcfg.dockerfiles.is_some()
|
||||
{
|
||||
let trusted = crate::config::load_trusted_projects(&global_config);
|
||||
let is_trusted = trusted.iter().any(|p| {
|
||||
let canonical_trusted = std::fs::canonicalize(p).unwrap_or_else(|_| p.clone());
|
||||
let canonical_project = std::fs::canonicalize(&project_dir)
|
||||
.unwrap_or_else(|_| project_dir.clone());
|
||||
canonical_project.starts_with(canonical_trusted)
|
||||
});
|
||||
|
||||
if is_trusted {
|
||||
overrides.extend(pcfg.dockerfiles.clone().unwrap_or_default());
|
||||
} else {
|
||||
eprintln!(
|
||||
"sandcage: warning: project Dockerfile overrides ignored (not in trusted_projects)"
|
||||
);
|
||||
eprintln!(
|
||||
"sandcage: add this project to trusted_projects in ~/.sandcage/config.toml"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let images: &[(&str, &str)] = &[
|
||||
("sandcage-base", DOCKERFILE_BASE),
|
||||
("sandcage-claude", DOCKERFILE_CLAUDE),
|
||||
@@ -334,17 +416,33 @@ pub fn build_images(force: bool) -> Result<()> {
|
||||
let hashes_path = build_hashes_path()?;
|
||||
let mut stored = read_stored_hashes(&hashes_path)?;
|
||||
|
||||
for (image, dockerfile) in images {
|
||||
let current_hash = sha256_hex(dockerfile);
|
||||
let needs_build = force || stored.get(*image).map_or(true, |h| h != ¤t_hash);
|
||||
for (image, bundled_dockerfile) in images {
|
||||
let short_name = image.strip_prefix("sandcage-").unwrap_or(image);
|
||||
let override_path = overrides.get(short_name);
|
||||
|
||||
let current_hash = if let Some(path) = override_path {
|
||||
sha256_hex(&read_dockerfile_at(path)?)
|
||||
} else {
|
||||
sha256_hex(bundled_dockerfile)
|
||||
};
|
||||
|
||||
let needs_build = force || stored.get(*image) != Some(¤t_hash);
|
||||
|
||||
if needs_build {
|
||||
if force {
|
||||
eprintln!("{image}: rebuilding (--force)");
|
||||
} else if override_path.is_some() {
|
||||
eprintln!("{image}: rebuilding (custom Dockerfile changed)");
|
||||
} else {
|
||||
eprintln!("{image}: rebuilding (Dockerfile changed)");
|
||||
}
|
||||
build_one_image(&docker, image, dockerfile)?;
|
||||
|
||||
if let Some(path) = override_path {
|
||||
build_one_image_from_path(&docker, image, path)?;
|
||||
} else {
|
||||
build_one_image_from_content(&docker, image, bundled_dockerfile)?;
|
||||
}
|
||||
|
||||
stored.insert(image.to_string(), current_hash);
|
||||
write_stored_hashes(&hashes_path, &stored)?;
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user