flowey_lib_common/
install_rust.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4//! Globally install a Rust toolchain, and ensure those tools are available on
5//! the user's $PATH
6
7use flowey::node::prelude::*;
8use std::collections::BTreeSet;
9use std::io::Write;
10
11new_flow_node!(struct Node);
12
13flowey_request! {
14    pub enum Request {
15        /// Automatically install all required Rust tools and components.
16        ///
17        /// If false - will check for pre-existing Rust installation, and fail
18        /// if it doesn't meet the current job's requirements.
19        AutoInstall(bool),
20
21        /// Ignore the Version requirement, and build using whatever version of
22        /// the Rust toolchain the user has installed locally.
23        IgnoreVersion(bool),
24
25        /// Install a specific Rust toolchain version.
26        // FUTURE: support installing / using multiple versions of the Rust
27        // toolchain at the same time, e.g: for stable and nightly, or to
28        // support regression tests between a pinned rust version and current
29        // stable.
30        Version(String),
31
32        /// Specify an additional target-triple to install the toolchain for.
33        ///
34        /// By default, only the native target will be installed.
35        InstallTargetTriple(target_lexicon::Triple),
36
37        /// If Rust was installed via Rustup, return the rustup toolchain that
38        /// was installed (e.g: when specifting `+stable` or `+nightly` to
39        /// commands)
40        GetRustupToolchain(WriteVar<Option<String>>),
41
42        /// Install the specified component.
43        InstallComponent(String),
44
45        /// Get the path to $CARGO_HOME
46        GetCargoHome(WriteVar<PathBuf>),
47
48        /// Ensure that Rust was installed and is available on the $PATH
49        EnsureInstalled(WriteVar<SideEffect>),
50    }
51}
52
53impl FlowNode for Node {
54    type Request = Request;
55
56    fn imports(dep: &mut ImportCtx<'_>) {
57        dep.import::<crate::check_needs_relaunch::Node>();
58    }
59
60    fn emit(requests: Vec<Self::Request>, ctx: &mut NodeCtx<'_>) -> anyhow::Result<()> {
61        let mut ensure_installed = Vec::new();
62        let mut rust_toolchain = None;
63        let mut auto_install = None;
64        let mut ignore_version = None;
65        let mut additional_target_triples = BTreeSet::new();
66        let mut additional_components = BTreeSet::new();
67        let mut get_rust_toolchain = Vec::new();
68        let mut get_cargo_home = Vec::new();
69
70        for req in requests {
71            match req {
72                Request::EnsureInstalled(v) => ensure_installed.push(v),
73                Request::AutoInstall(v) => {
74                    same_across_all_reqs("AutoInstall", &mut auto_install, v)?
75                }
76                Request::IgnoreVersion(v) => {
77                    same_across_all_reqs("IgnoreVersion", &mut ignore_version, v)?
78                }
79                Request::Version(v) => same_across_all_reqs("Version", &mut rust_toolchain, v)?,
80                Request::InstallTargetTriple(s) => {
81                    additional_target_triples.insert(s.to_string());
82                }
83                Request::InstallComponent(v) => {
84                    additional_components.insert(v);
85                }
86                Request::GetRustupToolchain(v) => get_rust_toolchain.push(v),
87                Request::GetCargoHome(v) => get_cargo_home.push(v),
88            }
89        }
90
91        let ensure_installed = ensure_installed;
92        let auto_install =
93            auto_install.ok_or(anyhow::anyhow!("Missing essential request: AutoInstall",))?;
94        if !auto_install && matches!(ctx.backend(), FlowBackend::Github) {
95            anyhow::bail!("`AutoInstall` must be true when using the Github backend");
96        }
97        let ignore_version =
98            ignore_version.ok_or(anyhow::anyhow!("Missing essential request: IgnoreVersion",))?;
99        if ignore_version && matches!(ctx.backend(), FlowBackend::Github) {
100            anyhow::bail!("`IgnoreVersion` must be false when using the Github backend");
101        }
102        let rust_toolchain =
103            rust_toolchain.ok_or(anyhow::anyhow!("Missing essential request: RustToolchain"))?;
104        let additional_target_triples = additional_target_triples;
105        let additional_components = additional_components;
106        let get_rust_toolchain = get_rust_toolchain;
107        let get_cargo_home = get_cargo_home;
108
109        // -- end of req processing -- //
110
111        let rust_toolchain = (!ignore_version).then_some(rust_toolchain);
112
113        let check_rust_install = {
114            let rust_toolchain = rust_toolchain.clone();
115            let additional_target_triples = additional_target_triples.clone();
116            let additional_components = additional_components.clone();
117
118            move |_: &mut RustRuntimeServices<'_>| {
119                if which::which("cargo").is_err() {
120                    anyhow::bail!("did not find `cargo` on $PATH");
121                }
122
123                let rust_toolchain = rust_toolchain.map(|s| format!("+{s}"));
124                let rust_toolchain = rust_toolchain.as_ref();
125
126                // make sure the specific rust version was installed
127                let sh = xshell::Shell::new()?;
128                xshell::cmd!(sh, "rustc {rust_toolchain...} -vV").run()?;
129
130                // make sure the additional target triples were installed
131                if let Ok(rustup) = which::which("rustup") {
132                    for (thing, expected_things) in [
133                        ("target", &additional_target_triples),
134                        ("component", &additional_components),
135                    ] {
136                        let output = xshell::cmd!(
137                            sh,
138                            "{rustup} {rust_toolchain...} {thing} list --installed"
139                        )
140                        .ignore_status()
141                        .output()?;
142                        let stderr = String::from_utf8(output.stderr)?;
143                        let stdout = String::from_utf8(output.stdout)?;
144
145                        // This error message may occur if the user has rustup
146                        // installed, but is using a custom custom toolchain.
147                        //
148                        // NOTE: not thrilled that we are sniffing a magic string
149                        // from stderr... but I'm also not sure if there's a better
150                        // way to detect this...
151                        if stderr.contains("does not support components") {
152                            log::warn!("Detected a non-standard `rustup default` toolchain!");
153                            log::warn!(
154                                "Will not be able to double-check that all required target-triples and components are available."
155                            );
156                        } else {
157                            let mut installed_things = BTreeSet::new();
158
159                            for line in stdout.lines() {
160                                let triple = line.trim();
161                                installed_things.insert(triple);
162                            }
163
164                            for expected_thing in expected_things {
165                                if !installed_things.contains(expected_thing.as_str()) {
166                                    anyhow::bail!(
167                                        "missing required {thing}: {expected_thing}; to install: `rustup {thing} add {expected_thing}`"
168                                    )
169                                }
170                            }
171                        }
172                    }
173                } else {
174                    log::warn!("`rustup` was not found!");
175                    log::warn!(
176                        "Unable to double-check that all target-triples and components are available."
177                    )
178                }
179
180                anyhow::Ok(())
181            }
182        };
183
184        let check_is_installed = |write_cargo_bin: Option<
185            WriteVar<Option<crate::check_needs_relaunch::BinOrEnv>>,
186        >,
187                                  ensure_installed: Vec<WriteVar<SideEffect>>,
188                                  auto_install: bool,
189                                  ctx: &mut NodeCtx<'_>| {
190            if write_cargo_bin.is_some() || !ensure_installed.is_empty() {
191                if auto_install || matches!(ctx.backend(), FlowBackend::Github) {
192                    let added_to_path = if matches!(ctx.backend(), FlowBackend::Github) {
193                        Some(ctx.emit_rust_step("add default cargo home to path", |_| {
194                            |_| {
195                                let default_cargo_home = home::home_dir()
196                                    .context("Unable to get home dir")?
197                                    .join(".cargo")
198                                    .join("bin");
199                                let github_path = std::env::var("GITHUB_PATH")?;
200                                let mut github_path =
201                                    fs_err::File::options().append(true).open(github_path)?;
202                                github_path
203                                    .write_all(default_cargo_home.as_os_str().as_encoded_bytes())?;
204                                log::info!("Added {} to PATH", default_cargo_home.display());
205                                Ok(())
206                            }
207                        }))
208                    } else {
209                        None
210                    };
211
212                    let rust_toolchain = rust_toolchain.clone();
213                    ctx.emit_rust_step("install Rust", |ctx| {
214                        let write_cargo_bin = if let Some(write_cargo_bin) = write_cargo_bin {
215                            Some(write_cargo_bin.claim(ctx))
216                        } else {
217                            ensure_installed.claim(ctx);
218                            None
219                        };
220                        added_to_path.claim(ctx);
221
222                        move |rt: &mut RustRuntimeServices<'_>| {
223                            if let Some(write_cargo_bin) = write_cargo_bin {
224                                rt.write(write_cargo_bin, &Some(crate::check_needs_relaunch::BinOrEnv::Bin("cargo".to_string())));
225                            }
226                            let rust_toolchain = rust_toolchain.clone();
227                            if check_rust_install.clone()(rt).is_ok() {
228                                return Ok(());
229                            }
230
231                            let sh = xshell::Shell::new()?;
232                            match rt.platform() {
233                                FlowPlatform::Linux(_) => {
234                                    let interactive_prompt = Some("-y");
235                                    let mut default_toolchain = Vec::new();
236                                    if let Some(ver) = rust_toolchain {
237                                        default_toolchain.push("--default-toolchain".into());
238                                        default_toolchain.push(ver)
239                                    };
240
241                                    xshell::cmd!(
242                                        sh,
243                                        "curl --fail --proto =https --tlsv1.2 -sSf https://sh.rustup.rs -o rustup-init.sh"
244                                    )
245                                    .run()?;
246                                    xshell::cmd!(sh, "chmod +x ./rustup-init.sh").run()?;
247                                    xshell::cmd!(
248                                        sh,
249                                        "./rustup-init.sh {interactive_prompt...} {default_toolchain...}"
250                                    )
251                                    .run()?;
252                                }
253                                FlowPlatform::Windows => {
254                                    let interactive_prompt = Some("-y");
255                                    let mut default_toolchain = Vec::new();
256                                    if let Some(ver) = rust_toolchain {
257                                        default_toolchain.push("--default-toolchain".into());
258                                        default_toolchain.push(ver)
259                                    };
260
261                                    let arch = match rt.arch() {
262                                        FlowArch::X86_64 => "x86_64",
263                                        FlowArch::Aarch64 => "aarch64",
264                                        arch => anyhow::bail!("unsupported arch {arch}"),
265                                    };
266
267                                    xshell::cmd!(
268                                        sh,
269                                        "curl --fail -sSfLo rustup-init.exe https://win.rustup.rs/{arch} --output rustup-init"
270                                    ).run()?;
271                                    xshell::cmd!(
272                                        sh,
273                                        "./rustup-init.exe {interactive_prompt...} {default_toolchain...}"
274                                    )
275                                    .run()?;
276                                },
277                                platform => anyhow::bail!("unsupported platform {platform}"),
278                            }
279
280                            if !additional_target_triples.is_empty() {
281                                xshell::cmd!(sh, "rustup target add {additional_target_triples...}")
282                                    .run()?;
283                            }
284                            if !additional_components.is_empty() {
285                                xshell::cmd!(sh, "rustup component add {additional_components...}")
286                                    .run()?;
287                            }
288
289                            Ok(())
290                        }
291                    })
292                } else if let Some(write_cargo_bin) = write_cargo_bin {
293                    ctx.emit_rust_step("ensure Rust is installed", |ctx| {
294                        let write_cargo_bin = write_cargo_bin.claim(ctx);
295                        move |rt| {
296                            rt.write(
297                                write_cargo_bin,
298                                &Some(crate::check_needs_relaunch::BinOrEnv::Bin(
299                                    "cargo".to_string(),
300                                )),
301                            );
302
303                            check_rust_install(rt)?;
304                            Ok(())
305                        }
306                    })
307                } else {
308                    ReadVar::from_static(()).into_side_effect()
309                }
310            } else {
311                ReadVar::from_static(()).into_side_effect()
312            }
313        };
314
315        // The reason we need to check for relaunch on Local but not GH Actions is that GH Actions
316        // spawns a new shell for each step, so the new shell will have the new $PATH. On the local backend,
317        // the same shell is reused and needs to be relaunched to pick up the new $PATH.
318        let is_installed =
319            if !ensure_installed.is_empty() && matches!(ctx.backend(), FlowBackend::Local) {
320                let (read_bin, write_cargo_bin) = ctx.new_var();
321                ctx.req(crate::check_needs_relaunch::Params {
322                    check: read_bin,
323                    done: ensure_installed,
324                });
325                check_is_installed(Some(write_cargo_bin), Vec::new(), auto_install, ctx)
326            } else {
327                check_is_installed(None, ensure_installed, auto_install, ctx)
328            };
329
330        if !get_rust_toolchain.is_empty() {
331            ctx.emit_rust_step("detect active toolchain", |ctx| {
332                is_installed.clone().claim(ctx);
333                let get_rust_toolchain = get_rust_toolchain.claim(ctx);
334
335                move |rt| {
336                    let rust_toolchain = match rust_toolchain {
337                        Some(toolchain) => Some(toolchain),
338                        None => {
339                            let sh = xshell::Shell::new()?;
340                            if let Ok(rustup) = which::which("rustup") {
341                                // Unfortunately, `rustup` still doesn't have any stable way to emit
342                                // machine-readable output. See https://github.com/rust-lang/rustup/issues/450
343                                //
344                                // As a result, this logic is written to work with multiple rustup
345                                // versions, both prior-to, and after 1.28.0.
346                                //
347                                // Prior to 1.28.0:
348                                //   $ rustup show active-toolchain
349                                //   stable-x86_64-unknown-linux-gnu (default)
350                                //
351                                // Starting from 1.28.0:
352                                //   $ rustup show active-toolchain
353                                //   stable-x86_64-unknown-linux-gnu
354                                //   active because: it's the default toolchain
355                                let output =
356                                    xshell::cmd!(sh, "{rustup} show active-toolchain").output()?;
357                                let stdout = String::from_utf8(output.stdout)?;
358                                let line = stdout.lines().next().unwrap();
359                                Some(line.split(' ').next().unwrap().into())
360                            } else {
361                                None
362                            }
363                        }
364                    };
365
366                    rt.write_all(get_rust_toolchain, &rust_toolchain);
367
368                    Ok(())
369                }
370            });
371        }
372
373        if !get_cargo_home.is_empty() {
374            ctx.emit_rust_step("report $CARGO_HOME", |ctx| {
375                is_installed.claim(ctx);
376                let get_cargo_home = get_cargo_home.claim(ctx);
377                move |rt| {
378                    let cargo_home = home::cargo_home()?;
379                    rt.write_all(get_cargo_home, &cargo_home);
380
381                    Ok(())
382                }
383            });
384        }
385
386        Ok(())
387    }
388}