Skip to main content

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