Skip to main content

isoloom_compose/
render.rs

1//! The Compose file of a planned [`Environment`]: a service per container machine (with its
2//! `init:` jobs and network sidecar), Isoloom's router, VLAN switches, probes, check runners and
3//! tools, then the networks and volumes; every resource labelled as the environment's.
4
5mod configs;
6mod hardware;
7mod process;
8mod secrets;
9mod service;
10
11pub(crate) use service::readiness;
12
13use std::fmt;
14
15use indexmap::IndexMap;
16use isoloom_plan::Environment;
17use isoloom_render::backend::Files;
18use isoloom_render::container::PROBE_IMAGE;
19use isoloom_render::{GeneratedFile, OUTPUT_DIR, header, trunks};
20use isoloom_spec::model::{Arch, Target};
21use isoloom_spec::validate::Cidr;
22
23use crate::error::{Error, Result};
24use crate::file::{Attachment, ComposeFile, NetworkMode, Restart, Service, Volume};
25use crate::images::TOOLBOX_IMAGE;
26use crate::layout::DIR;
27use crate::machine::Machine;
28use crate::names::{probe_service, tool_service};
29use crate::{check_runners, ipv6, labels, networks, outside, router, storage};
30
31/// How to start, check and stop the file by hand, at its top.
32const USAGE: &str = "# Start:  docker compose -f .isoloom/docker/compose.yml up -d --wait\n# Checks: isothrum check . (or: docker compose -f .isoloom/docker/compose.yml --profile check run --rm isoloom-check, and isoloom-check-<machine>)\n# Stop:   docker compose -f .isoloom/docker/compose.yml down -v\n";
33
34/// The Compose file of `env` and the files it mounts (check runners, appliance configurations),
35/// refused as `target` when `env` needs what Compose can't do.
36///
37/// # Errors
38///
39/// [`Error::Refused`] when `env` needs what Compose can't do, [`Error::Unwritable`] when the
40/// file can't be written as YAML.
41pub fn render(target: Target, env: &Environment) -> Result<Files> {
42    let Rendered { file, files: mounted } = compose_file(target, env)?;
43    let yaml = to_yaml(target, &file)?;
44    let mut files = vec![GeneratedFile {
45        path: format!("{OUTPUT_DIR}/{DIR}/compose.yml"),
46        contents: format!("{}{USAGE}{}{}{}\n{yaml}", header("#"), secrets::usage(&file), configs::usage(env), Moved(env)),
47    }];
48    files.extend(mounted);
49    Ok(files)
50}
51
52/// A Compose file before it is written, and the files it mounts.
53#[derive(Debug, Clone)]
54pub(crate) struct Rendered {
55    /// The Compose file.
56    pub(crate) file: ComposeFile,
57    /// The files next to it: check runners, appliance configurations, Dockerfiles.
58    pub(crate) files: Files,
59}
60
61/// The typed Compose file of `env` and the files it mounts, for a target that adapts it before
62/// writing it (`hybrid` plugs it into its Docker host's networks). Refused as `target` when
63/// `env` needs what Compose can't do.
64pub(crate) fn compose_file(target: Target, env: &Environment) -> Result<Rendered> {
65    if let Some(refusal) = isoloom_plan::refusal(&env.requirements, &crate::backend::capabilities()) {
66        return Err(Error::Refused { target, refusal });
67    }
68    let mut out = Out::default();
69    let ts = trunks::containers(env.spec());
70    router::services(env, &ts, &mut out.services);
71    for m in Machine::containers(env) {
72        service::machine(env, m, &ts, &mut out);
73    }
74    // The access box (the learner's attacker box) renders as an on-demand service even when it is
75    // `Supplied` (no `docker:`/`vm:` form of its own): the runner hot-adds it. Container-shape
76    // access boxes are already covered by `containers()` above.
77    for m in Machine::supplied_access(env) {
78        service::machine(env, m, &ts, &mut out);
79    }
80    storage::folders(env, &mut out.services);
81    let runners = check_runners::services(env, &mut out.services);
82    tools(env, &mut out.services);
83    let mut file = ComposeFile {
84        name: env.name.clone(),
85        networks: networks::declared(env, &ts),
86        volumes: volumes(env, &out.probe_archs),
87        secrets: secrets::of_file(env),
88        configs: configs::declared(env),
89        ..ComposeFile::default()
90    };
91    probes(env, &out.probe_archs, &mut out.services);
92    file.services = out.services;
93    labels::mark(env, &mut file);
94    // Existing networks aren't the environment's: unlabelled, so nothing collects them.
95    file.networks.extend(outside::networks(env));
96    let mut files = runners;
97    files.extend(out.files);
98    files.extend(isoloom_render::configure::dockerfile::files(env));
99    Ok(Rendered { file, files })
100}
101
102/// `file` as YAML, a failure reported as `target`'s.
103pub(crate) fn to_yaml(target: Target, file: &ComposeFile) -> Result<String> {
104    serde_yaml_ng::to_string(file).map_err(|source| Error::Unwritable { target, source })
105}
106
107/// What rendering the machines collects besides their services.
108#[derive(Default)]
109struct Out {
110    services: IndexMap<String, Service>,
111    /// Appliance configurations and Dockerfiles, next to the Compose file.
112    files: Vec<GeneratedFile>,
113    /// The architectures of machines that need the probe: one copy job (and volume) each.
114    probe_archs: Vec<Arch>,
115}
116
117/// The comment saying which networks moved to their Docker block, a line each.
118struct Moved<'a>(&'a Environment);
119
120impl fmt::Display for Moved<'_> {
121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122        for (name, n) in self.0.networks.iter().filter(|(_, n)| n.declared_cidr != n.cidr) {
123            writeln!(
124                f,
125                "# On Docker, network `{name}` uses {} instead of {} (same last octets).",
126                n.cidr, n.declared_cidr
127            )?;
128        }
129        Ok(())
130    }
131}
132
133/// Tools: observers on every network at the reserved addresses, outside the environment's
134/// contract (no reach rule, no check names them).
135fn tools(env: &Environment, services: &mut IndexMap<String, Service>) {
136    let spec = env.spec();
137    for (i, (name, tool)) in spec.tools.iter().enumerate() {
138        let shell = name == "shell";
139        let mut t = Service {
140            image: Some(env.images.pull(tool.image.as_deref().unwrap_or(TOOLBOX_IMAGE))),
141            hostname: Some(name.clone()),
142            command: tool.command.clone(),
143            restart: Some(Restart::UnlessStopped),
144            ..Service::default()
145        };
146        if tool.command.is_empty() && shell {
147            t.init = true;
148            t.entrypoint = vec!["sleep".into(), "infinity".into()];
149        }
150        if shell {
151            t.cap_add = vec!["NET_ADMIN".into(), "NET_RAW".into()];
152        }
153        // The spec is validated: every block parses.
154        for (net, n) in &spec.networks {
155            // On an IPv6-only network, Docker hands the tool its address.
156            if ipv6::only(env, net) {
157                t.networks.insert(net.clone(), None);
158            } else if let Some(cidr) = Cidr::parse(&n.cidr) {
159                t.networks.insert(net.clone(), Some(Attachment::at(cidr.tool(i))));
160            }
161        }
162        if let (Some(port), Some(host)) = (tool.port, tool.publish) {
163            t.ports = vec![format!("${{ISOLOOM_PUBLISH_ADDRESS:-127.0.0.1}}:{host}:{port}")];
164        }
165        services.insert(tool_service(name), t);
166    }
167}
168
169/// The one-shot services that copy the probe, one per architecture.
170fn probes(env: &Environment, archs: &[Arch], services: &mut IndexMap<String, Service>) {
171    for arch in archs {
172        let name = probe_service(*arch);
173        let p = Service {
174            image: Some(env.images.pull(PROBE_IMAGE)),
175            platform: Some(arch.docker_platform().to_owned()),
176            entrypoint: vec!["/bin/cp".into(), "/bin/busybox".into(), "/probe/busybox".into()],
177            volumes: vec![format!("{name}:/probe").into()],
178            network_mode: Some(NetworkMode::None),
179            restart: Some(Restart::No),
180            ..Service::default()
181        };
182        services.insert(name, p);
183    }
184}
185
186/// The named volumes: the machines' (and their disks'), then the probes'.
187fn volumes(env: &Environment, archs: &[Arch]) -> IndexMap<String, Volume> {
188    let mut out = storage::declared(env);
189    out.extend(archs.iter().map(|a| (probe_service(*a), Volume::default())));
190    out
191}
192
193#[cfg(test)]
194mod tests {
195    use isoloom_plan::Environment;
196    use isoloom_spec::model::Target;
197
198    use crate::start::{StartPlan, start_plan};
199
200    #[test]
201    fn the_start_plan_reads_the_same_from_the_text_as_from_the_typed_file() {
202        for example in crate::testing::examples() {
203            let env = Environment::build(&example.spec, Target::Docker, None);
204            let Ok(rendered) = super::compose_file(Target::Docker, &env) else { continue };
205            let yaml = super::to_yaml(Target::Docker, &rendered.file).expect("writes");
206            assert_eq!(StartPlan::of(&rendered.file), start_plan(&yaml).expect("reads back"), "{}", example.name);
207        }
208    }
209}