From dfb1208787dc5e026b7715d5253b0294078ca82a Mon Sep 17 00:00:00 2001 From: Ian Wahbe Date: Tue, 5 Oct 2021 16:27:25 -0700 Subject: [PATCH] feat: Add pulumi module (#3055) --- Cargo.lock | 69 +++++++- Cargo.toml | 1 + docs/config/README.md | 59 +++++++ docs/presets/README.md | 9 ++ src/configs/mod.rs | 3 + src/configs/pulumi.rs | 25 +++ src/configs/starship_root.rs | 1 + src/module.rs | 1 + src/modules/mod.rs | 3 + src/modules/pulumi.rs | 296 +++++++++++++++++++++++++++++++++++ src/utils.rs | 29 +++- 11 files changed, 489 insertions(+), 7 deletions(-) create mode 100644 src/configs/pulumi.rs create mode 100644 src/modules/pulumi.rs diff --git a/Cargo.lock b/Cargo.lock index ab87f2cf..922400bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -160,7 +160,16 @@ dependencies = [ "block-padding", "byte-tools", "byteorder", - "generic-array", + "generic-array 0.12.4", +] + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array 0.14.4", ] [[package]] @@ -301,6 +310,15 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea221b5284a47e40033bf9b66f35f984ec0ea2931eb03505246cd27a963f981b" +[[package]] +name = "cpufeatures" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95059428f66df56b63431fdb4e1947ed2190586af5c5a8a8b71122bdf5a7f469" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-channel" version = "0.5.1" @@ -368,7 +386,16 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3d0c8c8752312f9713efd397ff63acb9f85585afbf179282e720e7704954dd5" dependencies = [ - "generic-array", + "generic-array 0.12.4", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array 0.14.4", ] [[package]] @@ -630,6 +657,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "generic-array" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "501466ecc8a30d1d3b7fc9229b122b2ce8ed6e9d9223f1138d4babb253e51817" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "0.2.1" @@ -1079,6 +1116,12 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2839e79665f131bdb5782e51f2c6c9599c133c6098982a54c794358bf432529c" +[[package]] +name = "opaque-debug" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" + [[package]] name = "open" version = "2.0.1" @@ -1217,7 +1260,7 @@ checksum = "54be6e404f5317079812fc8f9f5279de376d8856929e21c184ecf6bbd692a11d" dependencies = [ "maplit", "pest", - "sha-1", + "sha-1 0.8.2", ] [[package]] @@ -1627,10 +1670,23 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7d94d0bede923b3cea61f3f1ff57ff8cdfd77b400fb8f9998949e0cf04163df" dependencies = [ - "block-buffer", - "digest", + "block-buffer 0.7.3", + "digest 0.8.1", "fake-simd", - "opaque-debug", + "opaque-debug 0.2.3", +] + +[[package]] +name = "sha-1" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99cd6713db3cf16b6c84e06321e049a9b9f699826e16096d23bbcc44d15d51a6" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if 1.0.0", + "cpufeatures", + "digest 0.9.0", + "opaque-debug 0.3.0", ] [[package]] @@ -1699,6 +1755,7 @@ dependencies = [ "semver", "serde", "serde_json", + "sha-1 0.9.8", "shadow-rs", "shell-words", "starship_module_config_derive", diff --git a/Cargo.toml b/Cargo.toml index 9c18bce0..9e911c60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,6 +69,7 @@ which = "4.2.2" shadow-rs = "0.7.1" versions = "3.0.3" strsim = "0.10.0" +sha-1 = "0.9.8" process_control = { version = "3.1.0", features = ["crossbeam-channel"] } diff --git a/docs/config/README.md b/docs/config/README.md index 68ffac0b..669a08fc 100644 --- a/docs/config/README.md +++ b/docs/config/README.md @@ -221,6 +221,7 @@ $nodejs\ $ocaml\ $perl\ $php\ +$pulumi\ $purescript\ $python\ $rlang\ @@ -2349,6 +2350,64 @@ By default the module will be shown if any of the following conditions are met: format = "via [🔹 $version](147 bold) " ``` +## Pulumi + +The `pulumi` module shows the currently selected [Pulumi Stack](https://www.pulumi.com/docs/intro/concepts/stack/) and version. + +::: tip + +By default the Pulumi version is not shown, since it takes an order of magnitude longer to load then most plugins (~70ms). +If you still want to enable it, [follow the example shown below](#with-pulumi-version). + +::: + +By default the module will be shown if any of the following conditions are met: + +- The current directory contains either `Pulumi.yaml` or `Pulumi.yml` +- A parent directory contains either `Pulumi.yaml` or `Pulumi.yml` + +### Options + +| Option | Default | Description | +| ------------------- | ------------------------------------ | ------------------------------------------------------------------------- | +| `format` | `"via [$symbol$stack]($style) "` | The format string for the module. | +| `version_format` | `"v${raw}"` | The version format. Available vars are `raw`, `major`, `minor`, & `patch` | +| `symbol` | `" "` | A format string shown before the Pulumi stack. | +| `style` | `"bold 5"` | The style for the module. | +| `disabled` | `false` | Disables the `pulumi` module. | + +### Variables + +| Variable | Example | Description | +| -------- | ---------- | ------------------------------------ | +| version | `v0.12.24` | The version of `pulumi` | +| stack | `dev` | The current Pulumi stack | +| symbol | | Mirrors the value of option `symbol` | +| style\* | | Mirrors the value of option `style` | + +\*: This variable can only be used as a part of a style string + +### Example + +#### With Pulumi Version + +```toml +# ~/.config/starship.toml + +[pulumi] +format = "[🛥 ($version )$stack]($style) " +``` + +#### Without Pulumi version + +```toml +# ~/.config/starship.toml +[pulumi] +symbol = "🛥 " +format = "[$symbol$stack]($style) " + +``` + ## PureScript The `purescript` module shows the currently installed version of [PureScript](https://www.purescript.org/) version. diff --git a/docs/presets/README.md b/docs/presets/README.md index 66e7ebe2..9ea120a5 100644 --- a/docs/presets/README.md +++ b/docs/presets/README.md @@ -205,6 +205,9 @@ format = '\[[$symbol($version)]($style)\]' [php] format = '\[[$symbol($version)]($style)\]' +[pulumi] +format = '\[[$symbol$stack]($style)\]' + [purescript] format = '\[[$symbol($version)]($style)\]' @@ -354,6 +357,9 @@ symbol = "pl " [php] symbol = "php " +[pulumi] +symbol = "pulumi " + [purescript] symbol = "purs " @@ -439,6 +445,9 @@ format = 'via [$symbol]($style)' [php] format = 'via [$symbol]($style)' +[pulumi] +format = 'via [$symbol$stack]($style)' + [purescript] format = 'via [$symbol]($style)' diff --git a/src/configs/mod.rs b/src/configs/mod.rs index 733c8ae3..80d47cac 100644 --- a/src/configs/mod.rs +++ b/src/configs/mod.rs @@ -48,6 +48,7 @@ pub mod openstack; pub mod package; pub mod perl; pub mod php; +pub mod pulumi; pub mod purescript; pub mod python; pub mod red; @@ -125,6 +126,7 @@ pub struct FullConfig<'a> { package: package::PackageConfig<'a>, perl: perl::PerlConfig<'a>, php: php::PhpConfig<'a>, + pulumi: pulumi::PulumiConfig<'a>, purescript: purescript::PureScriptConfig<'a>, python: python::PythonConfig<'a>, red: red::RedConfig<'a>, @@ -200,6 +202,7 @@ impl<'a> Default for FullConfig<'a> { package: Default::default(), perl: Default::default(), php: Default::default(), + pulumi: Default::default(), purescript: Default::default(), python: Default::default(), red: Default::default(), diff --git a/src/configs/pulumi.rs b/src/configs/pulumi.rs new file mode 100644 index 00000000..5a796dd7 --- /dev/null +++ b/src/configs/pulumi.rs @@ -0,0 +1,25 @@ +use crate::config::ModuleConfig; + +use serde::Serialize; +use starship_module_config_derive::ModuleConfig; + +#[derive(Clone, ModuleConfig, Serialize)] +pub struct PulumiConfig<'a> { + pub format: &'a str, + pub version_format: &'a str, + pub symbol: &'a str, + pub style: &'a str, + pub disabled: bool, +} + +impl<'a> Default for PulumiConfig<'a> { + fn default() -> Self { + PulumiConfig { + format: "via [$symbol$stack]($style) ", + version_format: "v${raw}", + symbol: " ", + style: "bold 5", + disabled: false, + } + } +} diff --git a/src/configs/starship_root.rs b/src/configs/starship_root.rs index dfbf8722..04378f65 100644 --- a/src/configs/starship_root.rs +++ b/src/configs/starship_root.rs @@ -53,6 +53,7 @@ pub const PROMPT_ORDER: &[&str] = &[ "ocaml", "perl", "php", + "pulumi", "purescript", "python", "rlang", diff --git a/src/module.rs b/src/module.rs index 2c88b553..c886b6a3 100644 --- a/src/module.rs +++ b/src/module.rs @@ -53,6 +53,7 @@ pub const ALL_MODULES: &[&str] = &[ "package", "perl", "php", + "pulumi", "purescript", "python", "red", diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 100b05a2..61640f78 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -43,6 +43,7 @@ mod openstack; mod package; mod perl; mod php; +mod pulumi; mod purescript; mod python; mod red; @@ -125,6 +126,7 @@ pub fn handle<'a>(module: &str, context: &'a Context) -> Option> { "package" => package::module(context), "perl" => perl::module(context), "php" => php::module(context), + "pulumi" => pulumi::module(context), "purescript" => purescript::module(context), "python" => python::module(context), "rlang" => rlang::module(context), @@ -212,6 +214,7 @@ pub fn description(module: &str) -> &'static str { "package" => "The package version of the current directory's project", "perl" => "The currently installed version of Perl", "php" => "The currently installed version of PHP", + "pulumi" => "The current stack and installed version of Pulumi", "purescript" => "The currently installed version of PureScript", "python" => "The currently installed version of Python", "red" => "The currently installed version of Red", diff --git a/src/modules/pulumi.rs b/src/modules/pulumi.rs new file mode 100644 index 00000000..78b7567f --- /dev/null +++ b/src/modules/pulumi.rs @@ -0,0 +1,296 @@ +#![warn(missing_docs)] +use sha1::{Digest, Sha1}; +use std::ffi::OsStr; +use std::fs::File; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use yaml_rust::{Yaml, YamlLoader}; + +use super::{Context, Module, RootModuleConfig}; +use crate::configs::pulumi::PulumiConfig; +use crate::formatter::{StringFormatter, VersionFormatter}; + +static PULUMI_HOME: &str = "PULUMI_HOME"; + +/// Creates a module with the current Pulumi version and stack name. +pub fn module<'a>(context: &'a Context) -> Option> { + let mut module = context.new_module("pulumi"); + let config = PulumiConfig::try_load(module.config); + + let project_file = find_package_file(&context.logical_dir)?; + + let parsed = StringFormatter::new(config.format).and_then(|formatter| { + formatter + .map_meta(|variable, _| match variable { + "symbol" => Some(config.symbol), + _ => None, + }) + .map_style(|variable| match variable { + "style" => Some(Ok(config.style)), + _ => None, + }) + .map(|variable| match variable { + "version" => { + let stdout = context.exec_cmd("pulumi", &["version"])?.stdout; + VersionFormatter::format_module_version( + module.get_name(), + parse_version(&stdout), + config.version_format, + ) + } + .map(Ok), + "stack" => stack_name(&project_file, context).map(Ok), + _ => None, + }) + .parse(None) + }); + + match parsed { + Ok(x) => { + module.set_segments(x); + Some(module) + } + Err(e) => { + log::warn!("Error in module `pulumi`:\n{}", e); + None + } + } +} + +/// Parse the output of `pulumi version` into just the version string. +/// +/// Normally, this just means returning it. When Pulumi is being developed, it +/// can return results like `3.12.0-alpha.1630554544+f89e9a29.dirty`, which we +/// don't want to see. Instead we display that as `3.12.0-alpha`. +fn parse_version(version: &str) -> &str { + let mut periods = 0; + for (i, c) in version.as_bytes().iter().enumerate() { + if *c == b'.' { + if periods == 2 { + return &version[0..i]; + } else { + periods += 1; + } + } + } + // We didn't hit 3 periods, so we just return the whole string. + version +} + +/// Find a file describing a Pulumi package in the current directory (or any parrent directory). +fn find_package_file(path: &Path) -> Option { + for path in path.ancestors() { + log::trace!("Looking for package file in {:?}", path); + let dir = std::fs::read_dir(path).ok()?; + let goal = dir.filter_map(Result::ok).find(|path| { + path.file_name() == OsStr::new("Pulumi.yaml") + || path.file_name() == OsStr::new("Pulumi.yml") + }); + if let Some(goal) = goal { + return Some(goal.path()); + } + } + log::trace!("Did not find a Pulumi package file"); + None +} + +/// We get the name of the current stack. +/// +/// Pulumi has no CLI option that is fast enough to get this for us, but finding +/// the location is simple. We get it ourselves. +fn stack_name(project_file: &Path, context: &Context) -> Option { + let mut file = File::open(&project_file).ok()?; + + let mut contents = String::new(); + file.read_to_string(&mut contents).ok()?; + let name = YamlLoader::load_from_str(&contents).ok().and_then( + |yaml| -> Option> { + log::trace!("Parsed {:?} into yaml", project_file); + let yaml = yaml.into_iter().next()?; + yaml.into_hash().map(|mut hash| -> Option { + hash.remove(&Yaml::String("name".to_string()))? + .into_string() + }) + }, + )??; + log::trace!("Found project name: {:?}", name); + + let workspace_file = get_pulumi_workspace(context, &name, project_file) + .map(File::open)? + .ok()?; + log::trace!("Trying to read workspace_file: {:?}", workspace_file); + let workspace: serde_json::Value = match serde_json::from_reader(workspace_file) { + Ok(k) => k, + Err(e) => { + log::debug!("Failed to parse workspace file: {}", e); + return None; + } + }; + log::trace!("Read workspace_file: {:?}", workspace); + workspace + .as_object()? + .get("stack")? + .as_str() + .map(ToString::to_string) +} + +/// Calculates the path of the workspace settings file for a given pulumi stack. +fn get_pulumi_workspace(context: &Context, name: &str, project_file: &Path) -> Option { + let project_file = if cfg!(test) { + // Because this depends on the absolute path of the file, it changes in + // each test run. We thus mock it. + "test".to_string() + } else { + let mut hasher = Sha1::new(); + hasher.update(project_file.to_str()?.as_bytes()); + crate::utils::encode_to_hex(&hasher.finalize().to_vec()) + }; + let unique_file_name = format!("{}-{}-workspace.json", name, project_file); + let mut path = pulumi_home_dir(context)?; + path.push("workspaces"); + path.push(unique_file_name); + Some(path) +} + +/// Get the Pulumi home directory. We first check `PULUMI_HOME`. If that isn't +/// set, we return `$HOME/.pulumi`. +fn pulumi_home_dir(context: &Context) -> Option { + if let Some(k) = context.get_env(PULUMI_HOME) { + std::path::PathBuf::from_str(&k).ok() + } else { + context.get_home().map(|p| p.join(".pulumi")) + } +} + +#[cfg(test)] +mod tests { + use std::io; + + use super::*; + use crate::test::ModuleRenderer; + use ansi_term::Color; + use clap::ArgMatches; + + #[test] + fn pulumi_version_release() { + let input = "3.12.0"; + assert_eq!(parse_version(input), input); + } + + #[test] + fn pulumi_version_prerelease() { + let input = "3.12.0-alpha"; + assert_eq!(parse_version(input), input); + } + + #[test] + fn pulumi_version_dirty() { + let input = "3.12.0-alpha.1630554544+f89e9a29.dirty"; + assert_eq!(parse_version(input), "3.12.0-alpha"); + } + + #[test] + fn get_home_dir() { + let mut context = Context::new(ArgMatches::default()); + context.env.insert("HOME", "/home/sweet/home".to_string()); + assert_eq!( + pulumi_home_dir(&context), + Some(PathBuf::from("/home/sweet/home/.pulumi")) + ); + context.env.insert("PULUMI_HOME", "/a/dir".to_string()); + assert_eq!(pulumi_home_dir(&context), Some(PathBuf::from("/a/dir"))) + } + + #[test] + fn test_get_pulumi_workspace() { + let mut context = Context::new(ArgMatches::default()); + context.env.insert("HOME", "/home/sweet/home".to_string()); + let name = "foobar"; + let project_file = PathBuf::from("/hello/Pulumi.yaml"); + assert_eq!( + get_pulumi_workspace(&context, name, &project_file), + Some("/home/sweet/home/.pulumi/workspaces/foobar-test-workspace.json") + .map(PathBuf::from) + ); + } + + #[test] + fn version_render() -> io::Result<()> { + let dir = tempfile::tempdir()?; + let pulumi_file = File::create(dir.path().join("Pulumi.yaml"))?; + pulumi_file.sync_all()?; + let rendered = ModuleRenderer::new("pulumi") + .path(dir.path()) + .config(toml::toml! { + [pulumi] + format = "with [$version]($style) " + }) + .collect(); + dir.close()?; + let expected = format!("with {} ", Color::Fixed(5).bold().paint("v1.2.3-ver")); + + assert_eq!(expected, rendered.expect("a result")); + Ok(()) + } + + #[test] + /// This test confirms a full render. This means finding a Pulumi.yml file, + /// tracing back to the backing workspace settings file, and printing the + /// stack name. + fn render_valid_paths() -> io::Result<()> { + use io::Write; + let dir = tempfile::tempdir()?; + let root = std::fs::canonicalize(dir.path())?; + let mut yaml = File::create(root.join("Pulumi.yml"))?; + yaml.write_all("name: starship\nruntime: nodejs\ndescription: A thing\n".as_bytes())?; + yaml.sync_all()?; + + let workspace_path = root.join(".pulumi").join("workspaces"); + let _ = std::fs::create_dir_all(&workspace_path)?; + let workspace_path = &workspace_path.join("starship-test-workspace.json"); + let mut workspace = File::create(&workspace_path)?; + serde_json::to_writer_pretty( + &mut workspace, + &serde_json::json!( + { + "stack": "launch" + } + ), + )?; + workspace.sync_all()?; + let rendered = ModuleRenderer::new("pulumi") + .path(root.clone()) + .logical_path(root.clone()) + .config(toml::toml! { + [pulumi] + format = "in [$symbol($stack)]($style) " + }) + .env("HOME", root.to_str().unwrap()) + .collect(); + let expected = format!("in {} ", Color::Fixed(5).bold().paint(" launch")); + assert_eq!(expected, rendered.expect("a result")); + dir.close()?; + Ok(()) + } + + #[test] + fn empty_config_file() -> io::Result<()> { + let dir = tempfile::tempdir()?; + let yaml = File::create(dir.path().join("Pulumi.yaml"))?; + yaml.sync_all()?; + + let rendered = ModuleRenderer::new("pulumi") + .path(dir.path()) + .logical_path(dir.path()) + .config(toml::toml! { + [pulumi] + format = "in [$symbol($stack)]($style) " + }) + .collect(); + let expected = format!("in {} ", Color::Fixed(5).bold().paint(" ")); + assert_eq!(expected, rendered.expect("a result")); + dir.close()?; + Ok(()) + } +} diff --git a/src/utils.rs b/src/utils.rs index 910bf9be..4a41c7dc 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -224,7 +224,11 @@ active boot switches: -d:release\n", stdout: String::from("7.3.8"), stderr: String::default(), }) - } + }, + "pulumi version" => Some(CommandOutput{ + stdout: String::from("1.2.3-ver.1631311768+e696fb6c"), + stderr: String::default(), + }), "purs --version" => Some(CommandOutput { stdout: String::from("0.13.5\n"), stderr: String::default(), @@ -499,6 +503,21 @@ pub fn home_dir() -> Option { directories_next::BaseDirs::new().map(|base_dirs| base_dirs.home_dir().to_owned()) } +const HEXTABLE: &[char] = &[ + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', +]; + +/// Encode a u8 slice into a hexadecimal string. +pub fn encode_to_hex(slice: &[u8]) -> String { + // let mut j = 0; + let mut dst = Vec::with_capacity(slice.len() * 2); + for &v in slice { + dst.push(HEXTABLE[(v >> 4) as usize] as u8); + dst.push(HEXTABLE[(v & 0x0f) as usize] as u8); + } + String::from_utf8(dst).unwrap() +} + #[cfg(test)] mod tests { use super::*; @@ -709,4 +728,12 @@ mod tests { }; assert_eq!(get_command_string_output(case2), "stderr"); } + + #[test] + fn sha1_hex() { + assert_eq!( + encode_to_hex(&[8, 13, 9, 189, 129, 94]), + "080d09bd815e".to_string() + ); + } }