Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ libc = "0.2.187"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
shlex = "1"
signal-hook = "0.4"
tempfile = "3"
which = "8"
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,30 @@ Selectable menus will be available when using `kubie ctx` and `kubie ns`.
* `kubie info depth` print depth of recursive contexts
* `kubie update` will check the latest kubie version and update your local installation if needed

### Eval mode (experimental)

`kubie ctx --eval` outputs `export` statements instead of spawning a new shell. Evaluating that output
switches your current shell into the selected kubie context with less startup overhead, which is
useful for shell key bindings, wrapper functions or non-interactive scripts.

```bash
eval "$(kubie ctx --eval my-context)"
```

Command wrapper:

```bash
kctx() {
eval "$(kubie ctx --eval "$@")"
}
```

```fish
function kctx
eval (kubie ctx --eval $argv)
end
```

## Settings
You can customize kubie's behavior with the `~/.kube/kubie.yaml` file. The settings available and their defaults are
available below.
Expand Down
43 changes: 43 additions & 0 deletions src/cmd/activation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
use anyhow::Result;

use crate::kubeconfig::{self, KubeConfig};
use crate::session::Session;
use crate::settings::Settings;
use crate::shell::spawn_shell;

use super::eval;

#[derive(Debug)]
pub enum ActivationMode {
Eval,
Spawn,
Switch,
}

impl ActivationMode {
pub fn resolve(eval: bool, recursive: bool, is_active: bool) -> Self {
if eval {
ActivationMode::Eval
} else if is_active && !recursive {
ActivationMode::Switch
} else {
ActivationMode::Spawn
}
}

pub fn activate(self, settings: &Settings, config: KubeConfig, session: &Session) -> Result<()> {
match self {
ActivationMode::Eval => eval::emit_session(settings, &config, session),
ActivationMode::Spawn => {
spawn_shell(settings, config, session)?;
Ok(())
}
ActivationMode::Switch => {
let path = kubeconfig::get_kubeconfig_path()?;
config.write_to_file(path.as_path())?;
session.save(None)?;
Ok(())
}
}
}
}
22 changes: 6 additions & 16 deletions src/cmd/context.rs
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
use anyhow::Result;

use crate::cmd::{select_or_list_context, SelectResult};
use crate::cmd::{select_or_list_context, ActivationMode, SelectResult};
use crate::kubeconfig::{self, Installed};
use crate::kubectl;
use crate::session::Session;
use crate::settings::Settings;
use crate::shell::spawn_shell;
use crate::state::State;
use crate::vars;

fn enter_context(
settings: &Settings,
installed: Installed,
installed: &Installed,
context_name: &str,
namespace_name: Option<&str>,
recursive: bool,
mode: ActivationMode,
) -> Result<()> {
let state = State::load()?;
let mut session = Session::load()?;
Expand Down Expand Up @@ -50,23 +48,15 @@ fn enter_context(
}
}

if vars::is_kubie_active() && !recursive {
let path = kubeconfig::get_kubeconfig_path()?;
kubeconfig.write_to_file(path.as_path())?;
session.save(None)?;
} else {
spawn_shell(settings, kubeconfig, &session)?;
}

Ok(())
mode.activate(settings, kubeconfig, &session)
}

pub fn context(
settings: &Settings,
context_name: Option<String>,
namespace_name: Option<String>,
kubeconfigs: Vec<String>,
recursive: bool,
mode: ActivationMode,
) -> Result<()> {
let mut installed = if kubeconfigs.is_empty() {
kubeconfig::get_installed_contexts(settings)?
Expand All @@ -82,5 +72,5 @@ pub fn context(
},
};

enter_context(settings, installed, &context_name, namespace_name.as_deref(), recursive)
enter_context(settings, &installed, &context_name, namespace_name.as_deref(), mode)
}
154 changes: 154 additions & 0 deletions src/cmd/eval.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
use std::env;
use std::fmt::Display;
use std::path::{Path, PathBuf};

