1use std::collections::BTreeMap;
8
9use indexmap::IndexMap;
10use isoloom_spec::model::{self, Configuration, Protocol};
11use isoloom_spec::syntax::{self, FileState, FirewallPort, OsRef, PortName, PortProtocol, ProjectPath, Service, Step};
12
13use super::daemons::{Daemon, DaemonTable};
14use super::family::{Family, codename};
15use super::resources::{PortSpec, RepositoryKind, RepositoryPlan, Resource, RunPlan, ServicePlan, ServiceSource, Shell};
16
17#[derive(Debug, Clone, PartialEq, Eq)]
20#[non_exhaustive]
21pub struct Recipe {
22 pub os: OsRef,
24 pub family: Family,
26 pub install: Vec<Resource>,
28 pub configure: Vec<Resource>,
30 pub start: Vec<Resource>,
32 pub daemons: Vec<Daemon>,
34 pub files: BTreeMap<ProjectPath, String>,
38}
39
40impl Recipe {
41 pub fn resources(&self) -> impl Iterator<Item = &Resource> {
43 self.install.iter().chain(&self.configure).chain(&self.start)
44 }
45
46 pub fn of(c: &Configuration, ports: &[model::Port], daemons: &DaemonTable) -> Result<Recipe, Vec<Unportable>> {
54 let port = |name: &PortName| {
55 let found = ports.iter().find(|p| p.name.as_deref() == Some(name.as_str()))?;
56 Some(PortSpec {
57 port: std::num::NonZeroU16::new(found.port)?,
58 protocol: found.protocol,
59 })
60 };
61 let mut r = resolve(c.os, [&c.install, &c.configure, &c.start], &port, daemons)?;
62 r.files.clone_from(&c.files);
63 Ok(r)
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct Unportable {
70 pub step: usize,
72 pub field: Option<&'static str>,
74 pub reason: String,
76 pub fix: String,
78}
79
80pub fn recipe(os: OsRef, m: &syntax::Machine, daemons: &DaemonTable) -> Result<Recipe, Vec<Unportable>> {
86 let steps = m.app().map(|a| a.steps.as_slice()).unwrap_or_default();
87 resolve(os, [steps, &[], &[]], &|name| named_port(name, &m.ports), daemons)
88}
89
90type PortOf<'a> = dyn Fn(&PortName) -> Option<PortSpec> + 'a;
92
93fn resolve(os: OsRef, phases: [&[Step]; 3], port: &PortOf<'_>, daemons: &DaemonTable) -> Result<Recipe, Vec<Unportable>> {
96 let family = Family::from(os);
97 let all = || phases.iter().flat_map(|p| p.iter());
98 let unportable: Vec<Unportable> = all().enumerate().filter_map(|(i, s)| unportable(i, s, family)).collect();
99 if !unportable.is_empty() {
100 return Err(unportable);
101 }
102 let mut unknown = Vec::new();
103 let mut resolved: [Vec<Resource>; 3] = Default::default();
104 let mut i = 0;
105 for (phase, steps) in resolved.iter_mut().zip(phases) {
106 for step in steps {
107 match resource(step, os, port, daemons) {
108 Some(r) => phase.push(r),
109 None => unknown.push(Unportable {
110 step: i,
111 field: None,
112 reason: format!("this Isoloom can't resolve a `{}` step", step.kind()),
113 fix: "upgrade Isoloom's plan with its spec".into(),
114 }),
115 }
116 i += 1;
117 }
118 }
119 if !unknown.is_empty() {
120 return Err(unknown);
121 }
122 let daemons = all()
123 .filter_map(|s| match s {
124 Step::Service(s) if s.enabled() => foreground(s, family, daemons),
125 _ => None,
126 })
127 .collect();
128 let [install, configure, start] = resolved;
129 Ok(Recipe {
130 os,
131 family,
132 install,
133 configure,
134 start,
135 daemons,
136 files: BTreeMap::new(),
137 })
138}
139
140fn unportable(i: usize, step: &Step, family: Family) -> Option<Unportable> {
142 if family != Family::Windows {
143 return None;
144 }
145 let acl = |field| Unportable {
146 step: i,
147 field: Some(field),
148 reason: format!("a Windows file's `{field}` is an ACL, which the model sets in 1.x"),
149 fix: format!("leave `{field}` out, or set the ACL from a `.ps1` step"),
150 };
151 match step {
152 Step::Repository(_) => Some(Unportable {
153 step: i,
154 field: None,
155 reason: "Windows has no package repository Isoloom drives".into(),
156 fix: "`{ run: provision/install.ps1 }`".into(),
157 }),
158 Step::Packages(p) if p.iter().any(|p| p.version.is_some()) => Some(Unportable {
159 step: i,
160 field: None,
161 reason: "a Windows feature has no version".into(),
162 fix: "the feature's name alone (`Web-Server`)".into(),
163 }),
164 Step::Copy(c) => first_set(&[("owner", c.owner.is_some()), ("mode", c.mode.is_some())]).map(acl),
165 Step::Template(t) => first_set(&[("owner", t.owner.is_some()), ("group", t.group.is_some()), ("mode", t.mode.is_some())]).map(acl),
166 Step::File(f) if matches!(f.state, FileState::Link(_)) => Some(Unportable {
167 step: i,
168 field: Some("link"),
169 reason: "Windows links need privileges a set-up step doesn't have".into(),
170 fix: "copy the file instead, or make the link from a `.ps1` step".into(),
171 }),
172 Step::File(f) => first_set(&[("owner", f.owner.is_some()), ("group", f.group.is_some()), ("mode", f.mode.is_some())]).map(acl),
173 Step::User(u) => first_set(&[
174 ("uid", u.uid.is_some()),
175 ("home", u.home.is_some()),
176 ("shell", u.shell.is_some()),
177 ("system", u.system),
178 ])
179 .map(|field| Unportable {
180 step: i,
181 field: Some(field),
182 reason: format!("a Windows account has no `{field}`"),
183 fix: format!("leave `{field}` out on Windows"),
184 }),
185 Step::Group(g) => first_set(&[("gid", g.gid.is_some()), ("system", g.system)]).map(|field| Unportable {
186 step: i,
187 field: Some(field),
188 reason: format!("a Windows group has no `{field}`"),
189 fix: format!("leave `{field}` out on Windows"),
190 }),
191 Step::Service(Service::Defined { user: Some(_), .. }) => Some(Unportable {
192 step: i,
193 field: Some("user"),
194 reason: "a Windows service Isoloom defines runs as SYSTEM in 1.0".into(),
195 fix: "leave `user` out on Windows".into(),
196 }),
197 _ => None,
198 }
199}
200
201fn first_set(fields: &[(&'static str, bool)]) -> Option<&'static str> {
203 fields.iter().find_map(|(field, set)| set.then_some(*field))
204}
205
206fn resource(step: &Step, os: OsRef, port: &PortOf<'_>, daemons: &DaemonTable) -> Option<Resource> {
209 let family = Family::from(os);
210 let r = match step {
211 Step::Run { script, creates } => Resource::Run(RunPlan {
212 script: script.clone(),
213 creates: creates.clone(),
214 shell: if family == Family::Windows { Shell::PowerShell } else { Shell::Posix },
215 }),
216 Step::Copy(c) => Resource::Copy(c.clone()),
217 Step::Packages(p) => Resource::Packages(p.clone()),
218 Step::Reboot => Resource::Reboot,
219 Step::Playbook(p) => Resource::Playbook(p.clone()),
220 Step::Repository(r) => Resource::Repository(repository(r, os)),
221 Step::File(f) => Resource::File(f.clone()),
222 Step::Template(t) => Resource::Template(t.clone()),
223 Step::User(u) => Resource::User(u.clone()),
224 Step::Group(g) => Resource::Group(g.clone()),
225 Step::Service(s) => Resource::Service(service(s, family, daemons)),
226 Step::Command(c) => Resource::Command(c.clone()),
227 Step::Firewall(f) => Resource::Firewall(f.allow.iter().filter_map(|p| firewall_port(p, port)).collect()),
228 _ => return None,
229 };
230 Some(r)
231}
232
233fn repository(r: &syntax::Repository, os: OsRef) -> RepositoryPlan {
235 let kind = match Family::from(os) {
236 Family::RedHat => RepositoryKind::Rpm,
237 Family::Alpine => RepositoryKind::Apk,
238 Family::Debian | Family::Windows => RepositoryKind::Apt,
239 };
240 let apt = kind == RepositoryKind::Apt;
241 let suite = r.suite.clone().or_else(|| codename(os.os()).and_then(|c| c.parse().ok())).filter(|_| apt);
242 let components = match &r.components {
243 Some(c) => c.clone(),
244 None if apt => "main".parse().ok().into_iter().collect(),
245 None => Vec::new(),
246 };
247 RepositoryPlan {
248 name: r.name.clone(),
249 kind,
250 url: r.url.clone(),
251 key: r.key.clone(),
252 suite,
253 components,
254 priority: r.priority,
255 }
256}
257
258fn service(s: &Service, family: Family, daemons: &DaemonTable) -> ServicePlan {
260 match s {
261 Service::Defined { name, run, user, cwd, enabled } => ServicePlan {
262 unit: name.clone(),
263 source: ServiceSource::Defined {
264 run: run.clone(),
265 user: user.clone(),
266 cwd: cwd.clone(),
267 },
268 enabled: *enabled,
269 },
270 _ => ServicePlan {
271 unit: daemons.find(s.name(), family).map_or_else(|| s.name().clone(), |(_, f)| f.daemon.unit.clone()),
272 source: ServiceSource::Package,
273 enabled: s.enabled(),
274 },
275 }
276}
277
278fn foreground(s: &Service, family: Family, daemons: &DaemonTable) -> Option<Daemon> {
280 match s {
281 Service::Defined { name, run, user, .. } => Some(Daemon {
282 unit: name.clone(),
283 foreground: run.clone(),
284 user: user.clone(),
285 logs: Vec::new(),
286 }),
287 _ => daemons.find(s.name(), family).map(|(_, f)| f.daemon.clone()),
288 }
289}
290
291fn firewall_port(p: &FirewallPort, named: &PortOf<'_>) -> Option<PortSpec> {
294 match p {
295 FirewallPort::Named(name) => named(name),
296 FirewallPort::Number { port, protocol } => Some(PortSpec {
297 port: *port,
298 protocol: *protocol,
299 }),
300 _ => None,
301 }
302}
303
304fn named_port(name: &PortName, ports: &IndexMap<PortName, syntax::Port>) -> Option<PortSpec> {
306 let found = ports.get(name)?;
307 let protocol = if found.protocol == PortProtocol::Udp { Protocol::Udp } else { Protocol::Tcp };
308 Some(PortSpec { port: found.port?, protocol })
309}
310
311#[cfg(test)]
312mod tests;