Skip to main content

isoloom_render/
run_manifest.rs

1//! What every backend's run manifest is built from: its header, steps (a native command or a
2//! driver action, with their policies), tools, the lab's networks, a machine's addresses and
3//! ports, the outputs of its published ports, and the checks a runner reports with the time
4//! its step may take. Pure functions over the plan, shared so each target says them once.
5//! Compose inside a VM is [`guest`]: what both backends of `docker-vm` share.
6
7pub mod guest;
8
9use std::collections::{BTreeMap, BTreeSet};
10use std::net::IpAddr;
11use std::num::{NonZeroU16, NonZeroU32};
12
13use isoloom_manifest::{
14    Action, Arch, Argv, Check, CheckKind, Cidr, Delivery, EnvName, Error as ManifestError, Generator, GeneratorName, HostArgv, Lab, LabName, Machine, Name,
15    Network, NonEmpty, Output, OutputRole, OutputSource, Param, Part, Placeholder, Port, PortKind, PortName, Program, Protocol, Publish, PublishMode, Role,
16    Run, SetBy, SnakeName, Step, StepId, StepSelection, Template, Tool, ToolName, Version, When,
17};
18use isoloom_plan::checks::{Probe, Resolved};
19use isoloom_plan::{Environment, MachinePlan, PublishedPort};
20use isoloom_spec::model;
21
22/// What a check runner's step may add to its checks' waits: the probes' own time (curl and nc
23/// cap a probe at 5 to 10 s) per check, and the runner's start.
24const PER_CHECK_S: u32 = 15;
25const START_S: u32 = 60;
26
27/// Isoloom, at this build's version: who wrote the manifest.
28///
29/// # Errors
30///
31/// Fails when the workspace's version isn't `major.minor.patch` (a bug of the build).
32pub fn generator() -> Result<Generator, ManifestError> {
33    Ok(Generator {
34        name: GeneratorName::Isoloom,
35        version: Version::parse(env!("CARGO_PKG_VERSION"))?,
36    })
37}
38
39/// The lab `env` plans: its name and instance, its message left to the caller.
40///
41/// # Errors
42///
43/// Fails when the environment's name or instance isn't one the manifest takes (a bug: the spec
44/// checks both).
45pub fn lab(env: &Environment) -> Result<Lab, ManifestError> {
46    Ok(Lab {
47        name: LabName::parse(&env.name)?,
48        instance: env.instance.map(isoloom_manifest::Instance::new).transpose()?,
49        message: None,
50    })
51}
52
53/// A bare tool name, as a step's program.
54///
55/// # Errors
56///
57/// Fails on a name the manifest refuses as a program (a shell, a path).
58pub fn program(name: &str) -> Result<Program, ManifestError> {
59    Program::parse(name)
60}
61
62/// `program` with `args`, the same on every host.
63///
64/// # Errors
65///
66/// Fails as [`program`] does.
67pub fn argv(name: &str, args: &[&str]) -> Result<HostArgv, ManifestError> {
68    Ok(HostArgv::Every(Argv::new(program(name)?, args.iter().map(|a| Template::text(a)).collect())))
69}
70
71/// A step running `argv` for at most `timeout_s` seconds.
72///
73/// # Errors
74///
75/// Fails when `id` isn't a step id.
76pub fn run(id: &str, argv: HostArgv, timeout_s: u32) -> Result<Step, ManifestError> {
77    let mut s = Step::argv(StepId::parse(id)?, argv);
78    s.timeout_s = NonZeroU32::new(timeout_s);
79    Ok(s)
80}
81
82/// A step running the driver's `action`.
83///
84/// # Errors
85///
86/// Fails when `id` isn't a step id.
87pub fn act(id: &str, action: Action) -> Result<Step, ManifestError> {
88    Ok(Step::action(StepId::parse(id)?, action))
89}
90
91/// Driver action `action` with its arguments `with` (names, then templates).
92///
93/// # Errors
94///
95/// Fails when a name isn't one an argument can have.
96pub fn action_with<'a>(action: Action, with: impl IntoIterator<Item = (&'a str, Template)>) -> Result<Run, ManifestError> {
97    Ok(Run::Action {
98        action,
99        with: with
100            .into_iter()
101            .map(|(k, v)| Ok((SnakeName::parse(k)?, v)))
102            .collect::<Result<BTreeMap<_, _>, ManifestError>>()?,
103    })
104}
105
106/// `ISOLOOM_DERIVED`, the switch of the checks derived from the spec (`0` skips them), delivered
107/// to the check runners' `steps`; `None` when there is none.
108///
109/// # Errors
110///
111/// Fails on a name the manifest refuses (a bug: it is fixed).
112pub fn derived_switch(steps: Vec<StepId>) -> Result<Option<Param>, ManifestError> {
113    let Ok(steps) = NonEmpty::new(steps, "the check steps") else {
114        return Ok(None);
115    };
116    Ok(Some(Param {
117        name: EnvName::parse(DERIVED)?,
118        description: "0: the checks derived from the spec (reachability, no internet) are skipped.".into(),
119        default: Some("1".into()),
120        values: vec!["0".into(), "1".into()],
121        required: false,
122        hosts: BTreeSet::new(),
123        providers: BTreeSet::new(),
124        set_by: SetBy::Embedder,
125        deliver: NonEmpty::one(Delivery::Env {
126            var: EnvName::parse(DERIVED)?,
127            steps: StepSelection::Only(steps),
128        }),
129    }))
130}
131
132/// The derived checks' switch, as a runner reads it.
133pub const DERIVED: &str = "ISOLOOM_DERIVED";
134
135/// `s` as a clean-up: it runs whatever happened before it, and its failure is only logged.
136#[must_use]
137pub fn cleanup(mut s: Step) -> Step {
138    s.when = When::Always;
139    s.failure = isoloom_manifest::Failure::Tolerate;
140    s
141}
142
143/// `s`, its failure only logged.
144#[must_use]
145pub fn tolerated(mut s: Step) -> Step {
146    s.failure = isoloom_manifest::Failure::Tolerate;
147    s
148}
149
150/// `s`, saying `note` when it starts.
151#[must_use]
152pub fn noted(mut s: Step, note: &str) -> Step {
153    s.note = Some(note.to_owned());
154    s
155}
156
157/// A tool named `name`, probed with `probe`, on every host and provider.
158///
159/// # Errors
160///
161/// Fails when `name` isn't a tool name.
162pub fn tool(name: &str, probe: HostArgv) -> Result<Tool, ManifestError> {
163    Ok(Tool {
164        name: ToolName::parse(name)?,
165        probe,
166        min_version: None,
167        container: None,
168        container_hosts: BTreeSet::new(),
169        hosts: BTreeSet::new(),
170        providers: BTreeSet::new(),
171    })
172}
173
174/// The lab's networks, each with its name where the target creates it (`native`).
175///
176/// # Errors
177///
178/// Fails on a network name or block the manifest refuses (a bug: the spec checks both).
179pub fn networks(env: &Environment, native: impl Fn(&str) -> Template) -> Result<Vec<Network>, ManifestError> {
180    env.networks
181        .iter()
182        .map(|(name, n)| {
183            Ok(Network {
184                name: Name::parse(name)?,
185                cidr: Cidr::parse(&n.cidr)?,
186                internet: n.internet,
187                native: Some(native(name)),
188            })
189        })
190        .collect()
191}
192
193/// A CPU architecture as the manifest names it.
194#[must_use]
195pub fn arch(a: model::Arch) -> Arch {
196    match a {
197        model::Arch::Amd64 => Arch::Amd64,
198        model::Arch::Arm64 => Arch::Arm64,
199    }
200}
201
202fn protocol(p: model::Protocol) -> Protocol {
203    if p.is_tcp() { Protocol::Tcp } else { Protocol::Udp }
204}
205
206/// A machine's address on each network: IPv4 where it has one, else IPv6.
207///
208/// # Errors
209///
210/// Fails on a network name the manifest refuses.
211pub fn addresses(mp: &MachinePlan) -> Result<BTreeMap<Name, IpAddr>, ManifestError> {
212    let v4 = mp.addresses.iter().map(|(n, a)| (n, IpAddr::V4(*a)));
213    let v6 = mp
214        .addresses6
215        .iter()
216        .filter(|(n, _)| !mp.addresses.contains_key(*n))
217        .map(|(n, a)| (n, IpAddr::V6(*a)));
218    v4.chain(v6).map(|(n, a)| Ok((Name::parse(n)?, a))).collect()
219}
220
221/// A port of the lab's, named `label` (the spec's name), published as `published` plans it,
222/// listening on `address` on the host.
223///
224/// # Errors
225///
226/// Fails on a name the manifest refuses, or port 0.
227pub fn port(label: &str, p: &model::Port, published: &[PublishedPort], address: &Template) -> Result<Port, ManifestError> {
228    let publish = published.iter().find(|x| x.name == label).map(|x| Publish {
229        mode: if x.fixed { PublishMode::Fixed } else { PublishMode::Ephemeral },
230        preferred: NonZeroU16::new(x.publish),
231        address: Some(address.clone()),
232    });
233    Ok(Port {
234        name: PortName::parse(label)?,
235        port: NonZeroU16::new(p.port).ok_or(ManifestError::OutOfRange {
236            what: "a port",
237            value: 0,
238            min: 1,
239            max: 65535,
240        })?,
241        kind: PortKind::from_parts(protocol(p.protocol), p.http, p.tls)?,
242        publish,
243    })
244}
245
246/// A bare machine with the given `name` and `role`, every other field empty: a caller fills its
247/// resources, addresses, native name and ports.
248///
249/// # Errors
250///
251/// Fails on a name the manifest refuses.
252pub fn machine(name: &str, role: Role) -> Result<Machine, ManifestError> {
253    Ok(Machine {
254        name: Name::parse(name)?,
255        role,
256        of: None,
257        os: None,
258        windows: false,
259        arch: None,
260        resources: None,
261        addresses: std::collections::BTreeMap::new(),
262        keep_running: false,
263        native: None,
264        volumes: Vec::new(),
265        ports: Vec::new(),
266    })
267}
268
269/// A Terraform state output `name` (role `role`), read by the runner as `${outputs.<name>}`.
270///
271/// # Errors
272///
273/// Fails on a name the manifest refuses.
274pub fn terraform_output(name: &str, role: OutputRole, description: &str) -> Result<Output, ManifestError> {
275    Ok(Output {
276        name: SnakeName::parse(name)?,
277        description: Some(description.to_owned()),
278        role,
279        sensitive: false,
280        format: None,
281        from: OutputSource::TerraformOutput {
282            name: isoloom_manifest::Identifier::parse(name)?,
283            default: None,
284        },
285    })
286}
287
288/// An output per published port of the lab's machines and tools: an HTTP port is an entry (the
289/// Open button), the others information; each shown at the host's loopback.
290///
291/// # Errors
292///
293/// Fails on an output name the manifest refuses.
294pub fn outputs(machines: &[Machine]) -> Result<Vec<Output>, ManifestError> {
295    outputs_at(machines, &Template::text("127.0.0.1"))
296}
297
298/// [`outputs`], each shown at `host` (a server lab's router, `${outputs.ip}`).
299///
300/// # Errors
301///
302/// Fails on an output name the manifest refuses.
303pub fn outputs_at(machines: &[Machine], host: &Template) -> Result<Vec<Output>, ManifestError> {
304    outputs_with(machines, |_| host.clone())
305}
306
307/// [`outputs`], each shown at the host the driver reports for its machine (`${host.<machine>}`):
308/// the loopback with the forwarded port, or, where the provider forwards nothing (ESXi), the
309/// machine's own address with its own port.
310///
311/// # Errors
312///
313/// Fails on an output name the manifest refuses.
314pub fn outputs_on_their_hosts(machines: &[Machine]) -> Result<Vec<Output>, ManifestError> {
315    outputs_with(machines, |m| Template::from(Placeholder::Host(m.name.clone())))
316}
317
318/// [`outputs`], machine `m`'s shown at `host(m)` (a cloud machine's own public address).
319///
320/// # Errors
321///
322/// Fails on an output name the manifest refuses.
323pub fn outputs_with(machines: &[Machine], host: impl Fn(&Machine) -> Template) -> Result<Vec<Output>, ManifestError> {
324    let shown = machines.iter().filter(|m| matches!(m.role, Role::Target | Role::Access | Role::Tool));
325    shown
326        .flat_map(|m| m.ports.iter().filter(|p| p.publish.is_some()).map(move |p| (m, p)))
327        .map(|(m, p)| output(m, p, &host(m)))
328        .collect()
329}
330
331fn output(m: &Machine, p: &Port, host: &Template) -> Result<Output, ManifestError> {
332    let snake: String = format!("{}_{}", m.name, p.name)
333        .chars()
334        .map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '_' })
335        .collect();
336    let (role, scheme, after) = match p.kind {
337        PortKind::Https => (OutputRole::Entry, "https://", "/"),
338        PortKind::Http => (OutputRole::Entry, "http://", "/"),
339        PortKind::Tcp | PortKind::Udp | PortKind::Tls => (OutputRole::Info, "", ""),
340    };
341    let port = if p.kind == PortKind::Udp {
342        format!("{}/udp", p.port)
343    } else {
344        p.port.to_string()
345    };
346    let what = format!("{}'s {} (port {port})", m.name, p.name);
347    Ok(Output {
348        name: SnakeName::parse(&snake)?,
349        description: Some(format!("{what}.")),
350        role,
351        sensitive: false,
352        format: Some(Template::from_parts(
353            std::iter::once(Part::Text(scheme.into())).chain(host.parts().iter().cloned()).chain([
354                Part::Text(":".into()),
355                Part::Placeholder(Placeholder::Value),
356                Part::Text(after.into()),
357            ]),
358        )),
359        from: OutputSource::PublishedPort {
360            machine: m.name.clone(),
361            port: p.name.clone(),
362        },
363    })
364}
365
366/// A planned check, as its runner reports it.
367#[must_use]
368pub fn check(c: &Resolved) -> Check {
369    let kind = match c.probe {
370        Probe::Http { .. } => CheckKind::Http,
371        Probe::Tcp { .. } => CheckKind::Tcp,
372        Probe::Exec { .. } => CheckKind::Exec,
373        Probe::Script { .. } => CheckKind::Script,
374        Probe::Playbook { .. } => CheckKind::Playbook,
375    };
376    Check {
377        name: c.name.clone(),
378        kind,
379        wait_s: c.wait,
380        derived: c.derived,
381    }
382}
383
384/// The time limit of a runner of `checks`, the same on every target: their waits, plus 15 s
385/// each, plus 60 s. A runner never stops before its slowest check could pass; booting what the
386/// runner runs on is not check time (a setup step's).
387#[must_use]
388pub fn timeout_s(checks: &[&Resolved]) -> u32 {
389    let waits: u32 = checks.iter().map(|c| c.wait).sum();
390    let count = u32::try_from(checks.len()).unwrap_or(u32::MAX);
391    waits.saturating_add(count.saturating_mul(PER_CHECK_S)).saturating_add(START_S)
392}
393
394#[cfg(test)]
395mod tests;