Skip to content
Merged
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
11 changes: 11 additions & 0 deletions docs/debugging.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,17 @@ For history that outlives the pod, use [VictoriaLogs](providers.md#log-provider-

## Debug containers and pods

When a pod shell fails, sofka keeps the exit status and the last 16 KiB of
captured standard error in a dialog until you dismiss it. After the command
exits, sofka reads available error output before applying a 100 ms deadline to
an idle pipe. It reads at most 1 MiB after exit so a descendant that keeps
writing cannot block the interface. If the runtime reports a
missing `sh`, press `d` to open the debug image prompt for the same target.
The image is shown before creation. No debug container is created until you
accept the prompt and any required guardrail confirmation. Canceling returns
to the original error. Read-only mode and the `debug` guardrail still apply.
Permission errors and connection failures do not trigger this recovery offer.

`:debug` on a **pod** attaches a temporary ephemeral debug container with
`kubectl debug`. sofka prompts for the image (prefilled from `[debug]`). An empty
`command` starts an interactive shell (bash if the image has it, else sh), like
Expand Down
3 changes: 3 additions & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,9 @@ concurrent drains, and full kubectl drain parity are outside this feature.
- **PVC explore** (`x` on a PVC, or `:pvc-explore`) - a two-pane browser over a
volume's contents, with `s` for a shell inside it. See
[PVC explore](#pvc-explore).
- **Shell failure recovery** keeps command errors visible until dismissed.
A missing shell offers the built-in debug image prompt for the same target,
without plugins. Creation requires explicit acceptance and obeys guardrails.
- **Ephemeral debug containers** and **node debug pods** (`:debug`). See
[Debug containers and pods](debugging.md#debug-containers-and-pods).
- **Logs** (`l`) - combined logs for marked pods, per-container on a pod, or aggregated across all matching
Expand Down
8 changes: 8 additions & 0 deletions docs/keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,3 +326,11 @@ See [Create a plugin package](plugin-authoring.md).

`PgUp` and `PgDn` scroll text that does not fit in the popup. The action keys stay
visible. In an input popup, typing moves the view to the cursor.

## Command failures

A failed interactive command opens an error dialog. `esc` or `enter` dismisses
it; `PgUp` and `PgDn` scroll the output. If a pod shell failed because `sh` is
missing, `d` opens the debug image prompt for the same pod and container.
Accept the image to start the debug container, subject to the configured
guardrails. Canceling the prompt returns to the original error.
80 changes: 77 additions & 3 deletions src/app/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -754,6 +754,28 @@ impl App {
if self.deny_readonly() {
return;
}
let container = container.or_else(|| {
let obj = self.store.get(&format!("{ns}/{pod}"))?;
let containers = obj.data.pointer("/spec/containers")?.as_array()?;
let default = obj
.metadata
.annotations
.as_ref()
.and_then(|a| a.get("kubectl.kubernetes.io/default-container"));
default
.filter(|name| {
containers
.iter()
.any(|c| c["name"].as_str() == Some(name.as_str()))
})
.cloned()
.or_else(|| containers.first()?.get("name")?.as_str().map(str::to_owned))
});
self.shell_target = Some(ShellTarget {
ns: ns.clone(),
pod: pod.clone(),
container: container.clone(),
});
self.note_action("shell", format!("{pod} in {ns}"));
let mut argv = self.kubectl_base();
argv.extend(["exec".into(), "-it".into(), "-n".into(), ns, pod]);
Expand Down Expand Up @@ -788,8 +810,19 @@ impl App {
};
let name = obj.metadata.name.clone().unwrap_or_default();
let ns = obj.metadata.namespace.clone().unwrap_or_default();
// A debug container is a mutation of the pod — let guardrails gate it,
// with no default confirmation (like shell).
self.request_debug_target(ns, name, target, None);
}

pub(super) fn request_debug_target(
&mut self,
ns: String,
name: String,
target: Option<String>,
recovery: Option<Box<CommandFailure>>,
) {
if self.deny_readonly() {
return;
}
let targets = [(name.clone(), ns.clone())];
if self
.guard("debug", "pods", &targets, ConfirmLevel::None)
Expand All @@ -806,10 +839,47 @@ impl App {
ns,
pod: name,
target,
recovery,
});
self.mode = Mode::Prompt;
}

pub(super) fn confirm_debug(
&mut self,
ns: String,
pod: String,
target: Option<String>,
image: String,
recovery: Option<Box<CommandFailure>>,
) {
if self.deny_readonly() {
if recovery.is_some() {
self.retain_recovery_error();
}
return;
}
let targets = [(pod.clone(), ns.clone())];
let Some(level) = self.guard("debug", "pods", &targets, ConfirmLevel::None) else {
if recovery.is_some() {
self.retain_recovery_error();
}
return;
};
let label = format!("Start debug container in {ns}/{pod} with image {image}?");
self.begin_guarded(
ConfirmAction::Debug {
ns,
pod: pod.clone(),
target,
image,
recovery,
},
label,
level,
pod,
);
}

/// Launch `kubectl debug` for the ephemeral container: suspends the TUI and
/// shells out interactively, exactly like exec/attach. The ephemeral
/// container persists on the pod (Kubernetes can't remove it) until the pod
Expand All @@ -820,6 +890,7 @@ impl App {
pod: String,
target: Option<String>,
image: String,
recovery: Option<Box<CommandFailure>>,
) {
let tgt = target
.as_deref()
Expand All @@ -846,7 +917,10 @@ impl App {
argv.push("--".into());
argv.extend(self.debug.command.clone());
}
self.pending = Some(Suspend::Shell(argv));
self.pending = Some(match recovery {
Some(failure) => Suspend::Recovery { argv, failure },
None => Suspend::Shell(argv),
});
}

/// `:debug` on a node — preview and confirm the host access a node debug
Expand Down
100 changes: 100 additions & 0 deletions src/app/command_failure.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
use super::*;

#[derive(Clone)]
pub struct ShellTarget {
pub ns: String,
pub pod: String,
pub container: Option<String>,
}

#[derive(Clone)]
pub struct CommandFailure {
pub message: String,
original_message: String,
pub target: Option<ShellTarget>,
}

pub(crate) fn missing_shell(message: &str) -> bool {
message.lines().any(|line| {
let line = line.to_ascii_lowercase();
(line.contains("exec: \"sh\"") || line.contains("exec: \"/bin/sh\""))
&& (line.contains("executable file not found")
|| line.contains("no such file or directory"))
})
}

impl App {
pub fn handle_command_result(
&mut self,
target: Option<ShellTarget>,
result: std::io::Result<()>,
recovery: Option<Box<CommandFailure>>,
) {
match result {
Ok(()) => {
self.command_failure = None;
self.flash = "Command completed.".into();
self.flash_err = false;
}
Err(error) => {
let message = crate::ui::strip_ansi_if_present(&error.to_string()).into_owned();
let target = target.filter(|_| missing_shell(&message));
let (message, original_message, target) = match recovery {
Some(previous) => (
format!(
"{}\n\nRecovery failed:\n{message}",
previous.original_message
),
previous.original_message,
previous.target,
),
None => (message.clone(), message, target),
};
self.command_failure = Some(CommandFailure {
message,
original_message,
target,
});
self.popup_scroll = 0;
self.flash = "Command failed.".into();
self.flash_err = true;
}
}
}

pub fn command_failure_visible(&self) -> bool {
self.command_failure.is_some() && !matches!(self.mode, Mode::Prompt | Mode::Confirm)
}

pub(super) fn retain_recovery_error(&mut self) {
if let Some(failure) = &mut self.command_failure {
failure.message.push_str(&format!("\n\n{}", self.flash));
}
}

pub(super) fn key_command_failure(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc | KeyCode::Enter => self.command_failure = None,
KeyCode::PageUp | KeyCode::Up => {
self.popup_scroll = self.popup_scroll.saturating_sub(self.popup_viewport.max(1));
}
KeyCode::PageDown | KeyCode::Down => {
self.popup_scroll = self
.popup_scroll
.saturating_add(self.popup_viewport.max(1))
.min(self.popup_max_scroll);
}
KeyCode::Char('d') => {
let target = self.command_failure.as_ref().and_then(|f| f.target.clone());
if let Some(target) = target {
let recovery = self.command_failure.clone().map(Box::new);
self.request_debug_target(target.ns, target.pod, target.container, recovery);
if self.mode != Mode::Prompt {
self.retain_recovery_error();
}
}
}
_ => {}
}
}
}
4 changes: 4 additions & 0 deletions src/app/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ impl App {
// ----- key handling --------------------------------------------------

pub fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
if self.command_failure_visible() {
self.key_command_failure(key);
return Ok(());
}
let action = self.keymap.action(self.key_scope(), &key);
if action == Some(Action::PluginActivity) {
self.toggle_plugin_activity();
Expand Down
18 changes: 18 additions & 0 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,10 @@ pub enum Mode {
/// command (exec, edit, port-forward), then resume.
pub enum Suspend {
Shell(Vec<String>),
Recovery {
argv: Vec<String>,
failure: Box<CommandFailure>,
},
}

/// A `kubectl port-forward` running in the background (not `Suspend::Shell`
Expand Down Expand Up @@ -373,6 +377,13 @@ enum ConfirmAction {
},
/// Delete the node debugger pods sofka launched this session (`:debug-clean`).
CleanupDebuggers,
Debug {
ns: String,
pod: String,
target: Option<String>,
image: String,
recovery: Option<Box<CommandFailure>>,
},
/// Create a temporary pod that mounts a PVC nothing else mounts, so it can
/// be browsed or shelled into.
PvcHelper {
Expand Down Expand Up @@ -483,6 +494,7 @@ enum PromptKind {
ns: String,
pod: String,
target: Option<String>,
recovery: Option<Box<CommandFailure>>,
},
/// File-transfer path prompts (`t` on a pod), asked in two steps: the
/// source path first (`src` is `None`), then the destination with the
Expand Down Expand Up @@ -2260,6 +2272,8 @@ pub struct App {
event_task: Option<JoinHandle<()>>,

pub pending: Option<Suspend>,
pub shell_target: Option<ShellTarget>,
pub command_failure: Option<CommandFailure>,
/// Mode to return to when leaving a transient view (logs/detail/diff).
return_mode: Mode,
/// Row key (ns/name) selected when a transient view was opened, restored on
Expand Down Expand Up @@ -2546,6 +2560,8 @@ impl App {
event_gen: 0,
event_task: None,
pending: None,
shell_target: None,
command_failure: None,
return_mode: Mode::Table,
return_selection: None,
should_quit: false,
Expand Down Expand Up @@ -2625,6 +2641,8 @@ impl App {
}

mod actions;
mod command_failure;
pub use command_failure::{CommandFailure, ShellTarget};
mod adjacent;
mod argocd;
mod authz;
Expand Down
3 changes: 3 additions & 0 deletions src/app/mouse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ impl App {
/// logs, documents, pickers) without a second navigation code path;
/// clicks are table-specific (select a row, sort by a header).
pub fn handle_mouse(&mut self, m: MouseEvent) -> Result<()> {
if self.command_failure_visible() {
return Ok(());
}
if self.plugin_activity_visible() {
let code = match m.kind {
MouseEventKind::ScrollUp => KeyCode::Up,
Expand Down
16 changes: 14 additions & 2 deletions src/app/overlays.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,13 @@ impl App {
} => {
self.do_node_debug(node, image, namespace, profile);
}
ConfirmAction::Debug {
ns,
pod,
target,
image,
recovery,
} => self.do_debug(ns, pod, target, image, recovery),
ConfirmAction::CleanupDebuggers => {
self.do_cleanup_debuggers();
}
Expand Down Expand Up @@ -337,11 +344,16 @@ impl App {
self.do_set_image(ns, name, plural, container, input);
}
}
Some(PromptKind::Debug { ns, pod, target }) => {
Some(PromptKind::Debug {
ns,
pod,
target,
recovery,
}) => {
if input.is_empty() {
self.flash_warn("no debug image given");
} else {
self.do_debug(ns, pod, target, input);
self.confirm_debug(ns, pod, target, input, recovery);
}
}
// The transfer prompts chain: source path first, then the
Expand Down
Loading