Files
depot/src/config.rs
T
SFG545 d9a30f5d56 feat: add metapackage build support and fail-fast custom scripts
- add BuildType::Meta handling in builder and interactive spec output
- allow metapackage specs without sources and expose is_metapackage()
- avoid build deps in install planning for metapackages
- run custom non-function build.sh scripts with `sh -e` to stop on first failure
- add regression test for custom script fail-fast behavior
- apply small cleanup refactors (let-chains/iterator simplifications)
2026-02-25 16:49:36 -06:00

773 lines
26 KiB
Rust
Executable File

//! Global configuration for Depot
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::path::{Path, PathBuf};
fn default_true() -> bool {
true
}
fn default_repo_db_filename() -> String {
"repo.db.zst".to_string()
}
fn resolve_rootfs_base(rootfs: &Path) -> PathBuf {
if rootfs.exists() {
rootfs.canonicalize().unwrap_or_else(|_| {
std::env::current_dir()
.map(|cwd| cwd.join(rootfs))
.unwrap_or_else(|_| rootfs.to_path_buf())
})
} else {
std::env::current_dir()
.map(|cwd| cwd.join(rootfs))
.unwrap_or_else(|_| rootfs.to_path_buf())
}
}
/// Global repo behavior settings loaded from `repos.toml`.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct RepoSettings {
/// Prefer binary repos over source repos when both can satisfy a request.
#[serde(default)]
pub prefer_binary: bool,
}
/// Source repository configuration entry loaded from `repos.toml`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceRepo {
/// Git URL for the package repo.
pub url: String,
/// Whether the repo is enabled for lookups/sync.
#[serde(default = "default_true")]
pub enabled: bool,
/// Lower numbers are higher priority.
#[serde(default)]
pub priority: i32,
/// Optional subdirectories to scan/index inside the git checkout.
#[serde(default)]
pub subdirs: Vec<String>,
}
/// Binary repository configuration entry loaded from `repos.toml`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct BinaryRepoArchEntry {
/// Whether this architecture is enabled for this repo.
#[serde(default = "default_true")]
pub enabled: bool,
/// Optional URL override for this architecture.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
/// Optional repo DB path override for this architecture.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo_db: Option<String>,
}
/// Binary repository configuration entry loaded from `repos.toml`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BinaryRepo {
/// Base URL for the binary repository.
pub url: String,
/// Whether the repo is enabled for lookups.
#[serde(default = "default_true")]
pub enabled: bool,
/// Lower numbers are higher priority.
#[serde(default)]
pub priority: i32,
/// Repo database filename/path relative to `url`.
#[serde(default = "default_repo_db_filename")]
pub repo_db: String,
/// Architecture-specific overrides/enablement.
///
/// Example:
/// `[binary.core.arch.x86_64]`
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub arch: BTreeMap<String, BinaryRepoArchEntry>,
/// Allow unsigned repo metadata for this repo.
#[serde(default)]
pub allow_unsigned: bool,
}
impl Default for BinaryRepo {
fn default() -> Self {
Self {
url: String::new(),
enabled: true,
priority: 0,
repo_db: default_repo_db_filename(),
arch: BTreeMap::new(),
allow_unsigned: false,
}
}
}
impl BinaryRepo {
/// Return true if this repo is enabled for the requested machine architecture.
pub fn supports_arch(&self, machine_arch: &str) -> bool {
if self.arch.is_empty() {
return true;
}
self.arch
.get(machine_arch)
.map(|entry| entry.enabled)
.unwrap_or(false)
}
/// Return the effective base URL for `machine_arch`, including arch overrides.
pub fn effective_url_for_arch<'a>(&'a self, machine_arch: &str) -> Option<&'a str> {
if self.arch.is_empty() {
return Some(self.url.as_str());
}
let entry = self.arch.get(machine_arch)?;
if !entry.enabled {
return None;
}
Some(entry.url.as_deref().unwrap_or(self.url.as_str()))
}
/// Return the effective repo DB path for `machine_arch`, including arch overrides.
pub fn effective_repo_db_for_arch<'a>(&'a self, machine_arch: &str) -> Option<&'a str> {
if self.arch.is_empty() {
return Some(self.repo_db.as_str());
}
let entry = self.arch.get(machine_arch)?;
if !entry.enabled {
return None;
}
Some(entry.repo_db.as_deref().unwrap_or(self.repo_db.as_str()))
}
}
/// Parsed contents of `/etc/depot.d/repos.toml`.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct RepoConfigFile {
/// Global repo behavior settings.
#[serde(default, skip_serializing_if = "repo_settings_is_default")]
pub settings: RepoSettings,
/// Source repos keyed by repo name.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub source: BTreeMap<String, SourceRepo>,
/// Binary repos keyed by repo name.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub binary: BTreeMap<String, BinaryRepo>,
}
fn repo_settings_is_default(settings: &RepoSettings) -> bool {
!settings.prefer_binary
}
/// Return the canonical `repos.toml` path for the given rootfs.
pub fn repos_toml_path(rootfs: &Path) -> PathBuf {
resolve_rootfs_base(rootfs).join("etc/depot.d/repos.toml")
}
/// Load `repos.toml` for the given rootfs. Missing file returns defaults.
pub fn load_repos_config_file(rootfs: &Path) -> Result<RepoConfigFile> {
let path = repos_toml_path(rootfs);
if !path.exists() {
return Ok(RepoConfigFile::default());
}
let content =
fs::read_to_string(&path).with_context(|| format!("Failed to read {}", path.display()))?;
let parsed: RepoConfigFile =
toml::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))?;
Ok(parsed)
}
/// Save `repos.toml` for the given rootfs, creating `/etc/depot.d` as needed.
pub fn save_repos_config_file(rootfs: &Path, repos: &RepoConfigFile) -> Result<PathBuf> {
let path = repos_toml_path(rootfs);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create {}", parent.display()))?;
}
let content = toml::to_string_pretty(repos).context("Failed to serialize repos.toml")?;
fs::write(&path, content).with_context(|| format!("Failed to write {}", path.display()))?;
Ok(path)
}
/// Global configuration settings
#[derive(Clone)]
pub struct Config {
/// Directory for cached source tarballs
pub cache_dir: PathBuf,
/// Directory for building packages
pub build_dir: PathBuf,
/// Directory for package database
pub db_dir: PathBuf,
/// System-level build overrides from /etc/depot.d/build.toml
pub build_overrides: toml::Value,
/// System-level package overrides from /etc/depot.d/package.toml
pub package_overrides: toml::Value,
/// Appends found in system TOML files (key -> values to append)
pub appends: HashMap<String, Vec<toml::Value>>,
/// Parsed repo settings from `/etc/depot.d/repos.toml`.
pub repo_settings: RepoSettings,
/// Source repos from `/etc/depot.d/repos.toml` (and legacy mirrors fallback).
pub source_repos: BTreeMap<String, SourceRepo>,
/// Binary repos from `/etc/depot.d/repos.toml`.
pub binary_repos: BTreeMap<String, BinaryRepo>,
/// Mirrors mapping read from /etc/depot.d/mirrors.toml (reponame -> git url)
pub mirrors: std::collections::HashMap<String, String>,
/// Directory where git mirrors are cloned (absolute path). Defaults to
/// <rootfs>/usr/src/depot unless overridden in depot.toml
pub repo_clone_dir: PathBuf,
/// Cache directory for binary packages and repo metadata.
pub package_cache_dir: PathBuf,
}
impl Config {
/// Create config with paths relative to the given rootfs
pub fn for_rootfs(rootfs: &Path) -> Self {
let abs_rootfs = resolve_rootfs_base(rootfs);
let is_system_root = abs_rootfs == Path::new("/") || abs_rootfs.as_os_str() == "/";
let is_root = crate::fakeroot::is_root();
let (cache_dir, package_cache_dir, build_dir, db_dir) = if is_system_root && !is_root {
let home = std::env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/tmp"));
(
home.join(".cache/depot/sources"),
home.join(".cache/depot/packages"),
home.join(".cache/depot/build"),
home.join(".local/share/depot"),
)
} else {
(
abs_rootfs.join("var/cache/depot/sources"),
abs_rootfs.join("var/cache/depot/packages"),
abs_rootfs.join("var/cache/depot/build"),
abs_rootfs.join("var/lib/depot"),
)
};
let mut config = Self {
cache_dir,
build_dir,
db_dir,
build_overrides: toml::Value::Table(toml::map::Map::new()),
package_overrides: toml::Value::Table(toml::map::Map::new()),
appends: HashMap::new(),
repo_settings: RepoSettings::default(),
source_repos: BTreeMap::new(),
binary_repos: BTreeMap::new(),
mirrors: std::collections::HashMap::new(),
repo_clone_dir: abs_rootfs.join("usr/src/depot"),
package_cache_dir,
};
if let Err(e) = config.load_system(&abs_rootfs) {
crate::log_warn!("Failed to load system config: {}", e);
}
config
}
/// Load system-level and user-level overrides
pub fn load_system(&mut self, rootfs: &Path) -> Result<()> {
// Load host system config (fallback) and then the requested rootfs config
// so that rootfs-specific settings override host settings when present.
let mut config_paths: Vec<PathBuf> = Vec::new();
let host_etc = PathBuf::from("/etc/depot.toml");
let rootfs_etc = rootfs.join("etc/depot.toml");
if host_etc.exists() && host_etc != rootfs_etc {
config_paths.push(host_etc);
}
config_paths.push(rootfs_etc);
// Add user-level config paths (user override) — these should remain highest precedence
if let Ok(home) = std::env::var("HOME") {
let home_path = PathBuf::from(home);
config_paths.push(home_path.join(".config/depot.toml"));
config_paths.push(home_path.join(".local/share/depot.toml"));
}
for path in config_paths {
if path.exists() {
let content = fs::read_to_string(&path)
.with_context(|| format!("Failed to read config: {}", path.display()))?;
let (val, appends) = self.preprocess_toml(&content)?;
// If it has a [build] section, merge it into build_overrides
if let Some(build) = val.get("build") {
merge_toml_values(&mut self.build_overrides, build);
}
// If it has a [package] section, merge it into package_overrides
if let Some(pkg) = val.get("package") {
merge_toml_values(&mut self.package_overrides, pkg);
}
for (k, v) in appends {
self.appends.insert(k, v);
}
}
}
// Keep existing etc/depot.d/ support. Check host (/etc/depot.d) first as a
// fallback, then the rootfs path so the rootfs can override host settings.
let host_build = PathBuf::from("/etc/depot.d/build.toml");
let build_path = rootfs.join("etc/depot.d/build.toml");
if host_build.exists() && host_build != build_path {
let content = fs::read_to_string(&host_build).with_context(|| {
format!(
"Failed to read system build config: {}",
host_build.display()
)
})?;
let (val, appends) = self.preprocess_toml(&content)?;
merge_toml_values(&mut self.build_overrides, &val);
for (k, v) in appends {
self.appends.insert(format!("build.{}", k), v);
}
}
if build_path.exists() {
let content = fs::read_to_string(&build_path).with_context(|| {
format!(
"Failed to read system build config: {}",
build_path.display()
)
})?;
let (val, appends) = self.preprocess_toml(&content)?;
merge_toml_values(&mut self.build_overrides, &val);
for (k, v) in appends {
self.appends.insert(format!("build.{}", k), v);
}
}
let host_package = PathBuf::from("/etc/depot.d/package.toml");
let package_path = rootfs.join("etc/depot.d/package.toml");
if host_package.exists() && host_package != package_path {
let content = fs::read_to_string(&host_package).with_context(|| {
format!(
"Failed to read system package config: {}",
host_package.display()
)
})?;
let (val, appends) = self.preprocess_toml(&content)?;
merge_toml_values(&mut self.package_overrides, &val);
for (k, v) in appends {
self.appends.insert(format!("package.{}", k), v);
}
}
if package_path.exists() {
let content = fs::read_to_string(&package_path).with_context(|| {
format!(
"Failed to read system package config: {}",
package_path.display()
)
})?;
let (val, appends) = self.preprocess_toml(&content)?;
merge_toml_values(&mut self.package_overrides, &val);
for (k, v) in appends {
self.appends.insert(format!("package.{}", k), v);
}
}
// Load new repos file: /etc/depot.d/repos.toml (host fallback then rootfs override).
let host_repos_path = PathBuf::from("/etc/depot.d/repos.toml");
let repos_path = rootfs.join("etc/depot.d/repos.toml");
for path in [host_repos_path, repos_path] {
if !path.exists() {
continue;
}
let content = fs::read_to_string(&path)
.with_context(|| format!("Failed to read repos config: {}", path.display()))?;
let parsed: RepoConfigFile = toml::from_str(&content)
.with_context(|| format!("Failed to parse repos config: {}", path.display()))?;
self.repo_settings = parsed.settings;
for (name, repo) in parsed.source {
self.source_repos.insert(name, repo);
}
for (name, repo) in parsed.binary {
self.binary_repos.insert(name, repo);
}
}
// Load legacy mirrors file: /etc/depot.d/mirrors.toml
let mirrors_path = rootfs.join("etc/depot.d/mirrors.toml");
if mirrors_path.exists() {
let content = fs::read_to_string(&mirrors_path).with_context(|| {
format!("Failed to read mirrors config: {}", mirrors_path.display())
})?;
let val: toml::Value = toml::from_str(&content)?;
if let Some(table) = val.as_table() {
for (k, v) in table {
if let Some(s) = v.as_str() {
self.mirrors.insert(k.clone(), s.to_string());
self.source_repos.entry(k.clone()).or_insert(SourceRepo {
url: s.to_string(),
enabled: true,
priority: 0,
subdirs: Vec::new(),
});
}
}
}
}
// Allow overriding repo clone dir via [repo] clone_dir in depot.toml
if let Some(repo_table) = self.build_overrides.get("repo").and_then(|v| v.as_table())
&& let Some(clone_val) = repo_table.get("clone_dir").and_then(|v| v.as_str())
{
let p = PathBuf::from(clone_val);
// If relative path, make it relative to rootfs
let repo_dir = if p.is_absolute() { p } else { rootfs.join(p) };
self.repo_clone_dir = repo_dir;
}
Ok(())
}
/// Return enabled source repos as `name -> git URL` pairs for legacy sync/status code.
pub fn enabled_source_mirror_map(&self) -> std::collections::HashMap<String, String> {
let mut out = std::collections::HashMap::new();
for (name, repo) in &self.source_repos {
if repo.enabled {
out.insert(name.clone(), repo.url.clone());
}
}
out
}
}
fn merge_toml_values(base: &mut toml::Value, over: &toml::Value) {
if let (Some(base_table), Some(over_table)) = (base.as_table_mut(), over.as_table()) {
for (k, v) in over_table {
if v.is_table() && base_table.contains_key(k) && base_table[k].is_table() {
merge_toml_values(&mut base_table[k], v);
} else {
base_table.insert(k.clone(), v.clone());
}
}
}
}
impl Config {
/// Preprocess TOML to support `key += value` syntax.
/// Returns the base toml::Value and a map of append operations.
fn preprocess_toml(
&self,
input: &str,
) -> Result<(toml::Value, HashMap<String, Vec<toml::Value>>)> {
let mut base_text = String::new();
let mut appends = HashMap::new();
for line in input.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
base_text.push_str(line);
base_text.push('\n');
continue;
}
if let Some(plus_idx) = trimmed.find("+=") {
let key = trimmed[..plus_idx].trim().to_string();
let val_str = trimmed[plus_idx + 2..].trim();
let val: toml::Value = toml::from_str::<toml::Value>(&format!("v = {}", val_str))
.context("Failed to parse append value")?
.get("v")
.cloned()
.unwrap();
appends.entry(key).or_insert_with(Vec::new).push(val);
} else {
base_text.push_str(line);
base_text.push('\n');
}
}
let base_val: toml::Value = toml::from_str::<toml::Value>(&base_text)?;
Ok((base_val, appends))
}
}
impl Default for Config {
fn default() -> Self {
Self::for_rootfs(Path::new("/"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_for_rootfs() {
let root = PathBuf::from("/tmp/test_root");
let config = Config::for_rootfs(&root);
// Canonicalization might happen, so let's just check ends_with or construct reliably
assert!(
config
.cache_dir
.to_string_lossy()
.contains("var/cache/depot/sources")
);
assert!(
config
.build_dir
.to_string_lossy()
.contains("var/cache/depot/build")
);
assert!(config.db_dir.to_string_lossy().contains("var/lib/depot"));
}
#[test]
fn test_preprocess_toml() {
let config = Config::for_rootfs(Path::new("/tmp/nonexistent"));
let input = r#"
[flags]
cc = "clang"
cflags = ["-O2"]
# An append operation
cflags += ["-DDEBUG"]
ldflags += "-L/usr/local/lib"
"#;
let (base, appends) = config.preprocess_toml(input).unwrap();
// Base value should have the non-append parts
assert_eq!(
base.get("flags")
.and_then(|f| f.get("cc"))
.and_then(|v| v.as_str()),
Some("clang")
);
assert_eq!(
base.get("flags")
.and_then(|f| f.get("cflags"))
.and_then(|v| v.as_array())
.map(|a| a.len()),
Some(1)
);
// Appends should be captured
assert!(appends.contains_key("cflags"));
assert_eq!(appends.get("cflags").unwrap().len(), 1);
assert_eq!(
appends.get("cflags").unwrap()[0].as_array().unwrap()[0].as_str(),
Some("-DDEBUG")
);
assert!(appends.contains_key("ldflags"));
assert_eq!(
appends.get("ldflags").unwrap()[0].as_str(),
Some("-L/usr/local/lib")
);
}
#[test]
fn test_load_system() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let etc = root.join("etc/depot.d");
fs::create_dir_all(&etc).unwrap();
fs::write(
etc.join("build.toml"),
r#"
[flags]
cflags = ["-O2"]
cflags += ["-g"]
"#,
)
.unwrap();
let config = Config::for_rootfs(root);
assert_eq!(
config
.build_overrides
.get("flags")
.and_then(|f| f.get("cflags"))
.and_then(|v| v.as_array())
.map(|a| a.len()),
Some(1)
);
assert!(config.appends.contains_key("build.cflags"));
assert_eq!(
config.appends.get("build.cflags").unwrap()[0]
.as_array()
.unwrap()[0]
.as_str(),
Some("-g")
);
}
#[test]
fn test_config_non_root_fallback() {
// Make the test deterministic by overriding HOME for the duration of the check.
// Save and restore the original value so other tests aren't affected.
let orig_home = std::env::var_os("HOME");
let fake_home = tempfile::tempdir().unwrap();
unsafe {
std::env::set_var("HOME", &fake_home.path());
}
let config = Config::for_rootfs(Path::new("/"));
if !crate::fakeroot::is_root() {
let home = std::env::var("HOME").unwrap();
assert!(config.cache_dir.to_string_lossy().starts_with(&home));
assert!(
config
.cache_dir
.to_string_lossy()
.contains(".cache/depot/sources")
);
} else {
// If running as root (e.g. in some CI), it should use /var
assert_eq!(config.cache_dir, PathBuf::from("/var/cache/depot/sources"));
}
// restore original HOME
if let Some(v) = orig_home {
unsafe {
std::env::set_var("HOME", v);
}
} else {
unsafe {
std::env::remove_var("HOME");
}
}
}
#[test]
fn test_load_depot_toml() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let etc = root.join("etc");
fs::create_dir_all(&etc).unwrap();
fs::write(
etc.join("depot.toml"),
r#"
[build]
prefix = "/opt/depot"
cc = "clang"
[build.flags]
cflags = ["-O3"]
"#,
)
.unwrap();
let config = Config::for_rootfs(root);
// Config construction calls load_system automatically
assert_eq!(
config
.build_overrides
.get("prefix")
.and_then(|v| v.as_str()),
Some("/opt/depot")
);
assert_eq!(
config.build_overrides.get("cc").and_then(|v| v.as_str()),
Some("clang")
);
assert_eq!(
config
.build_overrides
.get("flags")
.and_then(|f| f.get("cflags"))
.and_then(|v| v.as_array())
.map(|a| a.len()),
Some(1)
);
}
#[test]
fn test_load_repos_toml() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let etc = root.join("etc/depot.d");
fs::create_dir_all(&etc).unwrap();
fs::write(
etc.join("repos.toml"),
r#"
[settings]
prefer_binary = true
[source.vertex]
url = "https://gitlab.com/vertex-linux/packages.git"
enabled = true
priority = 10
subdirs = ["core", "extra"]
[binary.vertex]
url = "https://repo.example.invalid"
enabled = false
priority = 5
repo_db = "repo.db.zst"
[binary.vertex.arch.x86_64]
enabled = true
"#,
)
.unwrap();
let config = Config::for_rootfs(root);
assert!(config.repo_settings.prefer_binary);
let src = config.source_repos.get("vertex").unwrap();
assert_eq!(src.url, "https://gitlab.com/vertex-linux/packages.git");
assert_eq!(src.subdirs, vec!["core".to_string(), "extra".to_string()]);
let bin = config.binary_repos.get("vertex").unwrap();
assert_eq!(bin.url, "https://repo.example.invalid");
assert!(!bin.enabled);
assert!(bin.arch.contains_key("x86_64"));
assert!(bin.supports_arch("x86_64"));
assert!(!bin.supports_arch("aarch64"));
}
#[test]
fn test_save_and_load_repos_config_file_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let mut repos = RepoConfigFile::default();
repos.source.insert(
"vertex".to_string(),
SourceRepo {
url: "https://gitlab.com/vertex-linux/packages.git".to_string(),
enabled: true,
priority: 1,
subdirs: vec!["core".to_string()],
},
);
repos.binary.insert(
"vertex".to_string(),
BinaryRepo {
url: "https://repo.example.invalid".to_string(),
enabled: true,
priority: 2,
repo_db: "repo.db.zst".to_string(),
arch: {
let mut map = BTreeMap::new();
map.insert("x86_64".to_string(), BinaryRepoArchEntry::default());
map
},
allow_unsigned: false,
},
);
let path = save_repos_config_file(root, &repos).unwrap();
assert!(path.exists());
let loaded = load_repos_config_file(root).unwrap();
assert!(loaded.source.contains_key("vertex"));
assert!(loaded.binary.contains_key("vertex"));
}
}