use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde_yaml_ng::Value;
use crate::error::{Error, Result};
use crate::interp;
use crate::spec::{ComposeFile, MountType, SandboxSpec};
pub const DEFAULT_FILES: &[&str] = &["isb.yaml", "isb.yml"];
pub const OVERRIDE_FILES: &[&str] = &["isb.override.yaml", "isb.override.yml"];
#[derive(Debug, Clone)]
pub struct Project {
pub name: String,
pub file: ComposeFile,
pub base_dir: PathBuf,
pub files: Vec<PathBuf>,
pub vars: BTreeMap<String, String>,
pub dotenv: BTreeMap<String, String>,
pub store_secrets: SecretValues,
}
#[derive(Clone, Default, PartialEq)]
pub struct SecretValues(pub BTreeMap<String, Vec<u8>>);
impl std::fmt::Debug for SecretValues {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_set().entries(self.0.keys()).finish()
}
}
impl Project {
pub fn service(&self, service: &str) -> Result<&SandboxSpec> {
self.file.services.get(service).ok_or_else(|| {
Error::invalid(format!(
"no service {service:?} in {} (have: {})",
self.files_display(),
self.file
.services
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
))
})
}
pub fn select(&self, services: &[String]) -> Result<Vec<String>> {
let order = dependency_order(&self.file).map_err(Error::invalid)?;
if services.is_empty() {
return Ok(order);
}
let mut want: Vec<String> = Vec::new();
let mut stack: Vec<String> = Vec::new();
for s in services {
self.service(s)?;
stack.push(s.clone());
}
while let Some(s) = stack.pop() {
if want.contains(&s) {
continue;
}
stack.extend(self.file.services[&s].depends_on.keys().cloned());
want.push(s);
}
Ok(order.into_iter().filter(|s| want.contains(s)).collect())
}
pub fn select_exact(&self, services: &[String]) -> Result<Vec<String>> {
for s in services {
self.service(s)?;
}
let order = dependency_order(&self.file).map_err(Error::invalid)?;
Ok(order
.into_iter()
.filter(|s| services.is_empty() || services.contains(s))
.collect())
}
pub fn files_display(&self) -> String {
self.files
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ")
}
pub fn lookup(&self, k: &str) -> Option<String> {
self.vars
.get(k)
.cloned()
.or_else(|| std::env::var(k).ok())
.or_else(|| self.dotenv.get(k).cloned())
}
pub fn secret_values(&self) -> Result<BTreeMap<String, Vec<u8>>> {
let mut out = crate::supervise::resolve_secret_values(&self.file, &self.base_dir, &|k| {
self.lookup(k)
})?;
for (key, def) in self.store_backed_secrets() {
let v = self.store_secrets.0.get(&key).ok_or_else(|| {
Error::invalid(format!(
"secret {key:?}: {} secrets come from the org's secret store, which was not read",
def.source_kind()
))
})?;
out.insert(key, v.clone());
}
Ok(out)
}
pub fn store_backed_secrets(&self) -> BTreeMap<String, crate::spec::SecretDef> {
crate::stack::secrets::used_keys(&self.file)
.into_iter()
.filter_map(|k| {
let d = self.file.secrets.get(&k)?;
(!d.is_client_side()).then(|| (k, d.clone()))
})
.collect()
}
pub fn to_yaml(&self) -> Result<String> {
serde_yaml_ng::to_string(&self.file).map_err(|e| Error::invalid(e.to_string()))
}
}
#[derive(Debug, Clone, Default)]
pub struct LoadOptions {
pub files: Vec<PathBuf>,
pub env_files: Vec<PathBuf>,
pub project_name: Option<String>,
pub vars: BTreeMap<String, String>,
}
pub fn find_default(dir: &Path) -> Option<PathBuf> {
DEFAULT_FILES
.iter()
.map(|f| dir.join(f))
.find(|p| p.is_file())
}
pub fn find_override(dir: &Path) -> Option<PathBuf> {
OVERRIDE_FILES
.iter()
.map(|f| dir.join(f))
.find(|p| p.is_file())
}
pub fn load(opts: &LoadOptions) -> Result<Project> {
let mut files = opts.files.clone();
if files.is_empty() {
let cwd = std::env::current_dir()?;
files.push(find_default(&cwd).ok_or_else(|| {
Error::invalid(format!(
"no compose file in {} (isb reads only ./{}, never a parent directory's; -f FILE names another)",
cwd.display(),
DEFAULT_FILES.join(" or ./")
))
})?);
files.extend(find_override(&cwd));
}
let base = files[0]
.parent()
.map(|p| {
if p.as_os_str().is_empty() {
PathBuf::from(".")
} else {
p.to_path_buf()
}
})
.unwrap_or_else(|| PathBuf::from("."));
let base = base.canonicalize().unwrap_or(base);
let mut env_files = opts.env_files.clone();
if env_files.is_empty() {
env_files.extend(Some(base.join(".env")).filter(|p| p.is_file()));
}
let mut dotenv: BTreeMap<String, String> = BTreeMap::new();
for f in &env_files {
let text = std::fs::read_to_string(f).map_err(|e| Error::Parse {
path: f.display().to_string(),
message: e.to_string(),
})?;
for (k, v) in interp::parse_env_file(&text).map_err(|e| Error::Parse {
path: f.display().to_string(),
message: e.to_string(),
})? {
dotenv.insert(k, v);
}
}
let vars = opts.vars.clone();
let dotenv_kept = dotenv.clone();
let lookup = move |k: &str| {
vars.get(k)
.cloned()
.or_else(|| std::env::var(k).ok())
.or_else(|| dotenv.get(k).cloned())
};
let mut docs = Vec::new();
for f in &files {
let text = std::fs::read_to_string(f).map_err(|e| Error::Parse {
path: f.display().to_string(),
message: e.to_string(),
})?;
docs.push((f.clone(), text));
}
let mut p = load_docs(&docs, &base, opts.project_name.as_deref(), &lookup)?;
p.vars = opts.vars.clone();
p.dotenv = dotenv_kept;
Ok(p)
}
pub fn load_docs(
docs: &[(PathBuf, String)],
base: &Path,
project_name: Option<&str>,
lookup: &dyn Fn(&str) -> Option<String>,
) -> Result<Project> {
let mut merged = Value::Mapping(Default::default());
for (path, text) in docs {
let perr = |message: String| Error::Parse {
path: path.display().to_string(),
message,
};
let mut v: Value = serde_yaml_ng::from_str(text).map_err(|e| perr(e.to_string()))?;
if v.is_null() {
continue;
}
v.apply_merge().map_err(|e| perr(e.to_string()))?;
strip_extensions(&mut v);
reject_docker_only_keys(&v).map_err(perr)?;
interp::interpolate_yaml(&mut v, lookup).map_err(|e| perr(e.to_string()))?;
normalize_lists(&mut v, lookup);
serde_yaml_ng::from_value::<ComposeFile>(v.clone()).map_err(|e| perr(e.to_string()))?;
merge_file(&mut merged, v);
}
let files: Vec<PathBuf> = docs.iter().map(|(p, _)| p.clone()).collect();
let mut file: ComposeFile = serde_yaml_ng::from_value(merged).map_err(|e| Error::Parse {
path: files
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(" + "),
message: e.to_string(),
})?;
let name = project_name
.map(String::from)
.or_else(|| file.name.clone())
.unwrap_or_else(|| {
base.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "isb".into())
});
let name = sanitize_name(&name);
for (key, vol) in file.volumes.iter_mut() {
if vol.name.as_deref().is_none_or(str::is_empty) {
vol.name = Some(if vol.external {
key.clone()
} else {
format!("{name}_{key}")
});
}
}
for (service, spec) in file.services.iter_mut() {
if spec.name.as_deref().is_none_or(str::is_empty) {
spec.name = Some(format!("{name}-{}", sanitize_name(service)));
}
for v in &spec.volumes {
if v.mount_type == MountType::Volume && !file.volumes.contains_key(&v.source) {
return Err(Error::Parse {
path: files
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(" + "),
message: format!(
"service {service:?} mounts volume {:?}, which is not declared under top-level volumes",
v.source
),
});
}
}
}
validate_services(&mut file).map_err(|message| Error::Parse {
path: files
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(" + "),
message,
})?;
file.name = Some(name.clone());
Ok(Project {
name,
file,
base_dir: base.to_path_buf(),
files,
vars: BTreeMap::new(),
dotenv: BTreeMap::new(),
store_secrets: SecretValues::default(),
})
}
fn validate_services(file: &mut crate::spec::ComposeFile) -> std::result::Result<(), String> {
for (key, def) in &file.secrets {
def.validate().map_err(|e| format!("secret {key:?}: {e}"))?;
if def.external && def.name.is_none() {
crate::secrets::validate_name(key).map_err(|e| format!("external secret: {e}"))?;
}
}
let names: Vec<String> = file.services.keys().cloned().collect();
for (service, spec) in file.services.iter_mut() {
for dep in spec.depends_on.keys() {
if !names.contains(dep) {
return Err(format!(
"service {service:?} depends on {dep:?}, which is not a service here"
));
}
if dep == service {
return Err(format!("service {service:?} depends on itself"));
}
}
for s in &spec.secrets {
if !file.secrets.contains_key(&s.source) {
return Err(format!(
"service {service:?} uses secret {:?}, which is not declared under top-level secrets",
s.source
));
}
s.file_mode()
.map_err(|e| format!("service {service:?}: {e}"))?;
}
for (var, key) in spec.env.secrets.iter().chain(&spec.env.files) {
if !file.secrets.contains_key(key) {
return Err(format!(
"service {service:?}: environment {var} uses secret {key:?}, which is not declared under top-level secrets"
));
}
}
for (var, _) in spec.env.file_vars() {
if spec.env.vars.contains_key(&var) || spec.env.secrets.contains_key(&var) {
return Err(format!(
"service {service:?}: environment {var} is set, and also by {} `as: file`; drop one",
var.trim_end_matches("_FILE")
));
}
}
let oci = crate::plan::ImageSource::parse(&spec.image).is_ok_and(|i| i.is_oci());
crate::plan::check_oci_command(service, spec)?;
if !spec.env.secrets.is_empty() && !oci && spec.command.is_none() {
return Err(format!(
"service {service:?}: environment secrets on a system image need a command to give them to"
));
}
if let Some(h) = &spec.healthcheck {
h.probe().map_err(|e| format!("service {service:?}: {e}"))?;
}
if let Some(d) = &spec.deploy {
if d.mode.as_deref().is_some_and(|m| m != "replicated") {
return Err(format!(
"service {service:?}: deploy.mode {:?} is not supported (only replicated)",
d.mode.as_deref().unwrap_or_default()
));
}
if let Some(l) = d.resources.as_ref().and_then(|r| r.limits.clone()) {
if let Some(c) = l.cpus {
if spec.cpus.is_some() || spec.cpuset.is_some() {
return Err(format!(
"service {service:?}: set cpus or deploy.resources.limits.cpus, not both"
));
}
spec.cpus = Some(c.trim().trim_end_matches(".0").to_string());
}
if let Some(m) = l.memory {
if spec.memory.is_some() {
return Err(format!(
"service {service:?}: set mem_limit or deploy.resources.limits.memory, not both"
));
}
spec.memory = Some(m);
}
}
}
}
dependency_order(file).map(|_| ())
}
pub fn dependency_order(
file: &crate::spec::ComposeFile,
) -> std::result::Result<Vec<String>, String> {
let mut order: Vec<String> = Vec::new();
let mut remaining: Vec<&String> = file.services.keys().collect();
while !remaining.is_empty() {
let ready: Vec<&String> = remaining
.iter()
.copied()
.filter(|s| {
file.services[*s]
.depends_on
.keys()
.all(|d| order.contains(d) || !file.services.contains_key(d))
})
.collect();
if ready.is_empty() {
return Err(format!(
"depends_on has a cycle among: {}",
remaining
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
));
}
for s in ready {
order.push(s.clone());
remaining.retain(|r| *r != s);
}
}
Ok(order)
}
pub fn client_for(client: &crate::Client, project: &Project) -> crate::Client {
match (&project.file.incus_project, client.project_name()) {
(Some(p), "default") => client.clone().project(p),
_ => client.clone(),
}
}
pub fn up(
client: &crate::Client,
project: &Project,
services: &[String],
opts: crate::EnsureOptions,
report: &mut dyn FnMut(&str),
) -> Result<Vec<(String, crate::ApplyReport)>> {
Ok(up_handles(client, project, services, opts, report)?
.into_iter()
.map(|(s, r, _)| (s, r))
.collect())
}
pub fn up_handles(
client: &crate::Client,
project: &Project,
services: &[String],
opts: crate::EnsureOptions,
report: &mut dyn FnMut(&str),
) -> Result<Vec<(String, crate::ApplyReport, crate::Sandbox)>> {
let c = client_for(client, project);
let selected = project.select(services)?;
let healthy_needed: std::collections::BTreeSet<&String> = selected
.iter()
.flat_map(|s| project.file.services[s].depends_on.iter())
.filter(|(_, d)| d.condition == crate::spec::DependCondition::ServiceHealthy)
.map(|(k, _)| k)
.collect();
for dep in &healthy_needed {
let spec = &project.file.services[*dep];
if spec.health_probe().map_err(Error::invalid)?.is_none() {
return Err(Error::invalid(format!(
"a service depends on {dep:?} being healthy, but {dep:?} has no healthcheck"
)));
}
let oci = crate::plan::ImageSource::parse(&spec.image)?.is_oci();
if spec.command.is_some() && !spec.long_running() && !oci {
return Err(Error::invalid(format!(
"a service depends on {dep:?} being healthy, but its command only runs once every service is up; set restart on {dep:?} so isb supervises it"
)));
}
}
let secret_values = if selected
.iter()
.any(|s| !project.file.services[s].secret_keys().is_empty())
{
project.secret_values()?
} else {
BTreeMap::new()
};
for s in &selected {
if project.file.services[s].replicas() > 1 {
return Err(Error::invalid(format!(
"service {s:?} asks for {} replicas; isb up runs one, `isb stack deploy` runs replicas behind a load balancer",
project.file.services[s].replicas()
)));
}
}
if let Some(w) = crate::stack::secrets::env_exposure_warning(&project.file) {
report(&format!("warning: {w}"));
}
let mut out = Vec::new();
for s in selected {
let spec = project.service(&s)?;
let oci = crate::plan::ImageSource::parse(&spec.image)?.is_oci();
let secret_env = crate::supervise::secret_env(spec, &secret_values)?;
let mut with_env = spec.clone();
if oci {
with_env.env.vars.extend(secret_env.clone());
} else {
with_env.exec.env.extend(secret_env.clone());
}
let mut d =
crate::sandbox::resolve(&c, &with_env, &project.file.volumes, &project.base_dir)?;
if oci && spec.has_secret_files() {
let (spec, values) = (spec.clone(), secret_values.clone());
d.before_start = Some(crate::plan::BeforeStart(std::sync::Arc::new(
move |c, n| crate::supervise::push_secret_files(c, n, &spec, &values).map(|_| ()),
)));
}
let r = crate::sandbox::ensure(&c, &d, opts, report)?;
if r.applied.iter().all(|a| !a.is_change()) {
report(&format!("{}: up to date", d.name));
}
let sb = crate::Sandbox::from_desired(&c, &d);
if crate::supervise::push_secrets(&sb, spec, &secret_values)? && d.image.is_oci() {
sb.restart()?;
}
if spec.long_running()
&& spec.command.is_some()
&& !d.image.is_oci()
&& crate::supervise::install(&sb, &s, spec, spec.has_secret_files(), &secret_env)?
{
report(&format!(
"{}: supervising command as {}",
d.name,
crate::supervise::unit_name(&s)
));
}
if healthy_needed.contains(&s) {
wait_healthy(&sb, &s, spec, report)?;
}
out.push((s, r, sb));
}
Ok(out)
}
pub fn wait_healthy(
sb: &crate::Sandbox,
service: &str,
spec: &crate::SandboxSpec,
report: &mut dyn FnMut(&str),
) -> Result<()> {
let Some(check) = spec.health_probe().map_err(Error::invalid)? else {
return Ok(());
};
let deadline = (check.start_period + check.interval * check.retries)
.max(std::time::Duration::from_secs(60));
let started = std::time::Instant::now();
report(&format!(
"{}: waiting for {service} to be healthy",
sb.name()
));
loop {
let p = crate::supervise::probe(sb, &check);
if p.ok {
report(&format!("{}: healthy", sb.name()));
return Ok(());
}
if started.elapsed() >= deadline {
return Err(Error::NotReady {
sandbox: sb.name().to_string(),
check: "healthcheck".into(),
detail: p.output,
waited: started.elapsed(),
});
}
std::thread::sleep(check.start_interval.min(check.interval));
}
}
pub fn plan(
client: &crate::Client,
project: &Project,
services: &[String],
diff: crate::DiffOptions,
) -> Result<Vec<crate::SandboxPlan>> {
let c = client_for(client, project);
let mut plans = Vec::new();
for s in project.select(services)? {
let d = crate::sandbox::resolve(
&c,
project.service(&s)?,
&project.file.volumes,
&project.base_dir,
)?;
plans.push(crate::sandbox::plan_desired(&c, &d, diff)?);
}
Ok(plans)
}
pub fn down(
client: &crate::Client,
project: &Project,
services: &[String],
volumes: bool,
report: &mut dyn FnMut(&str),
) -> Result<()> {
let c = client_for(client, project);
for s in project.select_exact(services)?.into_iter().rev() {
let name = project.service(&s)?.name.clone().unwrap_or_default();
match crate::Sandbox::remove(&c, &name, true) {
Ok(()) => report(&format!("{name}: deleted")),
Err(e) if e.is_not_found() => report(&format!("{name}: not present")),
Err(e) => return Err(e),
}
}
if !volumes {
return Ok(());
}
if !services.is_empty() {
report("volumes kept: they are shared by the file; remove them with a full down");
return Ok(());
}
let facts = crate::sandbox::host_facts(&c)?;
let mut seen = std::collections::BTreeSet::new();
let vol_name = |key: &str| {
project
.file
.volumes
.get(key)
.and_then(|d| d.name.clone())
.unwrap_or_else(|| key.to_string())
};
for spec in project.file.services.values() {
let pool = facts.pick_pool(spec.storage.as_deref())?;
for v in &spec.volumes {
if v.mount_type != MountType::Volume {
continue;
}
let def = project.file.volumes.get(&v.source);
if v.external || def.is_some_and(|d| d.external) {
continue;
}
let vpool = match v.pool.as_deref().or(def.and_then(|d| d.pool.as_deref())) {
Some(x) if x != "auto" => x.to_string(),
_ => pool.clone(),
};
seen.insert((vpool, vol_name(&v.source)));
}
}
for (key, def) in &project.file.volumes {
let vname = vol_name(key);
if !def.external && !seen.iter().any(|(_, n)| *n == vname) {
seen.insert((facts.pick_pool(def.pool.as_deref())?, vname));
}
}
for (pool, vname) in seen {
match crate::volume::remove(&c, &pool, &vname) {
Ok(()) => report(&format!("volume {vname}: deleted")),
Err(e) if e.is_not_found() => {}
Err(e) => report(&format!("volume {vname}: kept ({e})")),
}
}
Ok(())
}
pub fn sanitize_name(s: &str) -> String {
let mut out = String::new();
for c in s.to_ascii_lowercase().chars() {
if c.is_ascii_alphanumeric() {
out.push(c);
} else if !out.ends_with('-') {
out.push('-');
}
}
let out = out.trim_matches('-').to_string();
if out.starts_with(|c: char| c.is_ascii_alphabetic()) {
out
} else {
format!("isb-{out}").trim_end_matches('-').to_string()
}
}
fn strip_extensions(v: &mut Value) {
let Value::Mapping(top) = v else { return };
top.retain(|k, _| {
!k.as_str()
.is_some_and(|s| s.starts_with("x-") || s == "version")
});
if let Some(Value::Mapping(sbs)) = top.get_mut("services") {
for (_, sb) in sbs.iter_mut() {
if let Value::Mapping(m) = sb {
m.retain(|k, _| !k.as_str().is_some_and(|s| s.starts_with("x-")));
}
}
}
}
const DOCKER_ONLY_TOP: &[(&str, &str)] = &[
(
"networks",
"networking comes from incus profiles (incus_profiles)",
),
("configs", "bind-mount the file instead"),
("include", "pass several files with -f"),
("sandboxes", "services are under services:"),
("project", "the incus project is incus_project:"),
];
const DOCKER_ONLY_SERVICE: &[(&str, &str)] = &[
(
"build",
"isb runs incus images: build one and name it in image",
),
("env_file", "list the variables under environment"),
(
"profiles",
"docker's service profiles are not supported; incus profiles are incus_profiles",
),
(
"networks",
"networking comes from incus profiles (incus_profiles) or raw_devices",
),
(
"network_mode",
"networking comes from incus profiles (incus_profiles) or raw_devices",
),
("hostname", "the guest's hostname is its container_name"),
("expose", "use ports"),
("devices", "use raw_devices"),
("gpus", "use raw_devices, e.g. {gpu: {type: gpu}}"),
("sysctls", "use raw_config with linux.sysctl.* keys"),
("working_directory", "the key is working_dir"),
("env", "the key is environment"),
("memory", "the key is mem_limit"),
("name", "the instance name is container_name"),
];
fn reject_docker_only_keys(v: &Value) -> std::result::Result<(), String> {
let Value::Mapping(top) = v else {
return Ok(());
};
let unsupported = |key: &str, table: &[(&str, &str)], at: &str| {
table
.iter()
.find(|(k, _)| *k == key)
.map(|(k, hint)| format!("{at}`{k}` is not an isb key: {hint}"))
};
for k in top.keys().filter_map(Value::as_str) {
if let Some(e) = unsupported(k, DOCKER_ONLY_TOP, "") {
return Err(e);
}
}
if let Some(Value::Mapping(services)) = top.get("services") {
for (name, svc) in services {
let Value::Mapping(svc) = svc else { continue };
let at = format!("service {:?}: ", name.as_str().unwrap_or_default());
for k in svc.keys().filter_map(Value::as_str) {
if let Some(e) = unsupported(k, DOCKER_ONLY_SERVICE, &at) {
return Err(e);
}
}
if let Some(Value::Mapping(exec)) = svc.get("exec") {
for (k, to) in [("user", "user"), ("cwd", "working_dir")] {
if exec.contains_key(k) {
return Err(format!(
"{at}`exec.{k}` is not an isb key: use {to} on the service"
));
}
}
}
}
}
Ok(())
}
fn normalize_lists(v: &mut Value, lookup: &dyn Fn(&str) -> Option<String>) {
fn to_map(v: &mut Value, bare: &dyn Fn(&str) -> Option<String>) {
let Value::Sequence(items) = v else { return };
let mut m = serde_yaml_ng::Mapping::new();
for item in items.iter() {
let Some(s) = item.as_str() else {
return;
};
match s.split_once('=') {
Some((k, val)) => {
m.insert(k.into(), val.into());
}
None => {
if let Some(val) = bare(s) {
m.insert(s.into(), val.into());
}
}
}
}
*v = Value::Mapping(m);
}
let Some(Value::Mapping(services)) = v.get_mut("services") else {
return;
};
for (_, svc) in services.iter_mut() {
let Value::Mapping(svc) = svc else { continue };
if let Some(e) = svc.get_mut("environment") {
to_map(e, lookup);
}
if let Some(Value::Mapping(exec)) = svc.get_mut("exec") {
if let Some(e) = exec.get_mut("env") {
to_map(e, lookup);
}
}
if let Some(l) = svc.get_mut("labels") {
to_map(l, &|_| Some(String::new()));
}
}
}
fn merge_file(merged: &mut Value, v: Value) {
let (Value::Mapping(am), Value::Mapping(bm)) = (&mut *merged, v) else {
return;
};
for (k, bv) in bm {
let is_services = k.as_str() == Some("services");
match am.get_mut(&k) {
Some(Value::Mapping(asvcs)) if is_services => {
let Value::Mapping(bsvcs) = bv else {
am.insert(k, bv);
continue;
};
for (name, bsvc) in bsvcs {
match asvcs.get_mut(&name) {
Some(asvc) => merge_service(asvc, bsvc),
None => {
asvcs.insert(name, bsvc);
}
}
}
}
Some(av) => deep_merge(av, bv),
None => {
am.insert(k, bv);
}
}
}
}
fn merge_service(a: &mut Value, b: Value) {
let (Value::Mapping(am), Value::Mapping(bm)) = (&mut *a, &b) else {
deep_merge(a, b);
return;
};
let bm = bm.clone();
for (k, bv) in bm {
match (k.as_str(), am.get_mut(&k), bv) {
(Some("ports"), Some(Value::Sequence(ap)), Value::Sequence(bp)) => {
for p in bp {
if !ap.contains(&p) {
ap.push(p);
}
}
}
(Some("volumes"), Some(Value::Sequence(av)), Value::Sequence(bv)) => {
for m in bv {
let t = mount_target(&m);
match av.iter_mut().find(|x| t.is_some() && mount_target(x) == t) {
Some(slot) => *slot = m,
None => av.push(m),
}
}
}
(_, Some(av), bv) => deep_merge(av, bv),
(_, None, bv) => {
am.insert(k, bv);
}
}
}
}
fn mount_target(m: &Value) -> Option<String> {
let t = match m {
Value::String(s) => s.split(':').nth(1)?.to_string(),
Value::Mapping(map) => map.get("target")?.as_str()?.to_string(),
_ => return None,
};
Some(t.trim_end_matches('/').to_string())
}
fn deep_merge(a: &mut Value, b: Value) {
match (a, b) {
(Value::Mapping(am), Value::Mapping(bm)) => {
for (k, bv) in bm {
match am.get_mut(&k) {
Some(av) => deep_merge(av, bv),
None => {
am.insert(k, bv);
}
}
}
}
(a, b) => *a = b,
}
}
#[cfg(test)]
mod tests;