isoloom_render/configure/
shell.rs1use 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
16pub const LIBRARY: &str = include_str!("shell/configure.sh");
18
19pub const LIBRARY_PATH: &str = ".isoloom/configure/lib/configure.sh";
21
22const CHANGED: &str = "/run/isoloom/configure.changed";
24
25#[must_use]
27pub fn library_file() -> GeneratedFile {
28 GeneratedFile {
29 path: LIBRARY_PATH.to_owned(),
30 contents: format!("{}{LIBRARY}", header("#")),
31 }
32}
33
34#[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
60fn 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
86fn 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
106fn 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#[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#[must_use]
176pub(super) fn literal_dollar(line: &str) -> bool {
177 line.contains(['$', '`'])
178}
179
180pub(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("e(a.as_ref()));
186 }
187 line
188}
189
190fn opt<T: ToString>(v: Option<&T>) -> String {
192 v.map(ToString::to_string).unwrap_or_default()
193}
194
195fn mode(m: Option<Mode>) -> String {
197 opt(m.as_ref())
198}
199
200fn yes(b: bool) -> String {
202 (if b { "yes" } else { "no" }).to_owned()
203}
204
205fn 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
227fn 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
240fn 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
259fn 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
271fn 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
282fn 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;