use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::time::{Duration, Instant};
use super::Server;
use crate::config::ContextsConfig;
use crate::context::{self, KubeContext, TfSeen};
use crate::layout::PaneId;
use crate::pane::Pane;
use crate::tools::ToolContext;
pub(super) const CONTEXT_STALE: Duration = Duration::from_secs(5);
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Production {
pub why: String,
what: String,
}
impl Production {
pub fn new(why: impl Into<String>, what: impl Into<String>) -> Production {
Production { why: why.into(), what: what.into() }
}
pub fn and(self, other: Production) -> Production {
if self == other {
return self;
}
Production { why: format!("{}; {}", self.why, other.why), what: format!("{} + {}", self.what, other.what) }
}
}
impl std::fmt::Display for Production {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.why)
}
}
fn kube_what(value: &str, flags: &context::KubeFlags, read: u64) -> String {
format!("kubeconfig {value:?} {flags:?} read #{read}")
}
type TfDir = (PathBuf, Option<String>);
const TF_DIRS_KEPT: usize = 512;
fn forget_unused(read: &mut HashMap<TfDir, Option<String>>, wanted: &HashSet<TfDir>) {
if read.len() > TF_DIRS_KEPT {
read.retain(|k, ws| wanted.contains(k) || ws.is_some());
}
}
#[derive(Default)]
pub(super) struct TfDirs {
read: HashMap<TfDir, Option<String>>,
reading: HashMap<TfDir, (Instant, u64)>,
reads: u64,
}
fn tf_place((dir, data_dir): &TfDir) -> String {
match data_dir {
Some(d) => format!("{}, TF_DATA_DIR {d}", dir.display()),
None => dir.display().to_string(),
}
}
pub(super) fn observe_tools(cfg: &ContextsConfig, pane: &mut Pane, argv: &[String]) -> bool {
let kube = cfg.kubernetes && context::runs_kube_tool(argv);
let aws = cfg.aws && context::runs_aws_tool(argv);
let tf = cfg.terraform && context::runs_terraform(argv);
if !kube && !aws && !tf {
return false;
}
let Some(pid) = pane.foreground_pid() else { return false };
let before = (pane.seen.kubeconfig.clone(), pane.seen.tf_workspace.clone());
let env = crate::process::environ(
pid,
&["KUBECONFIG", "AWS_PROFILE", "AWS_DEFAULT_PROFILE", "TF_WORKSPACE", "TF_DATA_DIR"],
);
if kube {
let flags = context::kube_flags(argv);
let value = flags.kubeconfig.clone().or_else(|| env.get("KUBECONFIG").cloned()).unwrap_or_default();
let base = crate::process::cwd(pid);
pane.seen.kubeconfig = Some(context::absolute_kubeconfig(&value, base.as_deref()));
pane.seen.kube_flags = context::KubeFlags { kubeconfig: None, ..flags };
}
if aws {
let profile = context::aws_profile_flag(argv)
.or_else(|| env.get("AWS_PROFILE").cloned())
.or_else(|| env.get("AWS_DEFAULT_PROFILE").cloned())
.filter(|p| !p.is_empty());
pane.seen.aws_profile = Some(profile);
}
if tf {
let cwd = crate::process::cwd(pid);
pane.seen.tf_workspace = match &cwd {
Some(dir) => Some(context::terraform_command_workspace(argv, dir, &env)),
None => env.get("TF_WORKSPACE").filter(|w| !w.is_empty()).map(|w| TfSeen::Env(w.clone())),
};
}
(pane.seen.kubeconfig.clone(), pane.seen.tf_workspace.clone()) != before
}
fn spec_env(pane: &Pane, name: &str) -> Option<String> {
pane.spec.env.iter().find(|(k, _)| k == name).map(|(_, v)| v.clone())
}
impl Server {
fn pane_kubeconfig(&self, pane: &Pane) -> Option<String> {
if !self.config.contexts.kubernetes || pane.ssh.is_some() {
return None;
}
pane.seen.kubeconfig.clone().or_else(|| {
spec_env(pane, "KUBECONFIG").map(|v| context::absolute_kubeconfig(&v, pane.spec.cwd.as_deref()))
})
}
pub(super) fn refresh_kube(&mut self) {
let values: HashSet<String> = self.panes.values().filter_map(|p| self.pane_kubeconfig(p)).collect();
self.kube.retain(|v, _| values.contains(v));
self.kube_cache.retain_told(|v| values.contains(v));
for value in values {
let Some(number) = self.kube_cache.begin(&value) else { continue };
let (cache, read, done) = (self.kube_cache.clone(), value.clone(), value.clone());
let started = self.background(
move || cache.merged(&read),
move |server, file| {
server.kube_cache.end(&done);
server.tell_kube(done, number, file);
},
);
if !started {
self.kube_cache.end(&value);
}
}
}
pub(super) fn tell_kube(&mut self, value: String, number: u64, file: context::KubeFile) {
let changed = self.kube.get(&value) != Some(&file);
if self.kube_cache.told(&value, number, changed) && changed {
self.kube.insert(value, file);
self.mark_dirty();
}
}
pub(super) fn kubeconfig_of(&self, id: PaneId) -> Option<String> {
self.pane_kubeconfig(self.panes.get(&id)?)
}
fn kube_place(&self, id: PaneId, value: &str) -> String {
let mut place = if value.is_empty() { "~/.kube/config".to_string() } else { value.to_string() };
if let Some(p) = self.panes.get(&id) {
if let Some(c) = &p.seen.kube_flags.context {
place.push_str(&format!(", context {c}"));
}
if let Some(ns) = &p.seen.kube_flags.namespace {
place.push_str(&format!(", namespace {ns}"));
}
}
place
}
fn kube_what(&self, id: PaneId, value: &str, read: u64) -> String {
let flags = self.panes.get(&id).map(|p| p.seen.kube_flags.clone()).unwrap_or_default();
kube_what(value, &flags, read)
}
pub(super) fn kube_stalled(&self, id: PaneId, value: &str) -> Production {
let read = self.kube_cache.known_since(value).unwrap_or(0);
Production::new(
format!("an unknown Kubernetes context ({} does not answer)", self.kube_place(id, value)),
format!("stalled {}", self.kube_what(id, value, read)),
)
}
pub fn pane_kube(&self, id: PaneId) -> Option<KubeContext> {
let p = self.panes.get(&id)?;
self.kube.get(&self.pane_kubeconfig(p)?)?.resolve(&p.seen.kube_flags)
}
fn pane_kube_unknown(&self, id: PaneId) -> Option<Production> {
if let Some(stalled) = self.pane_kube_stalled(id) {
return Some(stalled);
}
let p = self.panes.get(&id)?;
let value = self.pane_kubeconfig(p)?;
let f = self.kube.get(&value)?;
if p.seen.kube_flags.context.is_some() {
return None;
}
let read = self.kube_cache.known_since(&value).unwrap_or(0);
f.unreadable.as_ref().map(|why| {
Production::new(
format!("an unknown Kubernetes context (unreadable kubeconfig: {why})"),
format!("unreadable {}", self.kube_what(id, &value, read)),
)
})
}
fn pane_kube_stalled(&self, id: PaneId) -> Option<Production> {
let p = self.panes.get(&id)?;
let value = self.pane_kubeconfig(p)?;
let (since, number) = self.kube_cache.reading(&value)?;
(since.elapsed() > CONTEXT_STALE).then(|| {
Production::new(
format!("an unknown Kubernetes context ({} does not answer)", self.kube_place(id, &value)),
format!("stalled {}", self.kube_what(id, &value, number)),
)
})
}
pub fn pane_kube_is_unknown(&self, id: PaneId) -> bool {
self.pane_kube_unknown(id).is_some()
}
fn pane_kube_pending(&self, id: PaneId) -> Option<Production> {
let value = self.kubeconfig_of(id)?;
if self.kube.contains_key(&value) {
return None;
}
let number = self.kube_cache.reading(&value).map_or(0, |(_, n)| n);
Some(Production::new(
format!("a Kubernetes context not read yet ({})", self.kube_place(id, &value)),
format!("pending {}", self.kube_what(id, &value, number)),
))
}
pub fn pane_context_reading(&self, id: PaneId) -> bool {
let fresh = |since: &Instant| since.elapsed() <= CONTEXT_STALE;
let kube = self.kubeconfig_of(id).and_then(|v| self.kube_cache.reading(&v)).is_some_and(|(t, _)| fresh(&t));
let tf = self.pane_tf_keys(id).any(|k| self.tf_dirs.reading.get(&k).is_some_and(|(t, _)| fresh(t)));
kube || tf
}
pub(super) fn refresh_tf_dirs(&mut self) {
let mut wanted: HashSet<TfDir> = HashSet::new();
let terraform = self.config.contexts.terraform;
for p in self.panes.values_mut() {
if let Some(TfSeen::Dir { from, dir, data_dir }) = &p.seen.tf_workspace {
if p.current_cwd().as_ref() == Some(from) {
wanted.insert((dir.clone(), data_dir.clone()));
} else {
p.seen.tf_workspace = None;
}
}
let shell_here = p.is_terminal() && p.ssh.is_none() && p.exit_code.is_none();
if terraform && shell_here && !matches!(p.seen.tf_workspace, Some(TfSeen::Env(_))) {
wanted.extend(p.current_cwd().map(|cwd| (cwd, None)));
}
}
forget_unused(&mut self.tf_dirs.read, &wanted);
for key in wanted {
if self.tf_dirs.reading.contains_key(&key) {
continue;
}
self.tf_dirs.reads += 1;
self.tf_dirs.reading.insert(key.clone(), (Instant::now(), self.tf_dirs.reads));
let (read, done) = (key.clone(), key.clone());
let started = self.background(
move || context::terraform_dir_workspace(&read.0, read.1.as_deref()),
move |server, ws| {
server.tf_dirs.reading.remove(&done);
if server.tf_dirs.read.get(&done) != Some(&ws) {
server.tf_dirs.read.insert(done, ws);
server.mark_dirty();
}
},
);
if !started {
self.tf_dirs.reading.remove(&key);
}
}
}
pub fn pane_terraform(&self, id: PaneId) -> Option<String> {
let p = self.panes.get(&id)?;
if !self.config.contexts.terraform || p.ssh.is_some() {
return None;
}
let cwd = p.current_cwd();
match &p.seen.tf_workspace {
Some(TfSeen::Env(ws)) => Some(ws.clone()),
Some(TfSeen::Dir { from, dir, data_dir }) if cwd.as_ref() == Some(from) => {
match self.tf_dirs.read.get(&(dir.clone(), data_dir.clone()))? {
Some(ws) => Some(ws.clone()),
None => self.tf_dirs.read.get(&(cwd?, None))?.clone(),
}
}
_ => self.tf_dirs.read.get(&(cwd?, None))?.clone(),
}
}
fn pane_tf_dir(&self, id: PaneId) -> Option<TfDir> {
let p = self.panes.get(&id)?;
if !self.config.contexts.terraform || p.ssh.is_some() {
return None;
}
let Some(TfSeen::Dir { from, dir, data_dir }) = &p.seen.tf_workspace else { return None };
(p.current_cwd().as_ref() == Some(from)).then(|| (dir.clone(), data_dir.clone()))
}
fn pane_tf_keys(&self, id: PaneId) -> impl Iterator<Item = TfDir> {
let here = self.panes.get(&id).filter(|p| {
self.config.contexts.terraform && p.ssh.is_none() && !matches!(p.seen.tf_workspace, Some(TfSeen::Env(_)))
});
self.pane_tf_dir(id).into_iter().chain(here.and_then(|p| p.current_cwd()).map(|cwd| (cwd, None)))
}
fn pane_terraform_unknown(&self, id: PaneId) -> Option<Production> {
self.pane_tf_keys(id).find_map(|key| {
let (since, number) = self.tf_dirs.reading.get(&key)?;
(since.elapsed() > CONTEXT_STALE).then(|| {
Production::new(
format!("an unknown Terraform workspace ({} does not answer)", tf_place(&key)),
format!("stalled terraform {key:?} read #{number}"),
)
})
})
}
fn pane_context_pending(&self, id: PaneId) -> Option<Production> {
if let Some(why) = self.pane_kube_pending(id) {
return Some(why);
}
let key = self.pane_tf_dir(id)?;
if self.tf_dirs.read.contains_key(&key) {
return None;
}
let number = self.tf_dirs.reading.get(&key).map_or(0, |(_, n)| *n);
Some(Production::new(
format!("a Terraform workspace not read yet ({})", tf_place(&key)),
format!("pending terraform {key:?} read #{number}"),
))
}
pub fn pane_aws(&self, id: PaneId) -> Option<String> {
let p = self.panes.get(&id)?;
if !self.config.contexts.aws || p.ssh.is_some() {
return None;
}
match &p.seen.aws_profile {
Some(seen) => seen.clone(),
None => spec_env(p, "AWS_PROFILE"),
}
}
pub fn pane_server_entry(&self, id: PaneId) -> Option<crate::servers::Server> {
let mut s = self.panes.get(&id)?.ssh.clone()?;
if let Some(k) = self.known_servers.iter().find(|k| k.server.name == s.name) {
s.groups = k.server.groups.clone();
s.env = k.server.env.clone();
s.note = k.server.note.clone();
}
Some(s)
}
pub fn pane_production(&self, id: PaneId) -> Option<Production> {
if self.config.production.words.is_empty() {
return None;
}
self.pane_production_known(id).or_else(|| self.pane_context_pending(id))
}
pub fn pane_production_shown(&self, id: PaneId) -> Option<String> {
self.pane_production_known(id).map(|p| p.why)
}
fn pane_production_known(&self, id: PaneId) -> Option<Production> {
let words = &self.config.production.words;
if words.is_empty() {
return None;
}
if let Some(s) = self.pane_server_entry(id) {
return context::server_production(&s, words)
.then(|| Production::new(format!("server {}", s.name), format!("server {s:?}")));
}
if let Some(stalled) = self.pane_kube_stalled(id) {
return Some(stalled);
}
if let Some(k) = self.pane_kube(id) {
let value = self.kubeconfig_of(id).unwrap_or_default();
let what = self.kube_what(id, &value, self.kube_cache.known_since(&value).unwrap_or(0));
if context::names_production(&k.context, words) {
return Some(Production::new(format!("Kubernetes context {}", k.context), what));
}
if let Some(ns) = k.namespace.filter(|ns| context::names_production(ns, words)) {
return Some(Production::new(format!("Kubernetes namespace {ns} in {}", k.context), what));
}
}
if let Some(why) = self.pane_kube_unknown(id) {
return Some(why);
}
if let Some(why) = self.pane_terraform_unknown(id) {
return Some(why);
}
if let Some(ws) = self.pane_terraform(id).filter(|ws| context::names_production(ws, words)) {
let place = self
.pane_tf_dir(id)
.map(|k| format!("{k:?}"))
.or_else(|| self.panes.get(&id).and_then(|p| p.current_cwd()).map(|d| format!("{d:?}")));
return Some(Production::new(format!("Terraform workspace {ws}"), format!("terraform {place:?} {ws}")));
}
if let Some(p) = self.pane_aws(id).filter(|p| context::names_production(p, words)) {
return Some(Production::new(format!("AWS profile {p}"), format!("aws {p}")));
}
None
}
pub fn context_labels(&self, id: PaneId) -> Vec<(String, bool)> {
let ascii = crate::theme::current().ascii;
let words = &self.config.production.words;
let mut out = Vec::new();
if let Some(ws) = self.pane_terraform(id) {
out.push((format!("tf:{ws}"), context::names_production(&ws, words)));
}
if let Some(k) = self.pane_kube(id) {
let prod = context::names_production(&k.context, words)
|| k.namespace.as_deref().is_some_and(|ns| context::names_production(ns, words));
out.push((k.label(ascii), prod));
} else if self.pane_kube_is_unknown(id) {
out.push((if ascii { "k8s:?" } else { "⎈ ?" }.to_string(), true));
}
if let Some(p) = self.pane_aws(id) {
out.push((format!("aws:{p}"), context::names_production(&p, words)));
}
out
}
pub fn tool_context(&self, id: PaneId) -> ToolContext {
let Some(p) = self.panes.get(&id) else { return ToolContext::default() };
let server = self.pane_server_entry(id);
ToolContext {
cwd: if server.is_none() { p.current_cwd() } else { None },
remote_cwd: server.as_ref().and_then(|_| p.remote_cwd()),
server,
git: self.pane_git(id).cloned(),
kube: self.pane_kube(id),
kubeconfig: self.pane_kubeconfig(p),
terraform: self.pane_terraform(id),
aws_profile: self.pane_aws(id),
aws_default: matches!(p.seen.aws_profile, Some(None)),
selection: p.selection_text(),
production: self.pane_production(id).map(|p| p.why),
extra: Vec::new(),
secrets: Vec::new(),
}
}
pub(super) fn forget_ended_ssh(&mut self, ids: Vec<PaneId>) {
for id in ids {
let Some(p) = self.panes.get(&id) else { continue };
if self.ssh_target_of(&p.spec).is_none() {
self.panes.get_mut(&id).unwrap().ssh = None;
self.mark_dirty();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_terraform_directories_are_never_forgotten() {
let dir = |n: usize| (PathBuf::from(format!("/d{n}")), None);
let mut read: HashMap<TfDir, Option<String>> = (0..TF_DIRS_KEPT).map(|n| (dir(n), None)).collect();
read.insert(dir(9999), Some("prod".into()));
read.insert(dir(10000), None);
let wanted: HashSet<TfDir> = [dir(10000)].into_iter().collect();
forget_unused(&mut read, &wanted);
assert_eq!(read.get(&dir(9999)), Some(&Some("prod".into())), "a Terraform directory stays");
assert!(read.contains_key(&dir(10000)), "a wanted directory stays");
assert_eq!(read.len(), 2, "the unused others go");
}
#[test]
fn an_approval_holds_for_what_it_was_given_for() {
let flags = |c: &str| context::KubeFlags { context: Some(c.into()), ..Default::default() };
let stalled = |value: &str, f: &context::KubeFlags, read| {
Production::new(
"an unknown Kubernetes context (k does not answer)",
format!("stalled {}", kube_what(value, f, read)),
)
};
let asked = stalled("/k", &flags("prod-a"), 3);
assert_eq!(asked, stalled("/k", &flags("prod-a"), 3), "the same stalled file asked about twice");
assert_ne!(asked, stalled("/k", &flags("prod-b"), 3), "the same words, other flags");
assert_ne!(asked, stalled("/k", &flags("prod-a"), 4), "a newer read");
assert_ne!(asked, stalled("/other", &flags("prod-a"), 3), "another file");
let a = Production::new("Kubernetes context prod", kube_what("/a", &Default::default(), 1));
let b = Production::new("Kubernetes context prod", kube_what("/b", &Default::default(), 1));
assert_ne!(a, b);
assert_eq!(a.to_string(), "Kubernetes context prod");
assert_eq!(a.clone().and(a.clone()), a);
assert_eq!(a.and(b).to_string(), "Kubernetes context prod; Kubernetes context prod");
}
}