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, EnvironmentName, EnvironmentRef, Error as ManifestError, Generator, GeneratorName, HostArgv,
15    Machine, Name, Network, NonEmpty, Output, OutputRole, OutputSource, Param, Part, Placeholder, Port, PortKind, PortName, Program, Protocol, Publish,
16    PublishMode, Role, 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 environment `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 environment(env: &Environment) -> Result<EnvironmentRef, ManifestError> {
46    Ok(EnvironmentRef {
47        name: EnvironmentName::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        on_demand: false,
264        native: None,
265        volumes: Vec::new(),
266        ports: Vec::new(),
267    })
268}
269
270/// A Terraform state output `name` (role `role`), read by the runner as `${outputs.<name>}`.
271///
272/// # Errors
273///
274/// Fails on a name the manifest refuses.
275pub fn terraform_output(name: &str, role: OutputRole, description: &str) -> Result<Output, ManifestError> {
276    Ok(Output {
277        name: SnakeName::parse(name)?,
278        description: Some(description.to_owned()),
279        role,
280        sensitive: false,
281        format: None,
282        from: OutputSource::TerraformOutput {
283            name: isoloom_manifest::Identifier::parse(name)?,
284            default: None,
285        },
286    })
287}
288
289/// An output per published port of the lab's machines and tools: an HTTP port is an entry (the
290/// Open button), the others information; each shown at the host's loopback.
291///
292/// # Errors
293///
294/// Fails on an output name the manifest refuses.
295pub fn outputs(machines: &[Machine]) -> Result<Vec<Output>, ManifestError> {
296    outputs_at(machines, &Template::text("127.0.0.1"))
297}
298
299/// [`outputs`], each shown at `host` (a server lab's router, `${outputs.ip}`).
300///
301/// # Errors
302///
303/// Fails on an output name the manifest refuses.
304pub fn outputs_at(machines: &[Machine], host: &Template) -> Result<Vec<Output>, ManifestError> {
305    outputs_with(machines, |_| host.clone())
306}
307
308/// [`outputs`], each shown at the host the driver reports for its machine (`${host.<machine>}`):
309/// the loopback with the forwarded port, or, where the provider forwards nothing (ESXi), the
310/// machine's own address with its own port.
311///
312/// # Errors
313///
314/// Fails on an output name the manifest refuses.
315pub fn outputs_on_their_hosts(machines: &[Machine]) -> Result<Vec<Output>, ManifestError> {
316    outputs_with(machines, |m| Template::from(Placeholder::Host(m.name.clone())))
317}
318
319/// [`outputs`], machine `m`'s shown at `host(m)` (a cloud machine's own public address).
320///
321/// # Errors
322///
323/// Fails on an output name the manifest refuses.
324pub fn outputs_with(machines: &[Machine], host: impl Fn(&Machine) -> Template) -> Result<Vec<Output>, ManifestError> {
325    let shown = machines.iter().filter(|m| matches!(m.role, Role::Target | Role::Access | Role::Tool));
326    shown
327        .flat_map(|m| m.ports.iter().filter(|p| p.publish.is_some()).map(move |p| (m, p)))
328        .map(|(m, p)| output(m, p, &host(m)))
329        .collect()
330}
331
332fn output(m: &Machine, p: &Port, host: &Template) -> Result<Output, ManifestError> {
333    let snake: String = format!("{}_{}", m.name, p.name)
334        .chars()
335        .map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '_' })
336        .collect();
337    let (role, scheme, after) = match p.kind {
338        PortKind::Https => (OutputRole::Entry, "https://", "/"),
339        PortKind::Http => (OutputRole::Entry, "http://", "/"),
340        PortKind::Tcp | PortKind::Udp | PortKind::Tls => (OutputRole::Info, "", ""),
341    };
342    let port = if p.kind == PortKind::Udp {
343        format!("{}/udp", p.port)
344    } else {
345        p.port.to_string()
346    };
347    let what = format!("{}'s {} (port {port})", m.name, p.name);
348    Ok(Output {
349        name: SnakeName::parse(&snake)?,
350        description: Some(format!("{what}.")),
351        role,
352        sensitive: false,
353        format: Some(Template::from_parts(
354            std::iter::once(Part::Text(scheme.into())).chain(host.parts().iter().cloned()).chain([
355                Part::Text(":".into()),
356                Part::Placeholder(Placeholder::Value),
357                Part::Text(after.into()),
358            ]),
359        )),
360        from: OutputSource::PublishedPort {
361            machine: m.name.clone(),
362            port: p.name.clone(),
363        },
364    })
365}
366
367/// A planned check, as its runner reports it.
368#[must_use]
369pub fn check(c: &Resolved) -> Check {
370    let kind = match c.probe {
371        Probe::Http { .. } => CheckKind::Http,
372        Probe::Tcp { .. } => CheckKind::Tcp,
373        Probe::Exec { .. } => CheckKind::Exec,
374        Probe::Script { .. } => CheckKind::Script,
375        Probe::Playbook { .. } => CheckKind::Playbook,
376    };
377    Check {
378        name: c.name.clone(),
379        kind,
380        wait_s: c.wait,
381        derived: c.derived,
382    }
383}
384
385/// The time limit of a runner of `checks`, the same on every target: their waits, plus 15 s
386/// each, plus 60 s. A runner never stops before its slowest check could pass; booting what the
387/// runner runs on is not check time (a setup step's).
388#[must_use]
389pub fn timeout_s(checks: &[&Resolved]) -> u32 {
390    let waits: u32 = checks.iter().map(|c| c.wait).sum();
391    let count = u32::try_from(checks.len()).unwrap_or(u32::MAX);
392    waits.saturating_add(count.saturating_mul(PER_CHECK_S)).saturating_add(START_S)
393}
394
395#[cfg(test)]
396mod tests;