From 4f46411403711a9ba0daa18353ecfe3a7a8720c6 Mon Sep 17 00:00:00 2001 From: Harald Hoyer Date: Fri, 21 Jan 2022 16:44:46 +0100 Subject: [PATCH] feat: add a container indicator (#3304) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: add mock method for absolute files Signed-off-by: Harald Hoyer * feat(module): add a container indicator module Adds a container type indicator, if inside a container, detected via the presence of some marker files. E.g. inside a podman container entered with `toolbox enter` the prompt changes to the container name and version. ``` starship on  container_rebased [$!] is 📦 v1.0.0 via 🦀 v1.56.1 ❯ toolbox enter starship on  container_rebased [$!] is 📦 v1.0.0 via 🦀 v1.56.1 ⬢ [fedora-toolbox:35] ❯ ``` Signed-off-by: Harald Hoyer --- CONTRIBUTING.md | 41 ++++++++ docs/config/README.md | 35 +++++++ src/configs/container.rs | 23 ++++ src/configs/mod.rs | 3 + src/configs/starship_root.rs | 1 + src/context.rs | 6 ++ src/module.rs | 1 + src/modules/container.rs | 196 +++++++++++++++++++++++++++++++++++ src/modules/mod.rs | 3 + src/test/mod.rs | 6 +- src/utils.rs | 24 +++++ 11 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 src/configs/container.rs create mode 100644 src/modules/container.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 710342e1..d34f0111 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,6 +70,47 @@ pub fn module<'a>(context: &'a Context) -> Option> { If using `context.exec_cmd` isn't possible, please use `crate::utils::create_command` instead of `std::process::Command::new`. +## Absolute Filenames + +To use absolute filenames in your module, use `crate::utils::context_path()` to create a `PathBuf` from an absolute pathname. +In the test environment the root directory will be replaced with a `Tempdir`, which you can get via `ModuleRenderer::root_path()`. +So, you can populate that mocked root directory with any files you want. + +```rust +use crate::utils::context_path; + +pub fn module<'a>(context: &'a Context) -> Option> { + if !context_path(context, "/run/test/testfile").exists() { + return None + } + // .. +} +``` + +```rust +#[test] +fn test_testfile() { + let renderer = ModuleRenderer::new("mymodule"); + + let root_path = renderer.root_path(); + + // This creates `$TEMPDIR/run/test/testfile` + + let mut absolute_test_file = PathBuf::from(root_path); + + absolute_test_file.push("run"); + absolute_test_file.push("test"); + std::fs::DirBuilder::new() + .recursive(true) + .create(&absolute_test_file)?; + + absolute_test_file.push("testfile"); + std::fs::File::create(&absolute_test_file)?; + + // ... +} +``` + ## Logging Debug logging in starship is done with our custom logger implementation. diff --git a/docs/config/README.md b/docs/config/README.md index 85e345ca..ad1e16c3 100644 --- a/docs/config/README.md +++ b/docs/config/README.md @@ -207,6 +207,7 @@ $docker_context\ $package\ $cmake\ $cobol\ +$container\ $dart\ $deno\ $dotnet\ @@ -672,6 +673,40 @@ This does not suppress conda's own prompt modifier, you may want to run `conda c format = "[$symbol$environment](dimmed green) " ``` +## Container + +The `container` module displays a symbol and container name, if inside a container. + + +### Options + +| Option | Default | Description | +|------------|-----------------------------------|-------------------------------------------| +| `symbol` | `"⬢"` | The symbol shown, when inside a container | +| `style` | `"bold red dimmed"` | The style for the module. | +| `format` | "[$symbol \\[$name\\]]($style) " | The format for the module. | +| `disabled` | `false` | Disables the `container` module. | + + +### Variables + +| Variable | Example | Description | +|----------|---------------------|--------------------------------------| +| name | `fedora-toolbox:35` | The name of the container | +| 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 + +```toml +# ~/.config/starship.toml + +[container] +format = "[$symbol \\[$name\\]]($style) " +``` + ## Crystal The `crystal` module shows the currently installed version of [Crystal](https://crystal-lang.org/). diff --git a/src/configs/container.rs b/src/configs/container.rs new file mode 100644 index 00000000..107fdd28 --- /dev/null +++ b/src/configs/container.rs @@ -0,0 +1,23 @@ +use crate::config::ModuleConfig; + +use serde::Serialize; +use starship_module_config_derive::ModuleConfig; + +#[derive(Clone, ModuleConfig, Serialize)] +pub struct ContainerConfig<'a> { + pub format: &'a str, + pub symbol: &'a str, + pub style: &'a str, + pub disabled: bool, +} + +impl<'a> Default for ContainerConfig<'a> { + fn default() -> Self { + ContainerConfig { + format: "[$symbol \\[$name\\]]($style) ", + symbol: "⬢", + style: "red bold dimmed", + disabled: false, + } + } +} diff --git a/src/configs/mod.rs b/src/configs/mod.rs index 32130dc7..252f9c7b 100644 --- a/src/configs/mod.rs +++ b/src/configs/mod.rs @@ -11,6 +11,7 @@ pub mod cmake; pub mod cmd_duration; pub mod cobol; pub mod conda; +pub mod container; pub mod crystal; pub mod custom; pub mod dart; @@ -93,6 +94,7 @@ pub struct FullConfig<'a> { cmd_duration: cmd_duration::CmdDurationConfig<'a>, cobol: cobol::CobolConfig<'a>, conda: conda::CondaConfig<'a>, + container: container::ContainerConfig<'a>, crystal: crystal::CrystalConfig<'a>, dart: dart::DartConfig<'a>, deno: deno::DenoConfig<'a>, @@ -172,6 +174,7 @@ impl<'a> Default for FullConfig<'a> { cmd_duration: Default::default(), cobol: Default::default(), conda: Default::default(), + container: Default::default(), crystal: Default::default(), dart: Default::default(), deno: Default::default(), diff --git a/src/configs/starship_root.rs b/src/configs/starship_root.rs index 2a056797..b5ea06af 100644 --- a/src/configs/starship_root.rs +++ b/src/configs/starship_root.rs @@ -86,6 +86,7 @@ pub const PROMPT_ORDER: &[&str] = &[ "battery", "time", "status", + "container", "shell", "character", ]; diff --git a/src/context.rs b/src/context.rs index 8f9493e1..24a10dbb 100644 --- a/src/context.rs +++ b/src/context.rs @@ -63,6 +63,10 @@ pub struct Context<'a> { #[cfg(test)] pub cmd: HashMap<&'a str, Option>, + /// a mock of the root directory + #[cfg(test)] + pub root_dir: tempfile::TempDir, + #[cfg(feature = "battery")] pub battery_info_provider: &'a (dyn crate::modules::BatteryInfoProvider + Send + Sync), @@ -155,6 +159,8 @@ impl<'a> Context<'a> { target, width, #[cfg(test)] + root_dir: tempfile::TempDir::new().unwrap(), + #[cfg(test)] env: HashMap::new(), #[cfg(test)] cmd: HashMap::new(), diff --git a/src/module.rs b/src/module.rs index f3b24197..925e569c 100644 --- a/src/module.rs +++ b/src/module.rs @@ -17,6 +17,7 @@ pub const ALL_MODULES: &[&str] = &[ "cmd_duration", "cobol", "conda", + "container", "crystal", "dart", "deno", diff --git a/src/modules/container.rs b/src/modules/container.rs new file mode 100644 index 00000000..ffe71ce0 --- /dev/null +++ b/src/modules/container.rs @@ -0,0 +1,196 @@ +use super::{Context, Module}; + +#[cfg(not(target_os = "linux"))] +pub fn module<'a>(_context: &'a Context) -> Option> { + None +} + +#[cfg(target_os = "linux")] +pub fn module<'a>(context: &'a Context) -> Option> { + use super::RootModuleConfig; + use crate::configs::container::ContainerConfig; + use crate::formatter::StringFormatter; + use crate::utils::read_file; + + pub fn container_name(context: &Context) -> Option { + use crate::utils::context_path; + + if context_path(context, "/proc/vz").exists() && !context_path(context, "/proc/bc").exists() + { + // OpenVZ + return Some("OpenVZ".into()); + } + + if context_path(context, "/run/host/container-manager").exists() { + // OCI + return Some("OCI".into()); + } + + if context_path(context, "/run/systemd/container").exists() { + // systemd + return Some("Systemd".into()); + } + + let container_env_path = context_path(context, "/run/.containerenv"); + + if container_env_path.exists() { + // podman and others + + let image_res = read_file(container_env_path) + .map(|s| { + s.lines() + .find_map(|l| { + l.starts_with("image=\"").then(|| { + let r = l.split_at(7).1; + let name = r.rfind('/').map(|n| r.split_at(n + 1).1); + String::from(name.unwrap_or(r).trim_end_matches('"')) + }) + }) + .unwrap_or_else(|| "podman".into()) + }) + .unwrap_or_else(|_| "podman".into()); + + return Some(image_res); + } + + if context_path(context, "/.dockerenv").exists() { + // docker + return Some("Docker".into()); + } + + None + } + + let mut module = context.new_module("container"); + let config: ContainerConfig = ContainerConfig::try_load(module.config); + + if config.disabled { + return None; + } + + let container_name = container_name(context)?; + + 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 { + "name" => Some(Ok(&container_name)), + _ => None, + }) + .parse(None, Some(context)) + }); + + module.set_segments(match parsed { + Ok(segments) => segments, + Err(error) => { + log::warn!("Error in module `container`: \n{}", error); + return None; + } + }); + + Some(module) +} + +#[cfg(test)] +mod tests { + use crate::test::ModuleRenderer; + use ansi_term::Color; + use std::path::PathBuf; + + #[test] + fn test_none_if_disabled() { + let expected = None; + let actual = ModuleRenderer::new("container") + // For a custom config + .config(toml::toml! { + [container] + disabled = true + }) + // Run the module and collect the output + .collect(); + + assert_eq!(expected, actual); + } + + fn containerenv(name: Option<&str>) -> std::io::Result<(Option, Option)> { + use std::io::Write; + + let renderer = ModuleRenderer::new("container") + // For a custom config + .config(toml::toml! { + [container] + disabled = false + }); + + let root_path = renderer.root_path(); + + let mut containerenv = PathBuf::from(root_path); + + containerenv.push("run"); + std::fs::DirBuilder::new() + .recursive(true) + .create(&containerenv)?; + + containerenv.push(".containerenv"); + let mut file = std::fs::File::create(&containerenv)?; + if let Some(name) = name { + file.write_all(format!("image=\"{}\"\n", name).as_bytes())?; + } + + // The output of the module + let actual = renderer + // Run the module and collect the output + .collect(); + + // The value that should be rendered by the module. + let expected = Some(format!( + "{} ", + Color::Red + .bold() + .dimmed() + .paint(format!("⬢ [{}]", name.unwrap_or("podman"))) + )); + + Ok((actual, expected)) + } + + #[test] + #[cfg(target_os = "linux")] + fn test_containerenv() -> std::io::Result<()> { + let (actual, expected) = containerenv(None)?; + + // Assert that the actual and expected values are the same + assert_eq!(actual, expected); + + Ok(()) + } + + #[test] + #[cfg(target_os = "linux")] + fn test_containerenv_fedora() -> std::io::Result<()> { + let (actual, expected) = containerenv(Some("fedora-toolbox:35"))?; + + // Assert that the actual and expected values are the same + assert_eq!(actual, expected); + + Ok(()) + } + + #[test] + #[cfg(not(target_os = "linux"))] + fn test_containerenv() -> std::io::Result<()> { + let (actual, expected) = containerenv(None)?; + + // Assert that the actual and expected values are not the same + assert_ne!(actual, expected); + + Ok(()) + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 8d25c63f..fa79f449 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -6,6 +6,7 @@ mod cmake; mod cmd_duration; mod cobol; mod conda; +mod container; mod crystal; pub(crate) mod custom; mod dart; @@ -93,6 +94,7 @@ pub fn handle<'a>(module: &str, context: &'a Context) -> Option> { "cmd_duration" => cmd_duration::module(context), "cobol" => cobol::module(context), "conda" => conda::module(context), + "container" => container::module(context), "dart" => dart::module(context), "deno" => deno::module(context), "directory" => directory::module(context), @@ -182,6 +184,7 @@ pub fn description(module: &str) -> &'static str { "cmd_duration" => "How long the last command took to execute", "cobol" => "The currently installed version of COBOL/GNUCOBOL", "conda" => "The current conda environment, if $CONDA_DEFAULT_ENV is set", + "container" => "The container indicator, if inside a container.", "crystal" => "The currently installed version of Crystal", "dart" => "The currently installed version of Dart", "deno" => "The currently installed version of Deno", diff --git a/src/test/mod.rs b/src/test/mod.rs index 1a47d1da..1cada5f4 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -8,7 +8,7 @@ use crate::{ use log::{Level, LevelFilter}; use once_cell::sync::Lazy; use std::io; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use tempfile::TempDir; static FIXTURE_DIR: Lazy = @@ -70,6 +70,10 @@ impl<'a> ModuleRenderer<'a> { self } + pub fn root_path(&self) -> &Path { + self.context.root_dir.path() + } + pub fn logical_path(mut self, path: T) -> Self where T: Into, diff --git a/src/utils.rs b/src/utils.rs index f75546ee..6ef8299c 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -7,8 +7,32 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; +use crate::context::Context; use crate::context::Shell; +/// Create a `PathBuf` from an absolute path, where the root directory will be mocked in test +#[cfg(not(test))] +#[inline] +#[allow(dead_code)] +pub fn context_path + ?Sized>(_context: &Context, s: &S) -> PathBuf { + PathBuf::from(s) +} + +/// Create a `PathBuf` from an absolute path, where the root directory will be mocked in test +#[cfg(test)] +#[allow(dead_code)] +pub fn context_path + ?Sized>(context: &Context, s: &S) -> PathBuf { + let requested_path = PathBuf::from(s); + + if requested_path.is_absolute() { + let mut path = PathBuf::from(context.root_dir.path()); + path.extend(requested_path.components().skip(1)); + path + } else { + requested_path + } +} + /// Return the string contents of a file pub fn read_file + Debug>(file_name: P) -> Result { log::trace!("Trying to read from {:?}", file_name);