use std::net::{Ipv4Addr, Ipv6Addr};
use indexmap::IndexMap;
use isoloom_spec::images;
use isoloom_spec::model::{Arch, Boot, Constraints, External, Machine, MachineFile, Network, Protocol, Spec, Target, Tc, Ulimit, VmImage, Winrm};
use isoloom_spec::validate::docker_cidrs;
use isoloom_spec::windows::{self, Tpm};
mod image;
pub use image::{Build, Image};
use crate::addressing::{address, address_for, host_address, start_order};
use crate::ansible;
use crate::checks::{self, Resolved};
use crate::configure::{DaemonTable, Recipe};
use crate::hardware::{self, Hardware};
use crate::images::Images;
use crate::ipv6::{self, Ipv6Plan, Route6};
use crate::lifecycle::{self, MachineLifecycle};
use crate::outside::{self, OutsideAddress, OutsideNetwork};
use crate::process::Process;
use crate::requirements::{self, Requirement};
use crate::routing;
use crate::secrets::SecretFile;
use crate::storage::{self, DataDisk, Lifetime};
#[derive(Debug, Clone, PartialEq)]
pub struct Environment {
pub name: String,
pub target: Target,
pub instance: Option<u8>,
pub networks: IndexMap<String, NetworkPlan>,
pub outside: IndexMap<String, OutsideNetwork>,
pub router: Option<IndexMap<String, Ipv4Addr>>,
pub router6: IndexMap<String, Ipv6Addr>,
pub machines: IndexMap<String, MachinePlan>,
pub start_order: Vec<String>,
pub checks: Vec<Resolved>,
pub controller: Option<Controller>,
pub constraints: Option<Constraints>,
pub requirements: Vec<Requirement>,
pub images: Images,
pub inventory_groups: IndexMap<String, Vec<String>>,
pub private_dns: Option<crate::private_dns::PrivateDns>,
spec: Spec,
}
#[derive(Debug, Clone, PartialEq)]
pub struct NetworkPlan {
pub cidr: String,
pub declared_cidr: String,
pub host: Ipv4Addr,
pub gateway: Option<String>,
pub internet: bool,
pub tc: Option<Tc>,
pub vlan: Option<(String, u16)>,
pub ipv6: Option<Ipv6Plan>,
pub mtu: Option<u16>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MachineShape {
Container,
Vm,
Supplied,
External,
}
#[derive(Debug, Clone, PartialEq)]
pub struct MachinePlan {
pub shape: MachineShape,
pub addresses: IndexMap<String, Ipv4Addr>,
pub more_addresses: IndexMap<String, Vec<Ipv4Addr>>,
pub addresses6: IndexMap<String, Ipv6Addr>,
pub names: Vec<String>,
pub routes: Vec<Route>,
pub routes6: Vec<Route6>,
pub default_gateway: Option<Ipv4Addr>,
pub is_gateway: bool,
pub offline: bool,
pub ports: Vec<PublishedPort>,
pub volumes: Vec<Volume>,
pub files: Vec<MachineFile>,
pub disks: Vec<DataDisk>,
pub settings: Settings,
pub inputs: Vec<String>,
pub secrets: Vec<SecretFile>,
pub size: Size,
pub arch: Arch,
pub rate_limit: Option<String>,
pub image: Option<Image>,
pub vm_image: Option<VmImage>,
pub boot: Option<Boot>,
pub depends_on: Vec<String>,
pub lifecycle: MachineLifecycle,
pub os: Option<String>,
pub winrm: Option<Winrm>,
pub winssh: bool,
pub tpm: Option<Tpm>,
pub external: Option<External>,
pub host_vars: IndexMap<String, serde_json::Value>,
pub outside: IndexMap<String, OutsideAddress>,
pub process: Process,
pub hardware: Hardware,
pub recipe: Option<Recipe>,
}
impl MachinePlan {
pub fn is_windows(&self) -> bool {
self.os.as_deref().is_some_and(images::is_windows)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Route {
pub cidr: String,
pub via: Ipv4Addr,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PublishedPort {
pub name: String,
pub port: u16,
pub publish: u16,
pub protocol: Protocol,
pub fixed: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Volume {
pub name: String,
pub path: String,
pub subpath: Option<String>,
pub key: String,
pub lifetime: Lifetime,
pub kind: VolumeKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VolumeKind {
Own,
Shared {
owner: String,
users: Vec<String>,
},
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Settings {
pub environment: IndexMap<String, String>,
pub sysctls: IndexMap<String, String>,
pub ulimits: IndexMap<String, Ulimit>,
}
impl Settings {
#[must_use]
pub fn is_empty(&self) -> bool {
self.environment.is_empty() && self.sysctls.is_empty() && self.ulimits.is_empty()
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Size {
pub cpus: Option<u32>,
pub memory_mb: Option<u32>,
pub disk_gb: Option<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct Controller {
pub cpus: u32,
pub memory_mb: u32,
pub disk_gb: Option<u32>,
}
impl Controller {
pub const CPUS: u32 = 1;
pub const MEMORY_MB: u32 = 512;
#[must_use]
pub fn of(spec: &Spec) -> Controller {
let r = spec.controller.as_ref().and_then(|c| c.resources.as_ref());
Controller {
cpus: r.and_then(|r| r.cpus).unwrap_or(Controller::CPUS),
memory_mb: r.and_then(|r| r.memory_mb).unwrap_or(Controller::MEMORY_MB),
disk_gb: r.and_then(|r| r.disk_gb),
}
}
#[must_use]
pub fn needed(spec: &Spec) -> bool {
!spec.provision.is_empty() || spec.checks.iter().any(isoloom_spec::Check::is_playbook)
}
}
impl Environment {
#[must_use]
pub fn build(spec: &Spec, target: Target, instance: Option<u8>) -> Environment {
let laid_out = outside::without(&lay_out(spec, target));
let machines = machines(spec, &laid_out, target);
let requirements = all_requirements(spec, &laid_out, &machines);
let networks = laid_out
.networks
.iter()
.map(|(name, n)| {
let declared = spec.networks.get(name).map_or(&n.cidr, |o| &o.cidr);
(name.clone(), network(&laid_out, name, n, declared))
})
.collect();
Environment {
name: laid_out.name.clone(),
target,
instance,
networks,
outside: outside::networks(spec),
router: routing::needed(&laid_out).then(|| routing::networks(&laid_out).map(|n| (n.clone(), routing::address(&laid_out, n))).collect()),
router6: ipv6::router(&laid_out),
machines,
start_order: start_order(&laid_out).into_iter().map(String::from).collect(),
checks: checks::plan(&laid_out),
controller: Controller::needed(&laid_out).then(|| Controller::of(&laid_out)),
constraints: laid_out.constraints.clone(),
requirements,
images: Images::of(&laid_out),
inventory_groups: ansible::groups(&laid_out),
private_dns: crate::private_dns::plan(&laid_out),
spec: laid_out,
}
}
#[must_use]
pub fn spec(&self) -> &Spec {
&self.spec
}
#[must_use]
pub fn address_for(&self, from: &str, to: &str) -> Ipv4Addr {
address_for(&self.spec, from, to)
}
#[must_use]
pub fn probe_address(&self, name: &str, network: &str) -> String {
let m = self.machines.get(name);
let address = if ipv6::only(&self.spec, network) {
m.and_then(|m| m.addresses6.get(network)).map(ToString::to_string)
} else {
m.and_then(|m| m.addresses.get(network)).map(ToString::to_string)
};
address.unwrap_or_else(|| name.to_owned())
}
}
#[must_use]
pub fn on_docker(target: Target) -> bool {
matches!(target, Target::Docker | Target::Hosted)
}
#[must_use]
pub fn lay_out(spec: &Spec, target: Target) -> Spec {
let mut s = spec.clone();
if on_docker(target) {
for (name, c) in docker_cidrs(spec) {
if let Some(n) = s.networks.get_mut(&name) {
n.cidr = c.to_string();
}
}
}
s
}
fn network(spec: &Spec, name: &str, n: &Network, declared: &str) -> NetworkPlan {
NetworkPlan {
cidr: n.cidr.clone(),
declared_cidr: declared.to_owned(),
host: host_address(spec, name),
gateway: n.gateway.clone(),
internet: n.internet,
tc: n.tc.clone(),
vlan: n.vlan.clone(),
ipv6: ipv6::network(spec, name),
mtu: n.mtu,
}
}
fn shape(target: Target, m: &Machine) -> MachineShape {
if target == Target::External {
return MachineShape::External;
}
let container = match target {
Target::Docker | Target::Hosted | Target::DockerVm | Target::CloudDocker | Target::Kubernetes => true,
Target::Hybrid => m.docker.is_some(),
Target::Proxmox => m.vm.is_none() && m.docker.as_ref().is_some_and(|d| d.lxc),
_ => false,
};
match (container, m.docker.is_some(), m.vm.is_some()) {
(true, true, _) => MachineShape::Container,
(false, _, true) => MachineShape::Vm,
_ => MachineShape::Supplied,
}
}
fn machines(spec: &Spec, laid_out: &Spec, target: Target) -> IndexMap<String, MachinePlan> {
laid_out
.machines
.iter()
.map(|(name, m)| {
let mut mp = machine(laid_out, target, name, m);
mp.outside = spec.machines.get(name).map(|om| outside::addresses(spec, om)).unwrap_or_default();
(name.clone(), mp)
})
.collect()
}
fn all_requirements(spec: &Spec, laid_out: &Spec, machines: &IndexMap<String, MachinePlan>) -> Vec<Requirement> {
let shape = |n: &str| machines.get(n).map(|m| m.shape);
let mut out = requirements::of(laid_out);
out.extend(outside::requirements(spec));
out.extend(requirements::mixed_shapes(laid_out, shape));
out.extend(crate::machine_files::requirements(laid_out, shape));
out.extend(requirements::tpm(machines));
out.extend(crate::boot::requirements(machines));
out.extend(hardware::requirements(laid_out, shape));
out.extend(crate::more_addresses::requirements(laid_out, shape));
out
}
fn machine(spec: &Spec, target: Target, name: &str, m: &Machine) -> MachinePlan {
let shape = shape(target, m);
let recipe = m.configuration.as_ref().and_then(|c| Recipe::of(c, &m.ports, &DaemonTable::common()).ok());
MachinePlan {
shape,
addresses: m.networks.iter().map(|(n, o)| (n.clone(), address(spec, n, *o))).collect(),
more_addresses: crate::more_addresses::of(spec, m),
addresses6: ipv6::addresses(spec, m),
names: crate::private_dns::with_domain(
spec,
std::iter::once(name).chain(m.aliases.iter().map(String::as_str)).map(String::from).collect(),
),
routes: routing::routes(spec, name, m).into_iter().map(|(cidr, via)| Route { cidr, via }).collect(),
routes6: if target == Target::CloudVm { Vec::new() } else { ipv6::routes(spec, m) },
default_gateway: routing::default_gateway(spec, name, m),
is_gateway: routing::is_gateway(spec, name),
offline: routing::offline(spec, name, m),
ports: published(m),
volumes: volumes(spec, name, m),
files: m.files.clone(),
disks: storage::disks(name, m),
settings: Settings {
environment: m.environment.clone(),
sysctls: m.sysctls.clone(),
ulimits: m.ulimits.clone(),
},
inputs: m.inputs.clone(),
secrets: m.secrets.iter().map(|s| SecretFile::of(spec, s)).collect(),
size: m
.resources
.map(|r| Size {
cpus: r.cpus,
memory_mb: r.memory_mb,
disk_gb: r.disk_gb,
})
.unwrap_or_default(),
arch: m.arch,
rate_limit: m.rate_limit.clone(),
image: image::of(spec, shape, name, m, recipe.as_ref()),
vm_image: m.vm.as_ref().and_then(|v| v.image.clone()),
boot: m.vm.as_ref().and_then(|v| v.boot.clone()),
depends_on: m.depends_on.clone(),
lifecycle: if shape == MachineShape::Vm {
vm_lifecycle(spec, target, name, m, recipe.as_ref())
} else {
Vec::new()
},
os: m.vm.as_ref().map(|v| v.os.clone()),
winrm: m.vm.as_ref().filter(|v| images::is_windows(&v.os) && !windows::winssh(v)).map(images::winrm),
winssh: m.vm.as_ref().is_some_and(windows::winssh),
tpm: m.vm.as_ref().and_then(windows::tpm),
external: m.external.clone(),
host_vars: ansible::host_vars(spec, name),
outside: IndexMap::new(),
process: Process::of(m),
hardware: Hardware::of(m),
recipe,
}
}
fn vm_lifecycle(spec: &Spec, target: Target, name: &str, m: &Machine, recipe: Option<&Recipe>) -> MachineLifecycle {
let mut steps = lifecycle::of(spec, name, m, recipe);
if target == Target::CloudVm {
ipv6::cloud_routes(&mut steps);
}
steps
}
fn published(m: &Machine) -> Vec<PublishedPort> {
m.ports
.iter()
.filter_map(|s| {
s.publish.map(|publish| PublishedPort {
name: m.port_label(s),
port: s.port,
publish,
protocol: s.protocol,
fixed: s.fixed,
})
})
.collect()
}
fn volumes(spec: &Spec, name: &str, m: &Machine) -> Vec<Volume> {
m.volumes
.iter()
.map(|(v, mount)| Volume {
name: v.clone(),
path: mount.path().to_owned(),
subpath: mount.subpath().map(String::from),
key: spec.volume_key(name, v),
lifetime: storage::lifetime(spec, v),
kind: match spec.volume_owner(v) {
Some(owner) => VolumeKind::Shared {
owner: owner.to_owned(),
users: spec.volume_users(v).into_iter().map(String::from).collect(),
},
None => VolumeKind::Own,
},
})
.collect()
}
#[cfg(test)]
mod tests {
use isoloom_spec::model::Target;
use super::{Controller, Environment};
use crate::testing;
const NEEDS_ONE: &str = "version: 1\nname: t\nnetworks: { lan: { cidr: 10.9.0.0/24 } }\nmachines:\n web: { networks: { lan: 10 }, os: debian@12 }\nprovision: [{ ansible: site.yml }]\n";
fn controller(extra: &str) -> Option<Controller> {
testing::environment(&format!("{NEEDS_ONE}{extra}"), Target::Vagrant).controller
}
#[test]
fn the_controller_has_one_cpu_and_512_mb_unless_the_spec_sizes_it() {
assert_eq!(
controller(""),
Some(Controller {
cpus: 1,
memory_mb: 512,
disk_gb: None
})
);
assert_eq!(
controller("controller: { resources: { cpus: 2, memory_mb: 768, disk_gb: 10 } }\n"),
Some(Controller {
cpus: 2,
memory_mb: 768,
disk_gb: Some(10)
})
);
}
#[test]
fn settings_are_empty_until_one_is_set() {
assert!(super::Settings::default().is_empty());
let mut s = super::Settings::default();
s.sysctls.insert("net.ipv4.ip_forward".into(), "1".into());
assert!(!s.is_empty());
let mut u = super::Settings::default();
u.environment.insert("A".into(), "1".into());
assert!(!u.is_empty());
}
#[test]
fn a_probe_reaches_a_machine_at_its_address_else_by_name() {
let env = testing::environment(
"version: 1\nname: t\nnetworks:\n v4: { cidr: 10.9.0.0/24 }\n v6: { cidr: 10.8.0.0/24, ipv6: { cidr: \"fd00:8::/64\", only: true } }\n auto: { cidr: 10.7.0.0/24, ipv6: { cidr: \"fd00:7::/64\", only: true, addresses: auto } }\nmachines:\n web: { networks: { v4: 10, v6: 10, auto: 10 }, app: { image: nginx } }\n",
Target::Kubernetes,
);
assert_eq!(env.probe_address("web", "v4"), "10.9.0.10");
assert_eq!(env.probe_address("web", "v6"), "fd00:8::10");
assert_eq!(env.probe_address("web", "auto"), "web");
assert_eq!(env.probe_address("ghost", "v4"), "ghost");
}
const SHAPES: &str = "version: 1
name: t
networks: { a: { cidr: 10.1.0.0/24, ipv6: { cidr: \"fd00:1::/64\" } }, b: { cidr: 10.2.0.0/24, ipv6: { cidr: \"fd00:2::/64\" } } }
reach: [{ from: a, to: b }]
volumes: { data: { owner: vm } }
machines:
both: { networks: { a: 10 }, os: debian@12, app: { image: nginx, steps: [{ run: a.sh }] }, volumes: { data: /d, own: /o } }
img: { networks: { a: 11 }, app: { image: nginx } }
vm: { networks: { b: 12 }, os: debian@12, app: { steps: [{ run: a.sh }, reboot] }, volumes: { data: /d } }
ct: { networks: { a: 13 }, app: { image: alpine } }
win: { networks: { a: 14 }, os: windows-server@2022 }
user: { access: true, networks: { a: 5 } }
overrides:
proxmox: { machines: { ct: { lxc: true } } }
vagrant: { machines: { win: { winrm: ssl } } }
";
fn shapes(target: Target) -> Vec<(String, super::MachineShape)> {
testing::environment(SHAPES, target)
.machines
.iter()
.map(|(n, m)| (n.clone(), m.shape))
.collect()
}
fn shape_of(target: Target, machine: &str) -> Option<super::MachineShape> {
shapes(target).into_iter().find(|(n, _)| n == machine).map(|(_, s)| s)
}
#[test]
fn each_target_produces_a_machine_in_the_form_it_runs() {
use super::MachineShape::{Container, External, Supplied, Vm};
for t in [Target::Docker, Target::Hosted, Target::DockerVm, Target::CloudDocker, Target::Kubernetes] {
assert_eq!(shape_of(t, "both"), Some(Container), "{t:?}");
assert_eq!(shape_of(t, "user"), Some(Supplied), "{t:?}");
}
assert_eq!(shape_of(Target::Hybrid, "both"), Some(Container), "a container where it has one");
assert_eq!(shape_of(Target::Hybrid, "vm"), Some(Vm));
assert_eq!(shape_of(Target::Proxmox, "ct"), Some(Container), "an LXC container");
assert_eq!(shape_of(Target::Proxmox, "both"), Some(Vm));
assert_eq!(
shape_of(Target::Proxmox, "img"),
Some(Supplied),
"no VM and no LXC: Proxmox refuses it, nothing is built"
);
assert_eq!(shape_of(Target::Vagrant, "both"), Some(Vm));
assert_eq!(shape_of(Target::External, "both"), Some(External));
}
#[test]
fn a_vm_has_set_up_steps_and_a_container_none() {
let env = testing::environment(SHAPES, Target::Hybrid);
let steps = |m: &str| env.machines.get(m).map_or(0, |p| p.lifecycle.len());
assert!(steps("vm") > 0);
assert_eq!(steps("both"), 0);
}
#[test]
fn a_cloud_routes_ipv6_itself_and_the_other_vm_targets_through_the_router() {
let routes6 = |t: Target| {
testing::environment(SHAPES, t)
.machines
.get("both")
.map(|m| m.routes6.len())
.unwrap_or_default()
};
assert_eq!(routes6(Target::Vagrant), 1);
assert_eq!(routes6(Target::CloudVm), 0);
let cloud = testing::environment(SHAPES, Target::CloudVm);
let ipv6_routes = cloud.machines.get("both").and_then(|m| {
m.lifecycle.iter().find_map(|s| match s {
crate::Step::Ipv6 { routes, .. } => Some(routes.len()),
_ => None,
})
});
assert_eq!(ipv6_routes, Some(0));
}
#[test]
fn a_shared_volume_names_its_owner_and_users_and_an_own_one_is_the_machines() {
let env = testing::environment(SHAPES, Target::Vagrant);
let both = env.machines.get("both").map(|m| m.volumes.clone()).unwrap_or_default();
let kinds: Vec<(&str, &super::VolumeKind)> = both.iter().map(|v| (v.name.as_str(), &v.kind)).collect();
assert_eq!(
kinds,
[
(
"data",
&super::VolumeKind::Shared {
owner: "vm".into(),
users: vec!["both".into(), "vm".into()]
}
),
("own", &super::VolumeKind::Own)
]
);
}
#[test]
fn a_windows_vm_answers_winrm_its_way() {
let env = testing::environment(SHAPES, Target::Vagrant);
let win = env.machines.get("win").expect("planned");
assert!(win.is_windows() && !win.winssh);
assert_eq!(win.winrm, Some(isoloom_spec::model::Winrm::Ssl));
let vm = env.machines.get("vm").expect("planned");
assert!(!vm.is_windows() && vm.winrm.is_none());
assert!(super::on_docker(Target::Docker) && super::on_docker(Target::Hosted) && !super::on_docker(Target::DockerVm));
}
#[test]
fn only_playbooks_need_a_controller() {
let lab = testing::lab(
"version: 1\nname: t\nnetworks: { lan: { cidr: 10.9.0.0/24 } }\nmachines:\n web: { networks: { lan: 10 }, os: debian@12 }\nchecks: [checks/a.sh]\n",
);
let spec = lab.for_target(Target::Vagrant);
assert!(!Controller::needed(&spec) && Environment::build(&spec, Target::Vagrant, None).controller.is_none());
let playbook = testing::lab(
"version: 1\nname: t\nnetworks: { lan: { cidr: 10.9.0.0/24 } }\nmachines:\n web: { networks: { lan: 10 }, os: debian@12 }\nchecks: [checks/a.yml]\n",
);
assert!(Controller::needed(&playbook.for_target(Target::Vagrant)));
assert_eq!(Controller::of(&spec).memory_mb, Controller::MEMORY_MB);
}
}