use anyhow::{anyhow, Result};

use crate::kubeconfig::KubeConfig;
use crate::session::Session;
use crate::settings::Settings;
use crate::shell::{detect_shell, ShellKind};
use crate::vars;

// Distinct from spawn_shell's own prefix so is_managed_file never confuses the two.
const CONFIG_PREFIX: &str = "kubie-eval-config-";
const SESSION_PREFIX: &str = "kubie-eval-session-";

pub(super) fn emit_session(settings: &Settings, config: &KubeConfig, session: &Session) -> Result<()> {
let shell = detect_eval_shell(settings)?;

let (config_path, session_path) = match existing_eval_paths() {
// Reuse an existing --eval pair in place rather than minting a new one.
Some((config_path, session_path)) => {
config.write_to_file(&config_path)?;
session.save(Some(&session_path))?;
(config_path, session_path)
}
None => create_eval_files(config, session)?,
};

let depth = if vars::is_kubie_active() { vars::get_depth() } else { 1 };
let config_path = config_path.display().to_string();
let session_path = session_path.display().to_string();

emit_vars(shell, &config_path, &session_path, depth);

Ok(())
}

fn create_eval_files(config: &KubeConfig, session: &Session) -> Result<(PathBuf, PathBuf)> {
let temp_config_file = tempfile::Builder::new()
.prefix(CONFIG_PREFIX)
.suffix(".yaml")
.tempfile()?;
config.write_to_file(temp_config_file.path())?;

let temp_session_file = tempfile::Builder::new()
.prefix(SESSION_PREFIX)
.suffix(".json")
.tempfile()?;
session.save(Some(temp_session_file.path()))?;

let config_path = temp_config_file.into_temp_path().keep()?;
let session_path = temp_session_file.into_temp_path().keep()?;

Ok((config_path, session_path))
}

/// Paths of the kubeconfig/session pair from a previous `--eval` call, if still active.
fn existing_eval_paths() -> Option<(PathBuf, PathBuf)> {
if !vars::is_kubie_active() {
return None;
}

let config_path = PathBuf::from(env::var_os("KUBIE_KUBECONFIG")?);
let session_path = PathBuf::from(env::var_os("KUBIE_SESSION")?);

if is_managed_file(&config_path, CONFIG_PREFIX) && is_managed_file(&session_path, SESSION_PREFIX) {
Some((config_path, session_path))
} else {
None
}
}

fn is_managed_file(path: &Path, prefix: &str) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with(prefix))
}

fn detect_eval_shell(settings: &Settings) -> Result<ShellKind> {
let shell = match &settings.shell {
Some(s) => ShellKind::from_str(s).ok_or_else(|| anyhow!("Invalid shell setting: {}", s))?,
None => detect_shell()?,
};

match shell {
ShellKind::Bash | ShellKind::Zsh | ShellKind::Fish => Ok(shell),
_ => Err(anyhow!(
"--eval is not supported for this shell. Supported: bash, zsh, fish."
)),
}
}

fn emit_vars(shell: ShellKind, config_path: &str, session_path: &str, depth: impl Display) {
println!("{}", render_vars(shell, config_path, session_path, depth).join("\n"));
}

fn render_vars(shell: ShellKind, config_path: &str, session_path: &str, depth: impl Display) -> Vec<String> {
vec![
format_var(shell, "KUBECONFIG", config_path),
format_var(shell, "KUBIE_ACTIVE", "1"),
format_var(shell, "KUBIE_DEPTH", depth),
format_var(shell, "KUBIE_KUBECONFIG", config_path),
format_var(shell, "KUBIE_SESSION", session_path),
]
}

fn format_var(shell: ShellKind, key: &str, value: impl Display) -> String {
let value_str = value.to_string();
let quoted = shlex::try_quote(&value_str).unwrap_or(std::borrow::Cow::Borrowed(&value_str));
match shell {
ShellKind::Bash | ShellKind::Zsh => format!("export {}={};", key, quoted),
ShellKind::Fish => format!("set -gx {} {};", key, quoted),
_ => unreachable!(),
}
}

