use std::fmt;
use std::path::PathBuf;
use serde::Serialize;
use workload_spec::{LifecycleArchetype, SecretRef, VolumeSource, WorkloadSpec};
use crate::config::{CloudConfig, MachineConfig};
pub const KAMAJI_VOLUME_ROOT: &str = "/var/lib/yah/kamaji/volumes";
pub fn named_volume_path(name: &str) -> String {
format!("{KAMAJI_VOLUME_ROOT}/{name}")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MigrationRefusal {
UnknownWorkload { name: String, declared: Vec<String> },
UnknownGroup {
group: String,
declared: Vec<String>,
},
NotDeployed { workload: String },
AlreadyInGroup {
workload: String,
group: String,
machines: Vec<String>,
},
MultipleSources {
workload: String,
archetype: LifecycleArchetype,
machines: Vec<String>,
},
FungibleWithDurableVolumes {
workload: String,
archetype: LifecycleArchetype,
volumes: Vec<String>,
},
NoAdmissibleTarget {
workload: String,
group: String,
reason: String,
},
}
impl fmt::Display for MigrationRefusal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnknownWorkload { name, declared } => write!(
f,
"no workload '{name}' in .yah/infra/workloads/ — declared: {}",
list_or_none(declared)
),
Self::UnknownGroup { group, declared } => write!(
f,
"no machine declares sovereign_group = \"{group}\" — declared groups: {}. \
A group is the set of machines naming it, so migrating to one that \
nothing declares would place the workload nowhere; stamp a machine in \
.yah/infra/machines/<name>.toml first.",
list_or_none(declared)
),
Self::NotDeployed { workload } => write!(
f,
"workload '{workload}' is not running on any declared machine — nothing to \
migrate. To place it for the first time use \
`yah cloud workload deploy {workload} <machine>`; pass `--from <machine>` \
if it is running somewhere this camp cannot reach."
),
Self::AlreadyInGroup {
workload,
group,
machines,
} => write!(
f,
"workload '{workload}' is already in sovereign group '{group}' (on {}) — \
nothing to do",
machines.join(", ")
),
Self::MultipleSources {
workload,
archetype,
machines,
} => {
write!(
f,
"workload '{workload}' is live on {} machines outside the target group \
({}), and this plans one move at a time — re-run with \
`--from <machine>`.",
machines.len(),
machines.join(", ")
)?;
if matches!(archetype, LifecycleArchetype::Appliance) {
write!(
f,
" NOTE: '{workload}' is an appliance, whose defining property is at \
most one live instance. Two is a violation that predates this \
migration — resolve it before moving, or the move carries it \
across the cut."
)?;
}
Ok(())
}
Self::FungibleWithDurableVolumes {
workload,
archetype,
volumes,
} => write!(
f,
"workload '{workload}' declares archetype = \"{}\" but mounts durable \
volume(s) [{}]. A fungible workload is dropped and rescheduled, so the \
target would come up with empty storage and the data would be silently \
left on the source. Declare `archetype = \"appliance\"` if the state \
matters, or drop the volume if it does not.",
archetype.taint_key(),
volumes.join(", ")
),
Self::NoAdmissibleTarget {
workload,
group,
reason,
} => write!(
f,
"no machine in sovereign group '{group}' admits workload '{workload}': \
{reason}"
),
}
}
}
impl std::error::Error for MigrationRefusal {}
fn list_or_none<S: AsRef<str>>(items: &[S]) -> String {
if items.is_empty() {
"(none)".to_string()
} else {
items
.iter()
.map(|s| s.as_ref())
.collect::<Vec<_>>()
.join(", ")
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum VolumeDisposition {
Copy {
name: String,
host_path: String,
mounted_at: PathBuf,
},
Precondition {
host_path: PathBuf,
mounted_at: PathBuf,
},
Discard { mounted_at: PathBuf, size_mb: u32 },
}
impl VolumeDisposition {
pub fn is_durable(&self) -> bool {
!matches!(self, Self::Discard { .. })
}
pub fn label(&self) -> String {
match self {
Self::Copy { name, .. } => name.clone(),
Self::Precondition { host_path, .. } => host_path.display().to_string(),
Self::Discard { mounted_at, .. } => format!("tmpfs:{}", mounted_at.display()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Precondition {
pub what: String,
pub because: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct MigrationStep {
pub what: String,
pub command: Option<String>,
}
impl MigrationStep {
fn narrate(what: impl Into<String>) -> Self {
Self {
what: what.into(),
command: None,
}
}
fn run(what: impl Into<String>, command: impl Into<String>) -> Self {
Self {
what: what.into(),
command: Some(command.into()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct MigrationPlan {
pub workload: String,
pub archetype: LifecycleArchetype,
pub stateful: bool,
pub source_machine: String,
pub source_group: Option<String>,
pub source_ssh: Option<String>,
pub source_yubaba: Option<String>,
pub target_machine: String,
pub target_group: String,
pub target_ssh: Option<String>,
pub target_yubaba: Option<String>,
pub volumes: Vec<VolumeDisposition>,
pub preconditions: Vec<Precondition>,
pub steps: Vec<MigrationStep>,
}
impl MigrationPlan {
pub fn volumes_to_copy(&self) -> impl Iterator<Item = &VolumeDisposition> {
self.volumes
.iter()
.filter(|v| matches!(v, VolumeDisposition::Copy { .. }))
}
pub fn incurs_downtime(&self) -> bool {
self.stateful
}
}
pub fn plan_migration(
cfg: &CloudConfig,
workload: &str,
to_group: &str,
observed: &[String],
) -> Result<MigrationPlan, MigrationRefusal> {
let wl = cfg
.workload(workload)
.ok_or_else(|| MigrationRefusal::UnknownWorkload {
name: workload.to_string(),
declared: cfg
.workloads
.iter()
.map(|w| w.spec.name.clone())
.collect(),
})?;
let spec = &wl.spec;
let members = cfg.machines_in_group(to_group);
if members.is_empty() {
return Err(MigrationRefusal::UnknownGroup {
group: to_group.to_string(),
declared: cfg
.declared_sovereign_groups()
.into_iter()
.map(String::from)
.collect(),
});
}
if observed.is_empty() {
return Err(MigrationRefusal::NotDeployed {
workload: workload.to_string(),
});
}
let in_group: Vec<String> = observed
.iter()
.filter(|name| {
cfg.machine(name)
.and_then(|m| m.sovereign_group.as_deref())
== Some(to_group)
})
.cloned()
.collect();
let sources: Vec<String> = observed
.iter()
.filter(|name| !in_group.contains(name))
.cloned()
.collect();
if sources.is_empty() {
return Err(MigrationRefusal::AlreadyInGroup {
workload: workload.to_string(),
group: to_group.to_string(),
machines: in_group,
});
}
let archetype = spec.effective_archetype();
if sources.len() > 1 {
return Err(MigrationRefusal::MultipleSources {
workload: workload.to_string(),
archetype,
machines: sources,
});
}
let volumes = dispositions(spec);
let stateful = matches!(archetype, LifecycleArchetype::Appliance);
if !stateful {
let durable: Vec<String> = volumes
.iter()
.filter(|v| v.is_durable())
.map(|v| v.label())
.collect();
if !durable.is_empty() {
return Err(MigrationRefusal::FungibleWithDurableVolumes {
workload: workload.to_string(),
archetype,
volumes: durable,
});
}
}
let target =
cfg.admit_workload_in_group(spec, to_group)
.map_err(|e| MigrationRefusal::NoAdmissibleTarget {
workload: workload.to_string(),
group: to_group.to_string(),
reason: e.to_string(),
})?;
let source_name = sources[0].clone();
let source = cfg.machine(&source_name);
let preconditions = preconditions(spec, &volumes, &source_name, &target.name, to_group);
let steps = steps(
workload,
stateful,
&volumes,
source,
&source_name,
target,
);
Ok(MigrationPlan {
workload: workload.to_string(),
archetype,
stateful,
source_group: source.and_then(|m| m.sovereign_group.clone()),
source_ssh: source.and_then(|m| m.connect.as_ref().map(|c| c.ssh.clone())),
source_yubaba: source.and_then(|m| m.yubaba_url()),
source_machine: source_name,
target_machine: target.name.clone(),
target_group: to_group.to_string(),
target_ssh: target.connect.as_ref().map(|c| c.ssh.clone()),
target_yubaba: target.yubaba_url(),
volumes,
preconditions,
steps,
})
}
fn dispositions(spec: &WorkloadSpec) -> Vec<VolumeDisposition> {
spec.volumes
.iter()
.map(|v| match &v.source {
VolumeSource::Named { name } => VolumeDisposition::Copy {
name: name.clone(),
host_path: named_volume_path(name),
mounted_at: v.target.clone(),
},
VolumeSource::Bind { host_path } => VolumeDisposition::Precondition {
host_path: host_path.clone(),
mounted_at: v.target.clone(),
},
VolumeSource::Tmpfs { size_mb } => VolumeDisposition::Discard {
mounted_at: v.target.clone(),
size_mb: *size_mb,
},
})
.collect()
}
fn preconditions(
spec: &WorkloadSpec,
volumes: &[VolumeDisposition],
source: &str,
target: &str,
to_group: &str,
) -> Vec<Precondition> {
let mut out = Vec::new();
let cluster_secrets: Vec<String> = spec
.secrets
.iter()
.filter_map(|s| match &s.source {
SecretRef::Cluster { name } => Some(name.clone()),
_ => None,
})
.collect();
if !cluster_secrets.is_empty() {
out.push(Precondition {
what: format!(
"re-put cluster secret(s) [{}] into sovereign group '{to_group}': \
`yah cloud secret put <name> --machine <raft leader of {to_group}>`",
cluster_secrets.join(", ")
),
because: "a cluster secret is decrypted from the LOCAL raft replica, and a \
sovereign group is a separate raft. The camp KEK is shared, so the \
record would decrypt — but it is not replicated across groups, so \
it is absent. The workload deploys and then fails to start on a \
missing secret."
.to_string(),
});
}
for v in volumes {
if let VolumeDisposition::Precondition {
host_path,
mounted_at,
} = v
{
out.push(Precondition {
what: format!(
"ensure {} exists on {target} with the contents {mounted_at:?} expects",
host_path.display()
),
because: "a bind mount is an operator-managed host path. The camp did not \
create it and has no way to know whether it is reproducible on \
another box, so it is not copied automatically — an empty \
directory would mount cleanly and lose the data silently."
.to_string(),
});
}
}
out.push(Precondition {
what: format!(
"pre-pull the image on {target} under the exact `repo:tag@digest` ref \
(not the bare tag)"
),
because: "automatic pulling at admission is unbuilt, and a pull of the plain tag \
leaves containerd's image store keyed on a name kamaji never asks for — \
the deploy still fails 'image not found in containerd … pre-pull \
required'."
.to_string(),
});
out.push(Precondition {
what: format!(
"confirm the image architecture matches {target} (the source is {source})"
),
because: "sovereign groups in this fleet differ by hardware — prod is x86 and the \
dev group is aarch64 Pis — so an image that runs on the source may have \
no matching platform on the target."
.to_string(),
});
out
}
fn steps(
workload: &str,
stateful: bool,
volumes: &[VolumeDisposition],
source: Option<&MachineConfig>,
source_name: &str,
target: &MachineConfig,
) -> Vec<MigrationStep> {
let mut out = Vec::new();
let source_yubaba = source
.and_then(|m| m.yubaba_url())
.unwrap_or_else(|| format!("<{source_name} yubaba url>"));
let source_ssh = source
.and_then(|m| m.connect.as_ref().map(|c| c.ssh.clone()))
.unwrap_or_else(|| format!("<{source_name} ssh target>"));
let target_ssh = target
.connect
.as_ref()
.map(|c| c.ssh.clone())
.unwrap_or_else(|| format!("<{} ssh target>", target.name));
let find_ident = MigrationStep::run(
format!("find the server-assigned ident for '{workload}' on {source_name}"),
format!("curl -s {source_yubaba}/workloads"),
);
let destroy = MigrationStep::run(
format!("stop '{workload}' on {source_name}"),
format!("curl -X POST {source_yubaba}/workloads/<ident>/destroy"),
);
let deploy = MigrationStep::run(
format!("deploy '{workload}' onto {}", target.name),
format!("yah cloud workload deploy {workload} {}", target.name),
);
let health = MigrationStep::narrate(format!(
"confirm '{workload}' is healthy on {} before doing anything else",
target.name
));
if stateful {
out.push(MigrationStep::narrate(format!(
"NOTE: '{workload}' is stateful — this move takes it DOWN. The source must \
stop before the target starts, because an appliance is defined by having at \
most one live instance."
)));
out.push(find_ident);
out.push(destroy);
out.push(MigrationStep::narrate(
"confirm the container is gone on the source before copying — copying a \
volume out from under a running writer is how a half-written state file \
reaches the target",
));
for v in volumes {
match v {
VolumeDisposition::Copy {
name, host_path, ..
} => {
out.push(MigrationStep::run(
format!("copy volume '{name}' to {}", target.name),
format!(
"rsync -aHAX --numeric-ids --delete \
{source_ssh}:{host_path}/ {target_ssh}:{host_path}/"
),
));
}
VolumeDisposition::Precondition { host_path, .. } => {
out.push(MigrationStep::narrate(format!(
"bind mount {} is operator-managed and is NOT copied — see \
preconditions",
host_path.display()
)));
}
VolumeDisposition::Discard {
mounted_at,
size_mb,
} => {
out.push(MigrationStep::narrate(format!(
"tmpfs at {} ({size_mb} MiB) is discarded by design — do not copy it",
mounted_at.display()
)));
}
}
}
out.push(deploy);
out.push(health);
out.push(MigrationStep::narrate(format!(
"LAST, and only after the target is verified healthy: remove the stale volume \
data on {source_name}. Deliberately manual and deliberately last — it is the \
one step with no undo, and keeping it is the whole rollback."
)));
} else {
out.push(MigrationStep::narrate(format!(
"NOTE: '{workload}' is fungible — no state moves and there is no downtime. \
The target comes up first so it can be proven before the source is dropped."
)));
out.push(deploy);
out.push(health);
out.push(find_ident);
out.push(destroy);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
use tempfile::{tempdir, TempDir};
struct Camp {
dir: TempDir,
}
impl Camp {
fn new() -> Self {
Self {
dir: tempdir().unwrap(),
}
}
fn root(&self) -> &Path {
self.dir.path()
}
fn machine(self, name: &str, group: Option<&str>) -> Self {
self.machine_with(name, group, "")
}
fn machine_with(self, name: &str, group: Option<&str>, extra: &str) -> Self {
let dir = self.root().join(".yah/infra/machines");
std::fs::create_dir_all(&dir).unwrap();
let group_line = group
.map(|g| format!("sovereign_group = \"{g}\"\n"))
.unwrap_or_default();
std::fs::write(
dir.join(format!("{name}.toml")),
format!(
"name = \"{name}\"\nprovider = \"static\"\nmesh_tags = []\n\
{group_line}{extra}\n\
[connect]\naddress = \"10.0.0.1\"\nssh = \"root@{name}\"\n\
yubaba = \"http://{name}:7443\"\n"
),
)
.unwrap();
self
}
fn workload(self, name: &str, extra: &str) -> Self {
let dir = self.root().join(".yah/infra/workloads");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(format!("{name}.toml")),
format!(
"schema_version = 1\nname = \"{name}\"\ntier = \"infra\"\n\
replicas = 1\nrestart_policy = \"always\"\n\
{extra}\n\
[image]\nregistry = \"cr.yah.dev\"\nrepository = \"{name}\"\n\
tag = \"v1\"\ndigest = \"sha256:abc\"\n\
[resources]\nmemory_mb = 128\ncpu_millis = 100\n\
ephemeral_storage_mb = 64\n\
[stop_policy]\nsignal = 15\ngrace_period = 10000\n\
[expose.mesh]\nidentity = \"{name}\"\nports = [8080]\nallow_from = []\n"
),
)
.unwrap();
self
}
fn load(&self) -> CloudConfig {
CloudConfig::load(self.root()).expect("fixture camp must load")
}
}
const NAMED_VOLUME: &str = "[[volumes]]\nsource = { named = { name = \"pgdata\" } }\n\
target = \"/var/lib/postgresql\"\nread_only = false\n";
#[test]
fn a_named_volume_renders_the_kamaji_host_path() {
assert_eq!(
named_volume_path("pgdata"),
"/var/lib/yah/kamaji/volumes/pgdata"
);
}
#[test]
fn an_unknown_group_names_the_declared_vocabulary() {
let camp = Camp::new()
.machine("a", Some("prod"))
.machine("b", Some("dev"))
.workload("svc", "archetype = \"server\"");
let cfg = camp.load();
let err = plan_migration(&cfg, "svc", "Dev", &["a".into()]).unwrap_err();
let MigrationRefusal::UnknownGroup { declared, .. } = &err else {
panic!("expected UnknownGroup, got {err:?}");
};
assert_eq!(declared, &["dev".to_string(), "prod".to_string()]);
let msg = err.to_string();
assert!(msg.contains("dev") && msg.contains("prod"), "{msg}");
}
#[test]
fn an_undeployed_workload_is_refused_naming_the_deploy_verb() {
let camp = Camp::new()
.machine("a", Some("dev"))
.workload("svc", "archetype = \"server\"");
let err = plan_migration(&camp.load(), "svc", "dev", &[]).unwrap_err();
assert!(matches!(err, MigrationRefusal::NotDeployed { .. }));
assert!(err.to_string().contains("yah cloud workload deploy svc"));
}
#[test]
fn a_workload_already_in_the_target_group_is_a_no_op() {
let camp = Camp::new()
.machine("a", Some("dev"))
.machine("b", Some("prod"))
.workload("svc", "archetype = \"server\"");
let err = plan_migration(&camp.load(), "svc", "dev", &["a".into()]).unwrap_err();
assert!(matches!(err, MigrationRefusal::AlreadyInGroup { .. }));
}
#[test]
fn two_live_appliance_instances_are_refused_as_a_pre_existing_violation() {
let camp = Camp::new()
.machine("a", Some("prod"))
.machine("b", Some("prod"))
.machine("c", Some("dev"))
.workload("db", &format!("archetype = \"appliance\"\n{NAMED_VOLUME}"));
let err =
plan_migration(&camp.load(), "db", "dev", &["a".into(), "b".into()]).unwrap_err();
assert!(matches!(err, MigrationRefusal::MultipleSources { .. }));
let msg = err.to_string();
assert!(msg.contains("at most one live instance"), "{msg}");
assert!(msg.contains("--from"), "{msg}");
}
#[test]
fn a_declared_server_with_a_named_volume_is_refused_not_silently_rescheduled() {
let camp = Camp::new()
.machine("a", Some("prod"))
.machine("b", Some("dev"))
.workload("cache", &format!("archetype = \"server\"\n{NAMED_VOLUME}"));
let err = plan_migration(&camp.load(), "cache", "dev", &["a".into()]).unwrap_err();
let MigrationRefusal::FungibleWithDurableVolumes { volumes, .. } = &err else {
panic!("expected FungibleWithDurableVolumes, got {err:?}");
};
assert_eq!(volumes, &["pgdata".to_string()]);
assert!(err.to_string().contains("silently left on the source"));
}
#[test]
fn a_tmpfs_on_a_fungible_workload_is_not_durable_and_does_not_refuse() {
let camp = Camp::new().machine("a", Some("prod")).machine("b", Some("dev")).workload(
"svc",
"archetype = \"server\"\n[[volumes]]\n\
source = { tmpfs = { size_mb = 64 } }\ntarget = \"/scratch\"\nread_only = false\n",
);
let plan =
plan_migration(&camp.load(), "svc", "dev", &["a".into()]).expect("tmpfs is not state");
assert!(!plan.stateful);
assert_eq!(plan.volumes_to_copy().count(), 0);
assert_eq!(
plan.volumes,
vec![VolumeDisposition::Discard {
mounted_at: PathBuf::from("/scratch"),
size_mb: 64,
}]
);
assert!(!plan.volumes[0].is_durable());
assert!(plan
.steps
.iter()
.any(|s| s.what.contains("no state moves")));
}
#[test]
fn a_repelling_taint_in_the_target_group_refuses_through_the_admission_seam() {
let camp = Camp::new()
.machine("a", Some("prod"))
.machine_with("b", Some("dev"), "taints = [\"no-appliance\"]")
.workload("db", &format!("archetype = \"appliance\"\n{NAMED_VOLUME}"));
let err = plan_migration(&camp.load(), "db", "dev", &["a".into()]).unwrap_err();
let MigrationRefusal::NoAdmissibleTarget { reason, .. } = &err else {
panic!("expected NoAdmissibleTarget, got {err:?}");
};
assert!(reason.contains('b'), "must name the candidate: {reason}");
}
#[test]
fn the_stateful_half_stops_before_it_copies_and_copies_before_it_starts() {
let camp = Camp::new()
.machine("a", Some("prod"))
.machine("b", Some("dev"))
.workload("db", &format!("archetype = \"appliance\"\n{NAMED_VOLUME}"));
let plan = plan_migration(&camp.load(), "db", "dev", &["a".into()]).unwrap();
assert!(plan.stateful && plan.incurs_downtime());
assert_eq!(plan.volumes_to_copy().count(), 1);
assert_eq!(plan.target_machine, "b");
assert_eq!(plan.source_group.as_deref(), Some("prod"));
let idx = |needle: &str| {
plan.steps
.iter()
.position(|s| {
s.command.as_deref().unwrap_or("").contains(needle) || s.what.contains(needle)
})
.unwrap_or_else(|| panic!("no step matching {needle}: {:#?}", plan.steps))
};
let (stop, copy, start) = (idx("/destroy"), idx("rsync"), idx("workload deploy"));
assert!(
stop < copy && copy < start,
"stateful ordering must be stop({stop}) → copy({copy}) → start({start})"
);
let rsync = plan.steps[copy].command.as_deref().unwrap();
assert!(rsync.contains("root@a:/var/lib/yah/kamaji/volumes/pgdata/"), "{rsync}");
assert!(rsync.contains("root@b:/var/lib/yah/kamaji/volumes/pgdata/"), "{rsync}");
}
#[test]
fn the_fungible_half_starts_before_it_stops_and_never_copies() {
let camp = Camp::new()
.machine("a", Some("prod"))
.machine("b", Some("dev"))
.workload("svc", "archetype = \"server\"");
let plan = plan_migration(&camp.load(), "svc", "dev", &["a".into()]).unwrap();
assert!(!plan.stateful && !plan.incurs_downtime());
assert!(
!plan
.steps
.iter()
.any(|s| s.command.as_deref().unwrap_or("").contains("rsync")),
"the fungible half has nothing to copy"
);
let idx = |needle: &str| {
plan.steps
.iter()
.position(|s| s.command.as_deref().unwrap_or("").contains(needle))
.unwrap_or_else(|| panic!("no step matching {needle}: {:#?}", plan.steps))
};
assert!(
idx("workload deploy") < idx("/destroy"),
"fungible ordering must be start → stop, so the target is proven first"
);
}
#[test]
fn a_cluster_secret_raises_the_cross_group_raft_precondition() {
let camp = Camp::new().machine("a", Some("prod")).machine("b", Some("dev")).workload(
"svc",
"archetype = \"server\"\n[[secrets]]\n\
source = { cluster = { name = \"cheers/cloud-admin/verify-key\" } }\n\
target = { file = { path = \"/run/secrets/k\", mode = 256 } }\n",
);
let plan = plan_migration(&camp.load(), "svc", "dev", &["a".into()]).unwrap();
let p = plan
.preconditions
.iter()
.find(|p| p.what.contains("cluster secret"))
.expect("cluster secret precondition must be raised");
assert!(p.what.contains("cheers/cloud-admin/verify-key"), "{p:?}");
assert!(p.because.contains("separate raft"), "{p:?}");
}
#[test]
fn a_bind_mount_is_a_precondition_and_is_never_rsynced() {
let camp = Camp::new().machine("a", Some("prod")).machine("b", Some("dev")).workload(
"db",
"archetype = \"appliance\"\n[[volumes]]\n\
source = { bind = { host_path = \"/srv/data\" } }\n\
target = \"/data\"\nread_only = false\n",
);
let plan = plan_migration(&camp.load(), "db", "dev", &["a".into()]).unwrap();
assert_eq!(plan.volumes_to_copy().count(), 0);
assert!(plan
.preconditions
.iter()
.any(|p| p.what.contains("/srv/data")));
assert!(
!plan
.steps
.iter()
.any(|s| s.command.as_deref().unwrap_or("").contains("/srv/data")),
"an operator-managed host path must never be rsynced blindly"
);
}
}