🛰️ export from upstream (6885be6)

This commit is contained in:
2026-09-22 10:09:44 +02:00
parent 8262b5f39d
commit 010753e02d
2 changed files with 30 additions and 276 deletions
+2 -2
View File
@@ -69,8 +69,8 @@ pub async fn handle(
let global_config = home.join(".sandcage").join("config.toml"); let global_config = home.join(".sandcage").join("config.toml");
let config_src = crate::config_source::resolve_config_source(&workspace); let config_src = crate::config_source::resolve_config_source(&workspace);
let config_root = config_src.as_ref().map(|s| s.root.clone()).unwrap_or_else(|| workspace.clone()); let config_root = config_src.as_ref().map(|s| s.root.clone()).unwrap_or_else(|| workspace.clone());
let project_config = config_root.join(".sandcage").join("sandcage.yml"); let project_config = config_root.join(".sandcage").join("config.toml");
let local_config = config_root.join(".sandcage").join("local").join("sandcage.local.yml"); let local_config = config_root.join(".sandcage").join("local").join("config.local.toml");
let cfg = crate::config::resolve_config( let cfg = crate::config::resolve_config(
Some(&global_config), Some(&global_config),
Some(&project_config), Some(&project_config),
+28 -274
View File
@@ -6,7 +6,7 @@ use thiserror::Error;
use figment::providers::Format as _; use figment::providers::Format as _;
use crate::config::{SandcageConfig, SshKeyEntry}; use crate::config::SandcageConfig;
use crate::ssh_config::{self, SshHostBlock}; use crate::ssh_config::{self, SshHostBlock};
#[derive(Debug, Error, Diagnostic)] #[derive(Debug, Error, Diagnostic)]
@@ -19,11 +19,11 @@ pub enum SetupError {
#[diagnostic(code(sandcage::setup::read_dir_failed))] #[diagnostic(code(sandcage::setup::read_dir_failed))]
ReadDirFailed(PathBuf, #[source] std::io::Error), ReadDirFailed(PathBuf, #[source] std::io::Error),
#[error("No .sandcage/sandcage.yml found. Run `sandcage init` first, use `--global`, or set SANDCAGE_CONFIG_DIR.")] #[error("No .sandcage/config.toml found. Run `sandcage init` first, use `--global`, or set SANDCAGE_CONFIG_DIR.")]
#[diagnostic( #[diagnostic(
code(sandcage::setup::no_project_config), code(sandcage::setup::no_project_config),
help("In a git worktree, .sandcage/ is loaded from the main worktree root. \ help("In a git worktree, .sandcage/ is loaded from the main worktree root. \
You can also use a redirect file (sandcage.yml with redirect: key).") You can also use a redirect file (config.toml with redirect key).")
)] )]
NoProjectConfig, NoProjectConfig,
@@ -104,115 +104,6 @@ pub fn check_existing_mount(config: &SandcageConfig, mount: &str) -> bool {
.is_some_and(|m| m.iter().any(|entry| entry == mount)) .is_some_and(|m| m.iter().any(|entry| entry == mount))
} }
pub fn add_mount_to_yaml(path: &Path, mount: &str) -> Result<()> {
let content = std::fs::read_to_string(path)
.map_err(|e| SetupError::ConfigReadFailed(path.to_path_buf(), e))?;
let mut config: SandcageConfig = if content.trim().is_empty() {
SandcageConfig::default()
} else {
serde_yaml::from_str(&content)
.map_err(|e| SetupError::ConfigParseFailed(path.to_path_buf(), e.to_string()))?
};
let mounts = config.mounts.get_or_insert_with(Vec::new);
mounts.push(mount.to_string());
let yaml = serde_yaml::to_string(&config)
.map_err(|e| SetupError::ConfigParseFailed(path.to_path_buf(), e.to_string()))?;
let output = rehydrate_yaml_comments(&content, &yaml);
std::fs::write(path, output)
.map_err(|e| SetupError::ConfigWriteFailed(path.to_path_buf(), e))?;
Ok(())
}
fn rehydrate_yaml_comments(original: &str, generated: &str) -> String {
use std::collections::HashMap;
let mut key_comments: Vec<(Option<String>, Vec<String>)> = Vec::new();
let mut pending: Vec<String> = Vec::new();
for line in original.lines() {
let trimmed = line.trim();
let is_blank = trimmed.is_empty();
let is_top_comment =
trimmed.starts_with('#') && !line.starts_with(' ') && !line.starts_with('\t');
let is_top_level_key = !is_blank
&& !is_top_comment
&& !line.starts_with(' ')
&& !line.starts_with('\t')
&& !line.starts_with('-')
&& line.contains(':');
if is_top_level_key {
let key = line.split(':').next().unwrap().trim().to_string();
key_comments.push((Some(key), std::mem::take(&mut pending)));
} else if is_blank || is_top_comment {
pending.push(line.to_string());
}
}
if !pending.is_empty() {
key_comments.push((None, pending));
}
let comment_map: HashMap<&str, &[String]> = key_comments
.iter()
.filter_map(|(k, v)| k.as_deref().map(|k| (k, v.as_slice())))
.collect();
let preamble = key_comments
.first()
.and_then(|(k, v)| if k.is_some() { Some(v.as_slice()) } else { None });
let trailer = key_comments
.last()
.and_then(|(k, v)| if k.is_none() { Some(v.as_slice()) } else { None });
let mut result = String::new();
let mut first_key = true;
for line in generated.lines() {
let trimmed = line.trim();
let is_top_level_key = !trimmed.is_empty()
&& !line.starts_with(' ')
&& !line.starts_with('\t')
&& !line.starts_with('-')
&& line.contains(':');
if is_top_level_key {
let key = line.split(':').next().unwrap().trim();
if first_key {
if let Some(pre) = preamble {
for c in pre {
result.push_str(c);
result.push('\n');
}
}
first_key = false;
} else if let Some(comments) = comment_map.get(key) {
for c in *comments {
result.push_str(c);
result.push('\n');
}
}
}
result.push_str(line);
result.push('\n');
}
if let Some(trail) = trailer {
for c in trail {
result.push_str(c);
result.push('\n');
}
}
result
}
pub fn add_mount_to_toml(path: &Path, mount: &str) -> Result<()> { pub fn add_mount_to_toml(path: &Path, mount: &str) -> Result<()> {
let content = if path.exists() { let content = if path.exists() {
std::fs::read_to_string(path) std::fs::read_to_string(path)
@@ -345,54 +236,6 @@ pub fn has_legacy_ssh_mount(config: &SandcageConfig) -> bool {
config.ssh_mode.is_none() && check_existing_mount(config, SSH_MOUNT) config.ssh_mode.is_none() && check_existing_mount(config, SSH_MOUNT)
} }
pub fn write_ssh_config_to_yaml(path: &Path, entries: &[SshDiscoveryEntry]) -> Result<()> {
let content = if path.exists() {
std::fs::read_to_string(path)
.map_err(|e| SetupError::ConfigReadFailed(path.to_path_buf(), e))?
} else {
String::new()
};
let mut config: SandcageConfig = if content.trim().is_empty() {
SandcageConfig::default()
} else {
serde_yaml::from_str(&content)
.map_err(|e| SetupError::ConfigParseFailed(path.to_path_buf(), e.to_string()))?
};
config.ssh_mode = Some("volume".to_string());
config.ssh_keys = Some(
entries
.iter()
.map(|e| SshKeyEntry {
host: e
.ssh_host_alias
.clone()
.unwrap_or_else(|| e.display_host.clone()),
identity_file: e.identity_file.clone().unwrap_or_default(),
})
.collect(),
);
// Remove old bind mount if present
if let Some(ref mut mounts) = config.mounts {
mounts.retain(|m| m != SSH_MOUNT);
if mounts.is_empty() {
config.mounts = None;
}
}
let yaml = serde_yaml::to_string(&config)
.map_err(|e| SetupError::ConfigParseFailed(path.to_path_buf(), e.to_string()))?;
let output = rehydrate_yaml_comments(&content, &yaml);
std::fs::write(path, output)
.map_err(|e| SetupError::ConfigWriteFailed(path.to_path_buf(), e))?;
Ok(())
}
pub fn write_ssh_config_to_toml(path: &Path, entries: &[SshDiscoveryEntry]) -> Result<()> { pub fn write_ssh_config_to_toml(path: &Path, entries: &[SshDiscoveryEntry]) -> Result<()> {
let content = if path.exists() { let content = if path.exists() {
std::fs::read_to_string(path) std::fs::read_to_string(path)
@@ -455,7 +298,7 @@ pub fn run_ssh_setup(global: bool, yes: bool, refresh: bool, bind: bool) -> Resu
} else { } else {
let cwd = std::env::current_dir() let cwd = std::env::current_dir()
.map_err(|e| SetupError::ReadDirFailed(PathBuf::from("."), e))?; .map_err(|e| SetupError::ReadDirFailed(PathBuf::from("."), e))?;
cwd.join(".sandcage").join("sandcage.yml") cwd.join(".sandcage").join("config.toml")
}; };
if config_path_for_check.exists() { if config_path_for_check.exists() {
@@ -478,31 +321,15 @@ pub fn run_ssh_setup(global: bool, yes: bool, refresh: bool, bind: bool) -> Resu
.interact() .interact()
.unwrap_or(false); .unwrap_or(false);
if !migrate { if !migrate {
// Set explicit bind mode so we don't ask again let mut doc: toml_edit::DocumentMut =
if global { std::fs::read_to_string(&config_path_for_check)
let mut doc: toml_edit::DocumentMut = .unwrap_or_default()
std::fs::read_to_string(&config_path_for_check) .parse()
.unwrap_or_default() .unwrap_or_default();
.parse() doc["ssh_mode"] = toml_edit::value("bind");
.unwrap_or_default(); std::fs::write(&config_path_for_check, doc.to_string()).map_err(
doc["ssh_mode"] = toml_edit::value("bind"); |e| SetupError::ConfigWriteFailed(config_path_for_check, e),
std::fs::write(&config_path_for_check, doc.to_string()).map_err( )?;
|e| SetupError::ConfigWriteFailed(config_path_for_check, e),
)?;
} else {
let content = std::fs::read_to_string(&config_path_for_check)
.map_err(|e| {
SetupError::ConfigReadFailed(config_path_for_check.clone(), e)
})?;
let mut cfg: SandcageConfig =
serde_yaml::from_str(&content).unwrap_or_default();
cfg.ssh_mode = Some("bind".to_string());
let yaml = serde_yaml::to_string(&cfg).unwrap_or_default();
let output = rehydrate_yaml_comments(&content, &yaml);
std::fs::write(&config_path_for_check, output).map_err(|e| {
SetupError::ConfigWriteFailed(config_path_for_check, e)
})?;
}
eprintln!("sandcage: keeping bind mount, set ssh_mode: bind"); eprintln!("sandcage: keeping bind mount, set ssh_mode: bind");
return Ok(()); return Ok(());
} }
@@ -602,7 +429,7 @@ pub fn run_ssh_setup(global: bool, yes: bool, refresh: bool, bind: bool) -> Resu
} else { } else {
let cwd = std::env::current_dir() let cwd = std::env::current_dir()
.map_err(|e| SetupError::ReadDirFailed(PathBuf::from("."), e))?; .map_err(|e| SetupError::ReadDirFailed(PathBuf::from("."), e))?;
(cwd.join(".sandcage").join("local").join("sandcage.local.yml"), ".sandcage/local/sandcage.local.yml", false) (cwd.join(".sandcage").join("local").join("config.local.toml"), ".sandcage/local/config.local.toml", false)
}; };
// Write config // Write config
@@ -613,7 +440,7 @@ pub fn run_ssh_setup(global: bool, yes: bool, refresh: bool, bind: bool) -> Resu
} }
write_ssh_config_to_toml(&config_path, &selected_entries)?; write_ssh_config_to_toml(&config_path, &selected_entries)?;
} else { } else {
write_ssh_config_to_yaml(&config_path, &selected_entries)?; write_ssh_config_to_toml(&config_path, &selected_entries)?;
} }
// Populate volume // Populate volume
@@ -628,13 +455,19 @@ pub fn run_ssh_setup(global: bool, yes: bool, refresh: bool, bind: bool) -> Resu
fn run_ssh_refresh(global: bool, ssh_dir: &Path) -> Result<()> { fn run_ssh_refresh(global: bool, ssh_dir: &Path) -> Result<()> {
let home = dirs::home_dir().ok_or(SetupError::NoHomeDir)?; let home = dirs::home_dir().ok_or(SetupError::NoHomeDir)?;
let global_config = home.join(".sandcage").join("config.toml");
let config_path = if global { let config_path = if global {
home.join(".sandcage").join("config.toml") global_config.clone()
} else { } else {
let cwd = std::env::current_dir() let cwd = std::env::current_dir()
.map_err(|e| SetupError::ReadDirFailed(PathBuf::from("."), e))?; .map_err(|e| SetupError::ReadDirFailed(PathBuf::from("."), e))?;
cwd.join(".sandcage").join("local").join("sandcage.local.yml") let local = cwd.join(".sandcage").join("local").join("config.local.toml");
if local.exists() {
local
} else {
global_config.clone()
}
}; };
if !config_path.exists() { if !config_path.exists() {
@@ -644,7 +477,7 @@ fn run_ssh_refresh(global: bool, ssh_dir: &Path) -> Result<()> {
)); ));
} }
let cfg = if global { let cfg = {
let figment = figment::Figment::from( let figment = figment::Figment::from(
figment::providers::Serialized::defaults(SandcageConfig::default()), figment::providers::Serialized::defaults(SandcageConfig::default()),
) )
@@ -652,9 +485,6 @@ fn run_ssh_refresh(global: bool, ssh_dir: &Path) -> Result<()> {
figment figment
.extract::<SandcageConfig>() .extract::<SandcageConfig>()
.map_err(|e| SetupError::ConfigParseFailed(config_path.clone(), e.to_string()))? .map_err(|e| SetupError::ConfigParseFailed(config_path.clone(), e.to_string()))?
} else {
crate::config::load(&config_path)
.map_err(|e| SetupError::ConfigParseFailed(config_path.clone(), e.to_string()))?
}; };
let ssh_keys = cfg.ssh_keys.unwrap_or_default(); let ssh_keys = cfg.ssh_keys.unwrap_or_default();
@@ -727,11 +557,11 @@ fn run_ssh_setup_bind(global: bool, yes: bool) -> Result<()> {
} else { } else {
let cwd = let cwd =
std::env::current_dir().map_err(|e| SetupError::ReadDirFailed(PathBuf::from("."), e))?; std::env::current_dir().map_err(|e| SetupError::ReadDirFailed(PathBuf::from("."), e))?;
let project_config = cwd.join(".sandcage").join("sandcage.yml"); let project_config = cwd.join(".sandcage").join("config.toml");
if !project_config.exists() { if !project_config.exists() {
return Err(SetupError::NoProjectConfig); return Err(SetupError::NoProjectConfig);
} }
(project_config, ".sandcage/sandcage.yml".to_string()) (project_config, ".sandcage/config.toml".to_string())
}; };
// Check if already configured // Check if already configured
@@ -783,7 +613,7 @@ fn run_ssh_setup_bind(global: bool, yes: bool) -> Result<()> {
} }
add_mount_to_toml(&config_path, SSH_MOUNT)?; add_mount_to_toml(&config_path, SSH_MOUNT)?;
} else { } else {
add_mount_to_yaml(&config_path, SSH_MOUNT)?; add_mount_to_toml(&config_path, SSH_MOUNT)?;
} }
eprintln!("sandcage: added SSH mount to {config_label}"); eprintln!("sandcage: added SSH mount to {config_label}");
@@ -1018,8 +848,7 @@ pub fn populate_ssh_volume(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::io::Write; use tempfile::TempDir;
use tempfile::{NamedTempFile, TempDir};
fn touch(dir: &Path, name: &str) { fn touch(dir: &Path, name: &str) {
std::fs::write(dir.join(name), "").expect("touch file"); std::fs::write(dir.join(name), "").expect("touch file");
@@ -1108,81 +937,6 @@ mod tests {
assert!(!check_existing_mount(&config, "~/.ssh:/home/agent/.ssh:ro")); assert!(!check_existing_mount(&config, "~/.ssh:/home/agent/.ssh:ro"));
} }
// -- add_mount_to_yaml tests --
#[test]
fn add_mount_to_yaml_with_existing_mounts() {
let yaml = "env:\n FOO: bar\nmounts:\n - /data:/mnt\n";
let mut tmp = NamedTempFile::new().unwrap();
write!(tmp, "{yaml}").unwrap();
add_mount_to_yaml(tmp.path(), "~/.ssh:/home/agent/.ssh:ro").unwrap();
let content = std::fs::read_to_string(tmp.path()).unwrap();
assert!(content.contains("~/.ssh:/home/agent/.ssh:ro"));
assert!(content.contains("/data:/mnt"));
assert!(content.contains("FOO"));
}
#[test]
fn add_mount_to_yaml_no_existing_mounts() {
let yaml = "env:\n FOO: bar\n";
let mut tmp = NamedTempFile::new().unwrap();
write!(tmp, "{yaml}").unwrap();
add_mount_to_yaml(tmp.path(), "~/.ssh:/home/agent/.ssh:ro").unwrap();
let content = std::fs::read_to_string(tmp.path()).unwrap();
assert!(content.contains("~/.ssh:/home/agent/.ssh:ro"));
assert!(content.contains("FOO"));
}
#[test]
fn add_mount_to_yaml_empty_file() {
let mut tmp = NamedTempFile::new().unwrap();
write!(tmp, "").unwrap();
add_mount_to_yaml(tmp.path(), "~/.ssh:/home/agent/.ssh:ro").unwrap();
let content = std::fs::read_to_string(tmp.path()).unwrap();
assert!(content.contains("~/.ssh:/home/agent/.ssh:ro"));
}
#[test]
fn add_mount_to_yaml_preserves_comments() {
let yaml = "# Environment\nenv:\n FOO: bar\n\n# Mount points\nmounts:\n- /data:/mnt\n";
let mut tmp = NamedTempFile::new().unwrap();
write!(tmp, "{yaml}").unwrap();
add_mount_to_yaml(tmp.path(), "~/.ssh:/home/agent/.ssh:ro").unwrap();
let content = std::fs::read_to_string(tmp.path()).unwrap();
assert!(content.contains("# Environment"), "preamble comment preserved");
assert!(content.contains("# Mount points"), "inline comment preserved");
assert!(content.contains("~/.ssh:/home/agent/.ssh:ro"));
assert!(content.contains("/data:/mnt"));
}
#[test]
fn add_mount_to_yaml_preserves_trailing_comments() {
let yaml = "mounts:\n- /data:/mnt\n\n# end of file\n";
let mut tmp = NamedTempFile::new().unwrap();
write!(tmp, "{yaml}").unwrap();
add_mount_to_yaml(tmp.path(), "~/.ssh:/home/agent/.ssh:ro").unwrap();
let content = std::fs::read_to_string(tmp.path()).unwrap();
assert!(content.contains("# end of file"), "trailing comment preserved");
}
#[test]
fn rehydrate_preserves_blank_lines_between_keys() {
let original = "env:\n A: 1\n\nmounts:\n- /data:/mnt\n";
let generated = "env:\n A: '1'\nmounts:\n- /data:/mnt\n- /new:/new\n";
let result = rehydrate_yaml_comments(original, generated);
assert!(result.contains("\n\nmounts:"), "blank line before mounts preserved");
}
// -- add_mount_to_toml tests -- // -- add_mount_to_toml tests --
#[test] #[test]