use indexmap::IndexMap;
use isoloom_plan::{Environment, Image, PublishedPort};
use isoloom_render::container::{PROBE_DIR, init_names};
use isoloom_render::{GeneratedFile, OUTPUT_DIR, constraints, trunks};
use isoloom_spec::model;
use super::{Out, configs, hardware, process, secrets};
use crate::appliances::{self, Port};
use crate::file::{Attachment, Build, Condition, Deploy, Limits, NetworkMode, Resources, Restart, Service, Ulimit};
use crate::interpolation::literal;
use crate::layout::{DIR, ROOT, file_name, relative_to};
use crate::machine::Machine;
use crate::names::{probe_service, routes_service};
use crate::{labels, networks, outside, sidecars, storage};
pub(super) fn machine(env: &Environment, m: Machine<'_>, ts: &[trunks::Trunk], out: &mut Out) {
let mut svc = base(env, m, ts);
appliance(env, m, &mut svc, &mut out.files);
waits(env, m, &mut svc, &mut out.probe_archs);
svc.deploy = limits(m.plan);
svc.labels.extend(labels::ports(m.spec));
hardware::machine(&m.plan.hardware, &mut svc);
out.services.insert(m.name.to_owned(), svc);
if sidecars::needed(env, m) {
out.services.insert(routes_service(m.name), sidecars::routes(env, m.name, m));
}
init_jobs(env, m, &mut out.services);
}
pub(crate) fn readiness(m: &model::Machine) -> Condition {
if m.has_readiness() { Condition::Healthy } else { Condition::Started }
}
fn base(env: &Environment, m: Machine<'_>, ts: &[trunks::Trunk]) -> Service {
let (spec, plan) = (m.spec, m.plan);
let (tmpfs, long_tmpfs) = storage::tmpfs(spec);
let mut svc = Service {
platform: Some(plan.arch.docker_platform().to_owned()),
privileged: spec.privileged,
read_only: spec.read_only,
sysctls: plan.settings.sysctls.clone(),
ulimits: plan
.settings
.ulimits
.iter()
.map(|(k, u)| (k.clone(), Ulimit::new(u.soft(), u.hard())))
.collect(),
shm_size: spec.shm_size.clone(),
tmpfs,
hostname: Some(m.name.to_owned()),
networks: networks::attachments(env, plan),
extra_hosts: networks::extra_hosts(env, m),
environment: environment(env, plan),
ports: plan.ports.iter().map(port).collect(),
volumes: storage::volume_mounts(plan).into_iter().chain(long_tmpfs).collect(),
secrets: secrets::of_machine(plan),
configs: configs::mounts(m.name, plan),
restart: Some(Restart::UnlessStopped),
..Service::default()
};
if let Some(Image::Container { reference, build }) = &plan.image {
svc.image = Some(reference.clone());
svc.build = build.as_ref().map(|b| build_of(env, b));
}
if let Some(dns) = &spec.dns {
svc.dns.clone_from(&dns.servers);
svc.dns_opt.clone_from(&dns.options);
svc.dns_search.clone_from(&dns.search);
svc.domainname.clone_from(&dns.domain);
}
if spec.supplied || spec.docker.as_ref().is_some_and(|d| d.idle) {
svc.init = true;
svc.entrypoint = vec!["sleep".into(), "infinity".into()];
}
if let Some(Image::Container {
build: Some(isoloom_plan::Build::Generated { process, .. }),
..
}) = &plan.image
{
svc.init |= process.is_runner();
}
process::apply(plan, &mut svc);
svc.networks.retain(|k, _| !trunks::carried(ts, m.name, k));
for t in trunks::of(ts, m.name) {
svc.networks
.insert(trunks::link_network(t), Some(Attachment::with_mac(trunks::mac(t, trunks::End::Machine))));
}
svc.networks.extend(outside::attachments(plan));
if plan.is_gateway {
svc.cap_add = vec!["NET_ADMIN".into()];
svc.sysctls = IndexMap::from([("net.ipv4.ip_forward".to_owned(), "1".to_owned())]);
}
svc
}
fn port(p: &PublishedPort) -> String {
let proto = if p.protocol.is_tcp() { "" } else { "/udp" };
let (publish, port) = (p.publish, p.port);
if p.fixed {
format!("${{ISOLOOM_PUBLISH_ADDRESS:-127.0.0.1}}:{publish}:{port}{proto}")
} else {
format!("${{ISOLOOM_PUBLISH_ADDRESS:-127.0.0.1}}:${{ISOLOOM_PUBLISH_FIXED:+{publish}}}:{port}{proto}")
}
}
fn build_of(env: &Environment, b: &isoloom_plan::Build) -> Build {
let constraints = constraints::build_args(env.spec());
let (context, dockerfile, target, own) = match b {
isoloom_plan::Build::Own {
context,
dockerfile,
target,
args,
} => (
format!("{ROOT}/{context}"),
dockerfile.as_ref().map(|f| relative_to(context, f)),
target.clone(),
args.clone(),
),
isoloom_plan::Build::Generated { dockerfile, .. } => (ROOT.to_owned(), Some(dockerfile.clone()), None, IndexMap::new()),
};
Build {
context,
dockerfile,
target,
args: constraints
.into_iter()
.filter(|(k, _)| !own.contains_key(k))
.chain(own.clone())
.map(|(k, v)| (k, literal(&v)))
.collect(),
labels: IndexMap::new(),
}
}
fn environment(env: &Environment, plan: &isoloom_plan::MachinePlan) -> Option<IndexMap<String, String>> {
let constraints = constraints::machine_env(env.spec());
if plan.inputs.is_empty() && constraints.is_empty() && plan.settings.environment.is_empty() {
return None;
}
let fixed = plan.settings.environment.iter().map(|(k, v)| (k.clone(), v.clone()));
let mut out: IndexMap<String, String> = constraints.into_iter().chain(fixed).map(|(k, v)| (k, literal(&v))).collect();
out.extend(plan.inputs.iter().map(|i| (i.clone(), format!("${{{i}:-}}"))));
Some(out)
}
fn appliance(env: &Environment, m: Machine<'_>, svc: &mut Service, files: &mut Vec<GeneratedFile>) {
let Some((d, kind)) = m.spec.docker.as_ref().and_then(|d| Some((d, d.appliance?))) else {
return;
};
let spec = env.spec();
let name = m.name;
let w = appliances::wiring(spec, name, m.spec, kind, |n, o| isoloom_plan::addressing::address(spec, n, o));
svc.volumes.clear();
for f in w.files {
files.push(GeneratedFile {
path: format!("{OUTPUT_DIR}/{DIR}/appliances/{name}/{}", f.name),
contents: f.contents,
});
svc.volumes.push(format!("./appliances/{name}/{}:{}:ro", f.name, f.inside).into());
}
if let Some(own) = &d.config {
svc.volumes.push(format!("{ROOT}/{own}:{}:ro", w.own_config).into());
}
if let (Some(fw), Some(inside)) = (&d.firmware, w.firmware) {
svc.volumes.push(format!("{ROOT}/{fw}:{inside}:ro").into());
}
if let Some(dockerfile) = w.build {
files.push(GeneratedFile {
path: format!("{OUTPUT_DIR}/{DIR}/appliances/{name}/build/Dockerfile"),
contents: pull_from(env, dockerfile),
});
svc.image = None;
svc.build = Some(Build {
context: format!("./appliances/{name}/build"),
..Build::default()
});
}
svc.entrypoint = w.entrypoint.iter().map(|e| literal(e)).collect();
svc.environment = Some(w.environment.into_iter().collect());
if w.privileged {
svc.privileged = true;
} else {
svc.cap_add = vec!["NET_ADMIN".into(), "NET_RAW".into()];
}
svc.networks = appliance_networks(spec, name, &w.ports);
}
fn appliance_networks(spec: &isoloom_spec::model::Spec, name: &str, ports: &[Port]) -> IndexMap<String, Option<Attachment>> {
let mgmt = Attachment {
interface_name: Some("eth0".into()),
priority: Some(1000),
..Attachment::at(appliances::mgmt_address(spec, name))
};
let mut nets = IndexMap::from([(appliances::MGMT_NETWORK.to_owned(), Some(mgmt))]);
for (k, p) in ports.iter().enumerate() {
let a = Attachment {
ipv4_address: p.address.map(|a| a.to_string()),
interface_name: Some(format!("eth{}", k + 1)),
priority: Some(999u32.saturating_sub(u32::try_from(k).unwrap_or(u32::MAX))),
..Attachment::default()
};
nets.insert(p.network.clone(), Some(a));
}
nets
}
fn pull_from(env: &Environment, dockerfile: &str) -> String {
dockerfile
.split_inclusive('\n')
.map(|line| match line.strip_prefix("FROM ") {
Some(rest) => {
let (image, tail) = rest.find(char::is_whitespace).and_then(|at| rest.split_at_checked(at)).unwrap_or((rest, ""));
format!("FROM {}{tail}", env.images.pull(image))
}
None => line.to_owned(),
})
.collect()
}
fn waits(env: &Environment, m: Machine<'_>, svc: &mut Service, archs: &mut Vec<model::Arch>) {
let plan = m.plan;
if let Some(healthcheck) = process::healthcheck(m.spec, plan) {
svc.healthcheck = Some(healthcheck);
let probe_svc = probe_service(plan.arch);
svc.volumes.push(format!("{probe_svc}:{PROBE_DIR}:ro").into());
svc.after(probe_svc, Condition::CompletedSuccessfully);
if !archs.contains(&plan.arch) {
archs.push(plan.arch);
}
}
for dep in plan.depends_on.iter().filter_map(|d| Machine::of(env, d)) {
svc.after(dep.name, readiness(dep.spec));
for init in init_names(dep.name, dep.spec) {
svc.after(init, Condition::CompletedSuccessfully);
}
if sidecars::needed(env, dep) {
svc.after(routes_service(dep.name), Condition::Healthy);
}
}
}
fn limits(plan: &isoloom_plan::MachinePlan) -> Option<Deploy> {
let limits = Limits {
cpus: plan.size.cpus.map(|c| c.to_string()),
memory: plan.size.memory_mb.map(|mb| format!("{mb}M")),
};
(limits.cpus.is_some() || limits.memory.is_some()).then_some(Deploy {
resources: Resources {
limits: Some(limits),
reservations: None,
},
})
}
fn init_jobs(env: &Environment, m: Machine<'_>, services: &mut IndexMap<String, Service>) {
let Some(d) = &m.spec.docker else { return };
let plan = m.plan;
let image = match &plan.image {
Some(Image::Container { reference, .. }) => Some(reference.clone()),
_ => None,
};
let mut previous: Option<String> = None;
for (job, script) in init_names(m.name, m.spec).into_iter().zip(&d.init) {
let mounted = format!("/isoloom/init/{}", file_name(script));
let mut j = Service {
image: image.clone(),
user: Some("root".into()),
privileged: m.spec.privileged,
cap_add: if plan.is_gateway { vec!["NET_ADMIN".into()] } else { Vec::new() },
entrypoint: vec!["/bin/sh".into(), mounted.clone()],
network_mode: Some(NetworkMode::Service(m.name.to_owned())),
environment: environment(env, plan),
secrets: secrets::of_machine(plan),
configs: configs::mounts(m.name, plan),
restart: Some(Restart::No),
..Service::default()
};
j.volumes.push(format!("{ROOT}/{script}:{mounted}:ro").into());
j.volumes.extend(storage::volume_mounts(plan));
j.after(m.name, readiness(m.spec));
if sidecars::needed(env, m) {
j.after(routes_service(m.name), Condition::Healthy);
}
if let Some(p) = &previous {
j.after(p.clone(), Condition::CompletedSuccessfully);
}
services.insert(job.clone(), j);
previous = Some(job);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_from_line_keeps_its_stage_name_and_other_lines_stay() {
let env = crate::testing::environment(
"version: 1\nname: t\nnetworks: { lan: { cidr: 10.9.0.0/24 } }\nmachines:\n web: { networks: { lan: 10 }, app: { image: nginx } }\n",
);
let dockerfile = "FROM ubuntu:24.04 AS base\nRUN true\nFROM ubuntu:24.04\n";
assert_eq!(pull_from(&env, dockerfile), dockerfile);
}
#[test]
fn appliance_interfaces_attach_in_order_from_the_highest_priority() {
let spec = isoloom_plan::Lab::parse(
"version: 1\nname: t\nnetworks: { a: { cidr: 10.1.0.0/24 }, b: { cidr: 10.2.0.0/24 } }\nmachines:\n r: { networks: { a: 1, b: 1 }, app: { image: iol, appliance: cisco-iol } }\n",
)
.expect("parses")
.for_target(isoloom_spec::model::Target::Docker);
let ports = appliances::ports(&spec, "r", &spec.machines["r"], &|n, o| isoloom_plan::addressing::address(&spec, n, o));
let nets = appliance_networks(&spec, "r", &ports);
let order: Vec<(&str, Option<u32>)> = nets.iter().map(|(n, a)| (n.as_str(), a.as_ref().and_then(|a| a.priority))).collect();
assert_eq!(order, [("isoloom-mgmt", Some(1000)), ("a", Some(999)), ("b", Some(998))]);
}
}