#[cfg(test)]
mod tests {
use std::path::Path;

use super::{is_managed_file, render_vars, ShellKind, CONFIG_PREFIX};

#[test]
fn test_is_managed_file_accepts_matching_prefix() {
let path = Path::new("/tmp").join(format!("{CONFIG_PREFIX}abc123.yaml"));
assert!(is_managed_file(&path, CONFIG_PREFIX));
}

#[test]
fn test_is_managed_file_rejects_other_prefix() {
let path = Path::new("/tmp").join("kubie-config123.yaml");
assert!(!is_managed_file(&path, CONFIG_PREFIX));
}

#[test]
fn test_bash_output_uses_export_syntax() {
let script = render_vars(ShellKind::Bash, "/tmp/config.yaml", "/tmp/session.json", 1).join("\n");

assert!(script.contains("export KUBECONFIG=/tmp/config.yaml;"));
assert!(script.contains("export KUBIE_ACTIVE=1;"));
assert!(script.contains("export KUBIE_DEPTH=1;"));
assert!(script.contains("export KUBIE_SESSION=/tmp/session.json;"));
}

#[test]
fn test_fish_output_uses_set_gx_syntax() {
let script = render_vars(ShellKind::Fish, "/tmp/config.yaml", "/tmp/session.json", 2).join("\n");

assert!(script.contains("set -gx KUBECONFIG /tmp/config.yaml;"));
assert!(script.contains("set -gx KUBIE_DEPTH 2;"));
assert!(script.contains("set -gx KUBIE_SESSION /tmp/session.json;"));
}
}
8 changes: 8 additions & 0 deletions src/cmd/meta.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ pub enum Kubie {
#[clap(short = 'r', long = "recursive")]
recursive: bool,

/// Outputs shell statements to be eval'd in the current shell.
#[clap(long = "eval", conflicts_with = "recursive")]
eval: bool,

/// Name of the context to enter. Use '-' to switch back to the previous context.
context_name: Option<String>,
},
Expand All @@ -34,6 +38,10 @@ pub enum Kubie {
#[clap(short = 'r', long = "recursive")]
recursive: bool,

/// Outputs shell statements to be eval'd in the current shell.
#[clap(long = "eval", conflicts_with = "recursive")]
eval: bool,

/// Unsets the namespace in the currently active context.
#[clap(short = 'u', long = "unset")]
unset: bool,
Expand Down
8 changes: 6 additions & 2 deletions src/cmd/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ use crate::kubeconfig::Installed;
use crate::kubectl;
use crate::settings::Fzf;

mod activation;
pub mod context;
pub mod delete;
pub(crate) mod eval;
pub mod edit;
pub mod exec;
pub mod export;
Expand All @@ -18,6 +20,8 @@ pub mod namespace;
#[cfg(feature = "update")]
pub mod update;

pub use activation::ActivationMode;

pub enum SelectResult {
Cancelled,
Listed,
Expand All @@ -35,7 +39,7 @@ pub fn select_or_list_context(fzf: &Fzf, installed: &mut Installed) -> Result<Se
return Ok(SelectResult::Selected(context_names[0].clone()));
}

if io::stdout().is_terminal() {
if io::stdin().is_terminal() {
// NOTE: skim shows the list of context names in reverse order
context_names.reverse();
match crate::skim::select(fzf, context_names)? {
Expand All @@ -62,7 +66,7 @@ pub fn select_or_list_namespace(fzf: &Fzf, namespaces: Option<Vec<String>>) -> R
bail!("No namespaces found");
}

if io::stdout().is_terminal() {
if io::stdin().is_terminal() {
// NOTE: skim shows the list of namespaces in reverse order
namespaces.reverse();
match crate::skim::select(fzf, namespaces)? {
Expand Down
Loading
Loading