Skip to main content

isoloom_render/configure/
shell.rs

1//! The shell rendering of a recipe (design §2.1–§2.2): the POSIX library (`configure.sh`, one
2//! function per step kind, for apt, dnf and apk, systemd and OpenRC), and one library call per
3//! step. A Linux VM's set-up runs the calls after the library; a generated Dockerfile `RUN`s each
4//! call after sourcing it; cloud-init carries the VM's script. Every argument is quoted.
5
6use std::collections::BTreeSet;
7
8use isoloom_plan::configure::{CommandPlan, FilePlan, RepositoryKind, RepositoryPlan, Resource, ServicePlan, ServiceSource, UserPlan};
9use isoloom_spec::shell::quote;
10use isoloom_spec::syntax::{FileState, Guard, Invocation, Mode};
11
12use super::{Configured, units};
13use crate::steps::PROJECT_DIR;
14use crate::{GeneratedFile, header};
15
16/// The library, as Isoloom ships it.
17pub const LIBRARY: &str = include_str!("shell/configure.sh");
18
19/// Where the library is generated, from the project folder.
20pub const LIBRARY_PATH: &str = ".isoloom/configure/lib/configure.sh";
21
22/// The file the restart rule reads on a VM: something changed during this apply.
23const CHANGED: &str = "/run/isoloom/configure.changed";
24
25/// The library as a generated file, at [`LIBRARY_PATH`].
26#[must_use]
27pub fn library_file() -> GeneratedFile {
28    GeneratedFile {
29        path: LIBRARY_PATH.to_owned(),
30        contents: format!("{}{LIBRARY}", header("#")),
31    }
32}
33
34/// A VM's set-up step for `resources`, a run of the recipe between its scripts and reboots: the
35/// library, then each step's call, from the project folder. `fresh`: no script, playbook or
36/// reboot ran before it in this apply, so nothing has changed yet; otherwise one may have, and
37/// the services after it restart (the rule errs on restarting).
38#[must_use]
39pub fn script(c: &Configured<'_>, resources: &[Resource], fresh: bool) -> String {
40    let calls: String = resources
41        .iter()
42        .filter_map(|r| call(c, r))
43        .map(|line| {
44            if literal_dollar(&line) {
45                format!("# shellcheck disable=SC2016\n{line}")
46            } else {
47                line
48            }
49        })
50        .collect();
51    let mark = if fresh {
52        "rm -f \"$ISOLOOM_CHANGED\"".to_owned()
53    } else {
54        "mkdir -p /run/isoloom && : >\"$ISOLOOM_CHANGED\"".to_owned()
55    };
56    let library = needed(&calls);
57    format!("(\nset -eu\n{library}ISOLOOM_CHANGED={CHANGED}\n{mark}\ncd {PROJECT_DIR}\n{calls})\n")
58}
59
60/// The library's functions `calls` need: those they call, and those these call in turn, in the
61/// library's order and without its comments. A VM's step stays small, and holds no function
62/// shellcheck would find never called.
63fn needed(calls: &str) -> String {
64    let functions = functions(&super::compact(LIBRARY));
65    let mut wanted: BTreeSet<String> = called(calls);
66    loop {
67        let more: BTreeSet<String> = functions
68            .iter()
69            .filter(|(name, _)| wanted.contains(name))
70            .flat_map(|(_, text)| called(text))
71            .collect();
72        let before = wanted.len();
73        wanted.extend(more);
74        if wanted.len() == before {
75            break;
76        }
77    }
78    functions
79        .into_iter()
80        .filter(|(name, _)| wanted.contains(name))
81        .map(|(_, text)| text)
82        .collect::<Vec<_>>()
83        .concat()
84}
85
86/// The library's functions, by name, each with its text: from its `isoloom_<name>() {` line to
87/// the `}` that closes it, both at the start of a line.
88fn functions(library: &str) -> Vec<(String, String)> {
89    let mut out: Vec<(String, String)> = Vec::new();
90    let mut open: Option<(String, String)> = None;
91    for line in library.lines() {
92        if let Some((name, text)) = open.as_mut() {
93            text.push_str(line);
94            text.push('\n');
95            if line == "}" {
96                out.push((std::mem::take(name), std::mem::take(text)));
97                open = None;
98            }
99        } else if let Some(name) = line.strip_suffix("() {").filter(|n| n.starts_with("isoloom_")) {
100            open = Some((name.to_owned(), format!("{line}\n")));
101        }
102    }
103    out
104}
105
106/// The library's functions `text` names (`isoloom_<name>`).
107fn called(text: &str) -> BTreeSet<String> {
108    let mut out = BTreeSet::new();
109    let mut rest = text;
110    while let Some(at) = rest.find("isoloom_") {
111        let tail = rest.get(at..).unwrap_or_default();
112        let len = tail
113            .find(|c: char| !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'))
114            .unwrap_or(tail.len());
115        out.insert(tail.get(..len).unwrap_or_default().to_owned());
116        rest = tail.get(len..).unwrap_or_default();
117    }
118    out
119}
120
121/// Step `r`'s library call(s), one per line, as a VM runs it (a template's text inline); `None`
122/// for a step the library doesn't run (a script, a reboot, a playbook: the set-up's own steps)
123/// or one with nothing to do (no port to open).
124#[must_use]
125pub fn call(c: &Configured<'_>, r: &Resource) -> Option<String> {
126    let line = match r {
127        Resource::Repository(p) => repository(p),
128        Resource::Packages(ps) => words(
129            "isoloom_packages",
130            ps.iter().map(|p| match &p.version {
131                Some(v) => format!("{}={v}", p.name),
132                None => p.name.to_string(),
133            }),
134        ),
135        Resource::Copy(cp) => words(
136            "isoloom_copy",
137            [cp.from.to_string(), cp.to.to_string(), opt(cp.owner.as_ref()), String::new(), mode(cp.mode)],
138        ),
139        Resource::Template(t) => {
140            let text = c.template(&t.from);
141            let mode = t.mode.unwrap_or(Mode::CONFIG);
142            match text {
143                Ok(text) => words(
144                    "isoloom_template",
145                    [
146                        t.to.to_string(),
147                        opt(t.owner.as_ref()),
148                        opt(t.group.as_ref()),
149                        mode.to_string(),
150                        isoloom_spec::shell::base64(text.as_bytes()),
151                    ],
152                ),
153                Err(why) => words("isoloom_fail", [why]),
154            }
155        }
156        Resource::File(f) => file(f),
157        Resource::User(u) => user(c, u),
158        Resource::Group(g) => words(
159            "isoloom_group",
160            [g.name.to_string(), g.gid.map(|id| id.to_string()).unwrap_or_default(), yes(g.system)],
161        ),
162        Resource::Service(s) => service(c, s),
163        Resource::Command(cmd) => command(cmd),
164        Resource::Firewall(ports) if !ports.is_empty() => words(
165            "isoloom_firewall",
166            ports.iter().map(|p| format!("{}/{}", p.port, if p.protocol.is_tcp() { "tcp" } else { "udp" })),
167        ),
168        _ => return None,
169    };
170    Some(format!("{line}\n"))
171}
172
173/// Whether a call holds a `$` or a backquote inside its single quotes (a password's hash, a
174/// command's text): literal on purpose, which shellcheck's SC2016 would question.
175#[must_use]
176pub(super) fn literal_dollar(line: &str) -> bool {
177    line.contains(['$', '`'])
178}
179
180/// `function` and its arguments, each quoted.
181pub(super) fn words<I: IntoIterator<Item = S>, S: AsRef<str>>(function: &str, args: I) -> String {
182    let mut line = function.to_owned();
183    for a in args {
184        line.push(' ');
185        line.push_str(&quote(a.as_ref()));
186    }
187    line
188}
189
190/// An optional field as an argument: empty when it isn't given.
191fn opt<T: ToString>(v: Option<&T>) -> String {
192    v.map(ToString::to_string).unwrap_or_default()
193}
194
195/// A mode as an argument: empty when it isn't given.
196fn mode(m: Option<Mode>) -> String {
197    opt(m.as_ref())
198}
199
200/// `yes` or `no`.
201fn yes(b: bool) -> String {
202    (if b { "yes" } else { "no" }).to_owned()
203}
204
205/// A repository: its kind, name, address, key and priority, then apt's suite and components.
206fn repository(p: &RepositoryPlan) -> String {
207    let kind = match p.kind {
208        RepositoryKind::Rpm => "rpm",
209        RepositoryKind::Apk => "apk",
210        _ => "apt",
211    };
212    let mut args = vec![
213        kind.to_owned(),
214        p.name.to_string(),
215        p.url.to_string(),
216        p.key.url.to_string(),
217        p.key.sha256.to_string(),
218        p.priority.map(|n| n.to_string()).unwrap_or_default(),
219    ];
220    if p.kind == RepositoryKind::Apt {
221        args.push(opt(p.suite.as_ref()));
222        args.extend(p.components.iter().map(ToString::to_string));
223    }
224    words("isoloom_repository", args)
225}
226
227/// A file's state, then its owner, group and mode.
228fn file(f: &FilePlan) -> String {
229    let mut args = vec![f.path.to_string(), opt(f.owner.as_ref()), opt(f.group.as_ref()), mode(f.mode)];
230    match &f.state {
231        FileState::Directory => args.push("directory".into()),
232        FileState::Absent => args.push("absent".into()),
233        FileState::Link(target) => args.extend(["link".into(), target.to_string()]),
234        FileState::Download { url, sha256 } => args.extend(["download".into(), url.to_string(), sha256.to_string()]),
235        _ => return words("isoloom_fail", ["this Isoloom can't render that file state"]),
236    }
237    words("isoloom_file", args)
238}
239
240/// A user, its literal password hashed for the machine.
241fn user(c: &Configured<'_>, u: &UserPlan) -> String {
242    let groups: Vec<String> = u.groups.iter().map(ToString::to_string).collect();
243    let hash = u.password.as_ref().map(|p| c.password(u.name.as_str(), p.as_str())).unwrap_or_default();
244    words(
245        "isoloom_user",
246        [
247            u.name.to_string(),
248            u.uid.map(|id| id.to_string()).unwrap_or_default(),
249            opt(u.group.as_ref()),
250            groups.join(","),
251            opt(u.home.as_ref()),
252            opt(u.shell.as_ref()),
253            yes(u.system),
254            hash,
255        ],
256    )
257}
258
259/// A service: the unit Isoloom defines first, when it defines one, then its state.
260fn service(c: &Configured<'_>, s: &ServicePlan) -> String {
261    let state = words("isoloom_service", [s.unit.to_string(), yes(s.enabled)]);
262    match &s.source {
263        ServiceSource::Defined { run, user, cwd } => {
264            let unit = units::linux(c.recipe().family, &s.unit, run, user.as_ref(), cwd.as_ref());
265            format!("{}\n{state}", words("isoloom_unit", [s.unit.to_string(), unit]))
266        }
267        _ => state,
268    }
269}
270
271/// A command behind its guard: the guard's kind, its word count and words, then the command's.
272fn command(cmd: &CommandPlan) -> String {
273    let (kind, guard) = match &cmd.guard {
274        Guard::Creates(path) => ("creates", vec![path.to_string()]),
275        Guard::Unless(check) => ("unless", argv(check)),
276        _ => return words("isoloom_fail", ["this Isoloom can't render that guard"]),
277    };
278    let args = [kind.to_owned(), guard.len().to_string()].into_iter().chain(guard).chain(argv(&cmd.run));
279    words("isoloom_command", args)
280}
281
282/// What runs an invocation: a text through `sh -c`, or the program and its arguments.
283fn argv(i: &Invocation) -> Vec<String> {
284    match i {
285        Invocation::Exec(a) => a.args().to_vec(),
286        Invocation::Shell(text) => vec!["sh".into(), "-c".into(), text.clone()],
287        _ => vec!["false".into()],
288    }
289}
290
291#[cfg(test)]
292mod tests;