feat: add a container indicator (#3304)

* test: add mock method for absolute files

Signed-off-by: Harald Hoyer <harald@hoyer.xyz>

* 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 <harald@hoyer.xyz>
This commit is contained in:
Harald Hoyer 2022-01-21 16:44:46 +01:00 committed by GitHub
parent 0d573ac5ea
commit 4f46411403
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 338 additions and 1 deletions

View File

@ -70,6 +70,47 @@ pub fn module<'a>(context: &'a Context) -> Option<Module<'a>> {
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<Module<'a>> {
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.

View File

@ -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/).

23
src/configs/container.rs Normal file
View File

@ -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,
}
}
}

View File

@ -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(),

View File

@ -86,6 +86,7 @@ pub const PROMPT_ORDER: &[&str] = &[
"battery",
"time",
"status",
"container",
"shell",
"character",
];

View File

@ -63,6 +63,10 @@ pub struct Context<'a> {
#[cfg(test)]
pub cmd: HashMap<&'a str, Option<CommandOutput>>,
/// 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(),

View File

@ -17,6 +17,7 @@ pub const ALL_MODULES: &[&str] = &[
"cmd_duration",
"cobol",
"conda",
"container",
"crystal",
"dart",
"deno",

196
src/modules/container.rs Normal file
View File

@ -0,0 +1,196 @@
use super::{Context, Module};
#[cfg(not(target_os = "linux"))]
pub fn module<'a>(_context: &'a Context) -> Option<Module<'a>> {
None
}
#[cfg(target_os = "linux")]
pub fn module<'a>(context: &'a Context) -> Option<Module<'a>> {
use super::RootModuleConfig;
use crate::configs::container::ContainerConfig;
use crate::formatter::StringFormatter;
use crate::utils::read_file;
pub fn container_name(context: &Context) -> Option<String> {
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<String>, Option<String>)> {
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(())
}
}

View File

@ -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<Module<'a>> {
"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",

View File

@ -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<PathBuf> =
@ -70,6 +70,10 @@ impl<'a> ModuleRenderer<'a> {
self
}
pub fn root_path(&self) -> &Path {
self.context.root_dir.path()
}
pub fn logical_path<T>(mut self, path: T) -> Self
where
T: Into<PathBuf>,

View File

@ -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<S: AsRef<OsStr> + ?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<S: AsRef<OsStr> + ?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<P: AsRef<Path> + Debug>(file_name: P) -> Result<String> {
log::trace!("Trying to read from {:?}", file_name);