use super::{invalid, read};
use crate::ConfigError;
use std::collections::BTreeMap;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use toml_edit::{DocumentMut, value};
struct Edit {
path: PathBuf,
original: Option<String>,
updated: String,
}
pub struct PoolMigration {
destination: PathBuf,
final_policy: Option<String>,
edits: Vec<Edit>,
pools: Vec<String>,
}
impl PoolMigration {
#[must_use]
pub fn pools(&self) -> &[String] {
&self.pools
}
pub fn paths(&self) -> impl Iterator<Item = &Path> {
self.edits.iter().map(|edit| edit.path.as_path())
}
pub fn mark_default(
&mut self,
pool: &str,
provider: &str,
owner: &str,
group: &str,
) -> Result<(), ConfigError> {
if [provider, owner, group]
.iter()
.any(|value| value.trim().is_empty())
{
return Err(invalid(
"Default pool requires a validated provider compatibility identity",
));
}
if !self.edits.iter().any(|edit| edit.path == self.destination) {
let original = optional(&self.destination)?;
self.edits.insert(
0,
Edit {
path: self.destination.clone(),
updated: original
.clone()
.ok_or_else(|| invalid("Shared policy is missing"))?,
original,
},
);
}
let edit = self
.edits
.first_mut()
.ok_or_else(|| invalid("No shared policy changes planned"))?;
let mut doc = document(&edit.updated)?;
let raw: toml::Value =
toml::from_str(&edit.updated).map_err(|_| invalid("Invalid shared policy"))?;
let pools = super::pools(&raw)?;
let selected = pools
.get(pool)
.ok_or_else(|| invalid("Default pool is not in shared policy"))?;
if selected
.get("provider_plugin_id")
.and_then(toml::Value::as_str)
!= Some(owner)
{
return Err(invalid(
"Default pool owner does not match registered provider",
));
}
for (name, policy) in pools {
if name != pool
&& policy
.get("automatic_provider")
.and_then(toml::Value::as_str)
== Some(provider)
&& policy.get("automatic_group").and_then(toml::Value::as_str) == Some(group)
{
return Err(invalid(
"Another automatic default already exists for this provider group",
));
}
}
for (key, expected) in [("automatic_provider", provider), ("automatic_group", group)] {
if selected
.get(key)
.is_some_and(|value| value.as_str() != Some(expected))
{
return Err(invalid("Existing automatic pool identity conflicts"));
}
doc["auth"]["pools"][pool][key] = value(expected);
}
let updated = doc.to_string();
if updated == edit.updated {
if edit.original.as_deref() == Some(edit.updated.as_str()) {
self.edits.remove(0);
}
} else {
self.final_policy = Some(updated);
}
Ok(())
}
pub fn apply(self) -> Result<(), ConfigError> {
let mut ordered: Vec<_> = self.edits.iter().collect();
ordered.sort_by_key(|edit| &edit.path);
let mut locks = Vec::new();
for edit in ordered {
let lock_path = edit.path.with_extension("toml.lock");
let lock = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.map_err(|source| io(&lock_path, source))?;
lock.lock().map_err(|source| io(&lock_path, source))?;
locks.push(lock);
}
for edit in &self.edits {
if optional(&edit.path)? != edit.original {
return Err(invalid(
"Pool migration source changed; review a fresh plan",
));
}
}
for edit in &self.edits {
if let Some(original) = &edit.original {
let backup = edit.path.with_extension("toml.pool-migration.bak");
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&backup)
{
Ok(mut file) => {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
file.set_permissions(std::fs::Permissions::from_mode(0o600))
.map_err(|source| io(&backup, source))?;
}
file.write_all(original.as_bytes())
.map_err(|source| io(&backup, source))?;
file.sync_all().map_err(|source| io(&backup, source))?;
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
if read(&backup)? != *original {
return Err(invalid(
"Migration backup already exists with different contents; preserve it and choose a fresh backup location before retrying",
));
}
}
Err(error) => return Err(io(&backup, error)),
}
}
if let Some(parent) = edit.path.parent() {
std::fs::File::open(parent)
.and_then(|file| file.sync_all())
.map_err(|source| io(parent, source))?;
}
}
for edit in self.edits {
crate::edit::atomic_replace(&edit.path, edit.updated.as_bytes())
.map_err(|source| io(&edit.path, source))?;
}
if let Some(policy) = self.final_policy {
crate::edit::atomic_replace(&self.destination, policy.as_bytes())
.map_err(|source| io(&self.destination, source))?;
}
drop(locks);
Ok(())
}
}
fn io(path: &Path, source: std::io::Error) -> ConfigError {
ConfigError::Io {
path: path.to_owned(),
source,
}
}
fn optional(path: &Path) -> Result<Option<String>, ConfigError> {
match std::fs::symlink_metadata(path) {
Ok(meta) if meta.is_file() => read(path).map(Some),
Ok(_) => Err(invalid(
"Pool migration requires regular files, not symlinks",
)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(io(path, error)),
}
}
fn document(contents: &str) -> Result<DocumentMut, ConfigError> {
contents
.parse()
.map_err(|_| invalid("Pool migration requires valid TOML"))
}
pub fn plan(destination: &Path, sources: &[PathBuf]) -> Result<PoolMigration, ConfigError> {
if sources.is_empty() {
return Err(invalid("Select at least one model preset"));
}
let parent = destination
.parent()
.ok_or_else(|| invalid("Shared policy needs a parent directory"))?;
let destination = parent
.canonicalize()
.map_err(|error| io(parent, error))?
.join(
destination
.file_name()
.ok_or_else(|| invalid("Invalid shared policy path"))?,
);
let original = optional(&destination)?;
let mut shared = document(
original
.as_deref()
.unwrap_or("schema_version = 1\n[auth.pools]\n"),
)?;
let raw: toml::Value =
toml::from_str(&shared.to_string()).map_err(|_| invalid("Invalid shared pool policy"))?;
super::pools(&raw)?;
let mut policies = BTreeMap::<String, toml::Value>::new();
for (name, policy) in super::pools(&raw)? {
policies.insert(name.clone(), policy.clone());
}
let mut edits = Vec::new();
let mut seen = std::collections::BTreeSet::from([destination.clone()]);
for source in sources {
let parent = source
.parent()
.ok_or_else(|| invalid("Preset needs a parent directory"))?;
let path = parent
.canonicalize()
.map_err(|error| io(parent, error))?
.join(
source
.file_name()
.ok_or_else(|| invalid("Invalid preset path"))?,
);
if !seen.insert(path.clone()) {
return Err(invalid("Migration paths must be unique"));
}
let contents = optional(&path)?.ok_or_else(|| invalid("Preset does not exist"))?;
let mut doc = document(&contents)?;
consolidate(&mut doc, &destination, &mut shared, &mut policies)?;
let updated = doc.to_string();
if updated != contents {
edits.push(Edit {
path,
original: Some(contents),
updated,
});
}
}
let updated = shared.to_string();
if original.as_deref() != Some(updated.as_str()) {
edits.insert(
0,
Edit {
path: destination.clone(),
original,
updated,
},
);
}
Ok(PoolMigration {
destination,
final_policy: None,
edits,
pools: policies.into_keys().collect(),
})
}
fn consolidate(
doc: &mut DocumentMut,
destination: &Path,
shared: &mut DocumentMut,
policies: &mut BTreeMap<String, toml::Value>,
) -> Result<(), ConfigError> {
if let Some(reference) = doc.get("auth_pool_config")
&& reference.as_str() != destination.to_str()
{
return Err(invalid(
"Preset already references another shared pool file",
));
}
if doc.get("auth").and_then(|auth| auth.get("pools")).is_some() {
let raw: toml::Value =
toml::from_str(&doc.to_string()).map_err(|_| invalid("Invalid preset"))?;
let local = raw
.get("auth")
.and_then(|auth| auth.get("pools"))
.and_then(toml::Value::as_table)
.ok_or_else(|| invalid("Invalid auth.pools"))?;
for (name, policy) in local {
if let Some(existing) = policies.get(name) {
if existing != policy {
return Err(invalid(format!(
"Conflicting definitions for pool '{name}'; no files changed"
)));
}
} else {
policies.insert(name.clone(), policy.clone());
let item = doc["auth"]["pools"][name].clone();
shared["auth"]["pools"][name] = item;
}
}
doc["auth"]
.as_table_like_mut()
.ok_or_else(|| invalid("Invalid auth table"))?
.remove("pools");
}
if doc.get("auth_pool_config").is_none() {
doc.as_table_mut().insert(
"auth_pool_config",
value(
destination
.to_str()
.ok_or_else(|| invalid("Shared path must be UTF-8"))?,
),
);
}
let raw: toml::Value = toml::from_str(&shared.to_string())
.map_err(|_| invalid("Invalid resulting shared policy"))?;
super::pools(&raw)?;
Ok(())
}