Skip to main content

isoloom_render/configure/
powershell.rs

1//! The PowerShell rendering of a Windows recipe (design §2.1–§2.2): the library
2//! (`configure.ps1`, one function per step kind) and one call per step. A Windows VM's set-up
3//! runs the calls after the library. Every argument is a single-quoted literal.
4
5use isoloom_plan::configure::{CommandPlan, FilePlan, Resource, ServicePlan, ServiceSource, UserPlan};
6use isoloom_spec::shell::base64;
7use isoloom_spec::syntax::{FileState, Guard, Invocation};
8
9use super::Configured;
10use crate::powershell::quote;
11use crate::{GeneratedFile, header};
12
13/// The library, as Isoloom ships it.
14pub const LIBRARY: &str = include_str!("powershell/configure.ps1");
15
16/// Where the library is generated, from the project folder.
17pub const LIBRARY_PATH: &str = ".isoloom/configure/lib/configure.ps1";
18
19/// Where a Windows VM's project is, and where its recipe runs from.
20const PROJECT_DIR: &str = "C:\\isoloom";
21
22/// The file the restart rule reads on a Windows VM.
23const CHANGED: &str = "C:\\Windows\\Temp\\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 Windows VM's set-up step for `resources` (see [`super::shell::script`]): the library, then
35/// each step's call, from the project folder, stopping at the first error.
36#[must_use]
37pub fn script(c: &Configured<'_>, resources: &[Resource], fresh: bool) -> String {
38    let calls: String = resources.iter().filter_map(|r| call(c, r)).collect();
39    let mark = if fresh {
40        "Remove-Item -LiteralPath $env:ISOLOOM_CHANGED -Force -ErrorAction SilentlyContinue"
41    } else {
42        "Set-Content -LiteralPath $env:ISOLOOM_CHANGED -Value 'changed'"
43    };
44    let library = super::compact(LIBRARY);
45    format!("$ErrorActionPreference = 'Stop'\n{library}$env:ISOLOOM_CHANGED = '{CHANGED}'\n{mark}\nSet-Location -LiteralPath '{PROJECT_DIR}'\n{calls}")
46}
47
48/// Step `r`'s call(s), one per line; `None` for a step the library doesn't run (a script, a
49/// reboot, a playbook) or one Windows can't carry (a repository: lowering refuses it).
50#[must_use]
51pub fn call(c: &Configured<'_>, r: &Resource) -> Option<String> {
52    let line = match r {
53        Resource::Packages(ps) => format!("Install-IsoloomFeature -Name {}", list(ps.iter().map(|p| p.name.to_string()))),
54        Resource::Copy(cp) => format!("Copy-IsoloomItem -From {} -To {}", quote(cp.from.as_str()), quote(cp.to.as_str())),
55        Resource::Template(t) => match c.template(&t.from) {
56            Ok(text) => format!("Install-IsoloomTemplate -To {} -Base64 '{}'", quote(t.to.as_str()), base64(text.as_bytes())),
57            Err(why) => format!("throw {}", quote(&format!("isoloom: {why}"))),
58        },
59        Resource::File(f) => file(f)?,
60        Resource::User(u) => user(u),
61        Resource::Group(g) => format!("Add-IsoloomGroup -Name {}", quote(g.name.as_str())),
62        Resource::Service(s) => service(s),
63        Resource::Command(cmd) => command(cmd),
64        Resource::Firewall(ports) => ports
65            .iter()
66            .map(|p| {
67                format!(
68                    "Enable-IsoloomFirewallPort -Port {} -Protocol {}",
69                    p.port,
70                    if p.protocol.is_tcp() { "TCP" } else { "UDP" }
71                )
72            })
73            .collect::<Vec<_>>()
74            .join("\n"),
75        _ => return None,
76    };
77    (!line.is_empty()).then(|| format!("{line}\n"))
78}
79
80/// A PowerShell array of literals (`'a', 'b'`).
81fn list<I: IntoIterator<Item = String>>(items: I) -> String {
82    items.into_iter().map(|i| quote(&i)).collect::<Vec<_>>().join(", ")
83}
84
85/// A file's state (a link is refused on Windows: lowering reports it).
86fn file(f: &FilePlan) -> Option<String> {
87    let path = quote(f.path.as_str());
88    Some(match &f.state {
89        FileState::Directory => format!("Install-IsoloomFile -Path {path} -State directory"),
90        FileState::Absent => format!("Install-IsoloomFile -Path {path} -State absent"),
91        FileState::Download { url, sha256 } => format!(
92            "Install-IsoloomFile -Path {path} -State download -Url {} -Sha256 '{sha256}'",
93            quote(url.as_str())
94        ),
95        _ => return None,
96    })
97}
98
99/// A local user: its password (plain: Windows takes no hash) and its groups, its primary group
100/// first.
101fn user(u: &UserPlan) -> String {
102    let phrase = u.password.as_ref().map(|p| format!(" -Phrase {}", quote(p.as_str()))).unwrap_or_default();
103    let groups: Vec<String> = u.group.iter().chain(&u.groups).map(ToString::to_string).collect();
104    let groups = if groups.is_empty() {
105        String::new()
106    } else {
107        format!(" -Groups {}", list(groups))
108    };
109    format!("Add-IsoloomUser -Name {}{phrase}{groups}", quote(u.name.as_str()))
110}
111
112/// A service: a Windows service's state, or, for one Isoloom defines, its startup task's.
113fn service(s: &ServicePlan) -> String {
114    let name = quote(s.unit.as_str());
115    let enabled = if s.enabled { "$true" } else { "$false" };
116    match &s.source {
117        ServiceSource::Defined { run, cwd, .. } => {
118            let dir = cwd.as_ref().map(|d| format!(" -Directory {}", quote(d.as_str()))).unwrap_or_default();
119            format!(
120                "Register-IsoloomTask -Name {name} -Run {}{dir}\nRegister-IsoloomTaskState -Name {name} -Enabled {enabled}",
121                list(run.args().iter().cloned())
122            )
123        }
124        _ => format!("Register-IsoloomService -Name {name} -Enabled {enabled}"),
125    }
126}
127
128/// A command behind its guard.
129fn command(cmd: &CommandPlan) -> String {
130    let guard = match &cmd.guard {
131        Guard::Creates(path) => format!(" -Creates {}", quote(path.as_str())),
132        Guard::Unless(check) => format!(" -Unless {}", quote(&text(check))),
133        _ => String::new(),
134    };
135    format!("Invoke-IsoloomCommand -Run {}{guard}", quote(&text(&cmd.run)))
136}
137
138/// An invocation as PowerShell's text: a text as it is, a program and its arguments after `&`.
139fn text(i: &Invocation) -> String {
140    match i {
141        Invocation::Exec(a) => format!("& {}", a.args().iter().map(|w| quote(w)).collect::<Vec<_>>().join(" ")),
142        Invocation::Shell(t) => t.clone(),
143        _ => "throw 'this Isoloom cannot render that command'".to_owned(),
144    }
145}
146
147#[cfg(test)]
148mod tests;