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
15 changes: 14 additions & 1 deletion docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,19 @@ right - so a download or an upload is one keystroke rather than a hand-written
through right now, so the confirmation says so. Deleting pods is a mutation
like any other: blocked in read-only mode, matched by the `pvc-explore`
guardrail, recorded in `:journal`.
- **Missing tools trigger built-in recovery.** If the first listing fails
because `sh`, `ls`, or `head` is missing, sofka tries up to 16 other running
containers that mount the same part of the claim. Read-only restrictions
remain in force. If none works, it offers a helper pod and asks before
creating it. Read-only mode and the `pvc-explore` guardrail still apply.
For an in-use `ReadWriteOnce` claim, the helper is scheduled on the consumer's
node. An occupied `ReadWriteOncePod` claim cannot use a second pod. Recovery
reports that restriction without creating a helper. A static `subPath` is
preserved; a `subPathExpr` mount cannot recover automatically because its
boundary cannot be determined from the pod specification. Permission errors,
connection failures, and invalid paths retain their specific messages.
Canceling recovery leaves the original error in the browser. Reopen the claim
to start a new recovery attempt.
- **Navigation is confined to the mount.** `⌫` stops at the mount point, and
every listing verifies with `pwd -P` that it actually landed inside the
volume - so a symlink on the volume pointing at `/` is refused rather than
Expand Down Expand Up @@ -729,7 +742,7 @@ right - so a download or an upload is one keystroke rather than a hand-written
than after.

Listings are read with `ls -A -l` over `kubectl exec`, so the pod's image needs
a shell and `ls`; transfers additionally need `tar`, as `kubectl cp` always
a shell, `ls`, and `head`; transfers additionally need `tar`, as `kubectl cp` always
does. An entry `ls` cannot stat still appears, with an unknown size and a
warning, rather than blanking the whole directory. The helper-pod image and
lifetime are configurable:
Expand Down
5 changes: 5 additions & 0 deletions docs/keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,11 @@ Interactive actions (`e`, `s` for shell, `a`) suspend the TUI and shell out to
`kubectl`. Delete, scale, restart, set-image, suspend, resume, reconcile, and
port-forward go through the kube API (or a backgrounded process) directly.

If the first PVC listing fails because required tools are missing, sofka tries
other suitable containers, then offers a helper pod. Accept or cancel the
existing confirmation dialog. Volume access restrictions, read-only mode, and
guardrails still apply. Canceling keeps the original error visible.

## Plugin commands

| Command | Action |
Expand Down
11 changes: 11 additions & 0 deletions src/app/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1697,6 +1697,17 @@ impl App {
self.discard_pvc_target(namespace, context, result);
}
}
Msg::PvcRecovery {
generation,
run,
result,
} => {
if generation == self.generation {
self.handle_pvc_recovery(run, result);
} else if run == self.pvc.run {
self.pvc.loading = false;
}
}
Msg::PvcListing {
generation,
run,
Expand Down
220 changes: 211 additions & 9 deletions src/app/pvcexplore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ pub struct PvcExplore {
/// Bumped on every navigation so a slow listing for a directory the user
/// has already left is discarded instead of replacing the current one.
pub run: u64,
pub(super) recovery: Option<RecoveryState>,
listed: bool,
}

pub(super) struct RecoveryState {
pub error: String,
pub original: Mount,
candidates: std::collections::VecDeque<Mount>,
helper: Option<Result<pvc::HelperOptions, String>>,
}

impl Default for PvcExplore {
Expand All @@ -111,6 +120,8 @@ impl Default for PvcExplore {
focus: Pane::Remote,
shell_pending: false,
run: 0,
recovery: None,
listed: false,
}
}
}
Expand Down Expand Up @@ -285,7 +296,12 @@ impl App {
return;
}
match result {
Err(e) => self.set_claimed_status(status, e, true),
Err(e) => {
self.set_claimed_status(status, e.clone(), true);
if self.pvc.recovery.is_some() {
self.pvc_recovery_error(&e);
}
}
Ok(Some(mount)) => {
self.clear_claimed_status(status);
self.enter_pvc_target(namespace, mount);
Expand Down Expand Up @@ -337,15 +353,17 @@ impl App {
}
}
PvcIntent::Browse => {
self.set_return_mode();
if !self.pvc.active {
self.set_return_mode();
}
self.pvc.active = true;
self.pvc.focus = Pane::Remote;
// Seeded, not left empty: a first listing that fails restores
// the title from here, and an empty path would blank it and
// make `⌫` and `r` both misbehave.
self.pvc.displayed_path = path.clone();
self.pvc.remote.clear();
self.pvc.remote_error = None;
self.pvc.remote_error = self.pvc.recovery.as_ref().map(|r| r.error.clone());
self.pvc.truncated = false;
self.mode = Mode::PvcExplore;
self.reload_local();
Expand All @@ -361,7 +379,7 @@ impl App {
fn offer_pvc_helper(&mut self) {
if self.readonly {
self.flash_warn(&format!(
"nothing mounts {} — browsing it needs a helper pod, which read-only mode blocks",
"browsing {} needs a helper pod, which read-only mode blocks",
self.pvc.claim
));
return;
Expand Down Expand Up @@ -390,8 +408,14 @@ impl App {
return;
};
let image = self.pvc_cfg.image.clone();
let label =
format!("Nothing mounts {claim}. Create a temporary {image} pod in {ns} to mount it?");
let label = if let Some(recovery) = &self.pvc.recovery {
format!(
"{}\n\nBrowse with helper pod? Create a temporary {image} pod in {ns} for {claim}.",
recovery.error
)
} else {
format!("Nothing mounts {claim}. Create a temporary {image} pod in {ns} to mount it?")
};
self.begin_guarded(
ConfirmAction::PvcHelper {
ns,
Expand All @@ -407,19 +431,43 @@ impl App {
/// Create the helper pod and wait for it to run. `generateName` means two
/// sessions browsing the same claim never collide on a name.
pub(super) fn create_pvc_helper(&mut self, ns: String, claim: String, intent: PvcIntent) {
if self.deny_readonly()
|| self
.guard(
"pvc-explore",
"persistentvolumeclaims",
&[(claim.clone(), ns.clone())],
ConfirmLevel::None,
)
.is_none()
{
if self.pvc.recovery.is_some() {
let reason = self.flash.clone();
self.pvc_recovery_error(&reason);
}
return;
}
self.pvc.intent = intent;
self.pvc.run += 1;
let run = self.pvc.run;
let ttl = self.pvc_ttl_secs();
let manifest = pvc::helper_pod(&claim, &self.pvc_cfg.image, ttl);
let mut manifest = pvc::helper_pod(&claim, &self.pvc_cfg.image, ttl);
let original = self.pvc.recovery.as_ref().map(|r| r.original.clone());
self.note_action("pvc-explore helper pod", format!("{claim} in {ns}"));
let status = self.claim_status(format!("starting a helper pod for {claim}…"));
let context = self.cluster.context.clone();
let client = self.cluster.client.clone();
let tx = self.tx.clone();
let genr = self.generation;
tokio::spawn(async move {
let result = start_helper(client, &ns, manifest).await;
let result = async {
if let Some(original) = original {
let plan = load_recovery_plan(client.clone(), &ns, &claim, &original).await?;
pvc::apply_helper_options(&mut manifest, &plan.helper?);
}
start_helper(client, &ns, manifest).await
}
.await;
let _ = tx
.send(Msg::PvcTarget {
generation: genr,
Expand All @@ -433,6 +481,98 @@ impl App {
});
}

fn start_pvc_recovery(&mut self, error: String) {
let Some(original) = self.pvc.mount.clone() else {
return;
};
self.pvc.remote_error = Some(error.clone());
self.pvc.loading = true;
self.pvc.recovery = Some(RecoveryState {
error,
original: original.clone(),
candidates: Default::default(),
helper: None,
});
self.pvc.run += 1;
let run = self.pvc.run;
let generation = self.generation;
let client = self.cluster.client.clone();
let namespace = self.pvc.namespace.clone();
let claim = self.pvc.claim.clone();
let tx = self.tx.clone();
tokio::spawn(async move {
let result = load_recovery_plan(client, &namespace, &claim, &original).await;
let _ = tx
.send(Msg::PvcRecovery {
generation,
run,
result,
})
.await;
});
}

pub(super) fn handle_pvc_recovery(
&mut self,
run: u64,
result: Result<pvc::RecoveryPlan, String>,
) {
if run != self.pvc.run || !self.pvc.active {
return;
}
self.pvc.loading = false;
if self.mode != Mode::PvcExplore {
self.pvc_recovery_error("Recovery canceled because another view or dialog is open.");
return;
}
match result {
Ok(plan) => {
let Some(recovery) = &mut self.pvc.recovery else {
return;
};
recovery.candidates = plan.candidates.into();
recovery.helper = Some(plan.helper);
self.next_pvc_candidate();
}
Err(error) => self.pvc_recovery_error(&error),
}
}

fn next_pvc_candidate(&mut self) {
let Some(recovery) = &mut self.pvc.recovery else {
return;
};
if let Some(mount) = recovery.candidates.pop_front() {
self.enter_pvc_target(self.pvc.namespace.clone(), mount);
return;
}
let helper = recovery.helper.take();
self.pvc.loading = false;
match helper {
Some(Ok(_)) => {
self.offer_pvc_helper();
if self.mode == Mode::PvcExplore {
let reason = self.flash.clone();
self.pvc_recovery_error(&reason);
}
}
Some(Err(error)) => self.pvc_recovery_error(&error),
None => self.pvc_recovery_error(
"No further recovery targets are available. Reopen the claim to retry.",
),
}
}

fn pvc_recovery_error(&mut self, reason: &str) {
let message = match &self.pvc.recovery {
Some(recovery) => format!("{}\n\n{reason}", recovery.error),
None => reason.to_owned(),
};
self.pvc.loading = false;
self.pvc.remote_error = Some(message.clone());
self.flash_warn(&message);
}

/// `[pvc_explore] ttl`, already validated at load; a value that slipped
/// through falls back to the default rather than creating a pod that never
/// expires.
Expand Down Expand Up @@ -489,6 +629,8 @@ impl App {
self.pvc.remote_path = path.clone();
match result {
Ok((listing, warn)) => {
self.pvc.listed = true;
self.pvc.recovery = None;
self.pvc.truncated = listing.truncated;
// Re-listing the same directory keeps the cursor; stepping
// into a new one starts at the top, unless we stepped *out* of
Expand Down Expand Up @@ -517,6 +659,26 @@ impl App {
// directory, so leaving the title pointing somewhere else would
// mislabel them.
Err(e) => {
if !self.pvc.listed
&& self.mode == Mode::PvcExplore
&& self
.pvc
.mount
.as_ref()
.is_some_and(|m| !m.helper && path == m.path)
&& pvc::missing_listing_tools(&e)
{
if self.pvc.recovery.is_some() {
self.next_pvc_candidate();
} else {
self.start_pvc_recovery(e);
}
return;
}
if self.pvc.recovery.is_some() {
self.pvc_recovery_error(&e);
return;
}
self.pvc.remote_path = self.pvc.displayed_path.clone();
self.pvc.want_select = None;
// Only when the failure is about the directory still on
Expand Down Expand Up @@ -846,6 +1008,8 @@ impl App {
self.pvc.remote_path.clear();
self.pvc.displayed_path.clear();
self.pvc.want_select = None;
self.pvc.recovery = None;
self.pvc.listed = false;
}

/// Delete the helper pod, if this session created one. Best effort and
Expand Down Expand Up @@ -1203,10 +1367,47 @@ fn pod_resource() -> kube::discovery::ApiResource {
kube::discovery::ApiResource::erase::<Pod>(&())
}

async fn load_recovery_plan(
client: Client,
ns: &str,
claim: &str,
original: &Mount,
) -> Result<pvc::RecoveryPlan, String> {
let read = async {
let pods: Api<DynamicObject> = Api::namespaced_with(client.clone(), ns, &pod_resource());
let pods = pods
.list(&ListParams::default())
.await
.map_err(|e| format!("Cannot list recovery pods: {e}"))?;
let resource = kube::discovery::ApiResource::erase::<
k8s_openapi::api::core::v1::PersistentVolumeClaim,
>(&());
let claims: Api<DynamicObject> = Api::namespaced_with(client, ns, &resource);
let claim = claims
.get(claim)
.await
.map_err(|e| format!("Cannot check volume access modes: {e}"))?;
Ok(pvc::recovery_plan(&pods.items, &claim, original))
};
tokio::time::timeout(LIST_TIMEOUT, read)
.await
.map_err(|_| "Recovery checks timed out.".to_owned())?
}

/// Create the helper pod and poll until it is `Running` (or fails), returning
/// the mount to browse it through.
async fn start_helper(client: Client, ns: &str, manifest: Value) -> Result<Mount, String> {
let spec: Pod = serde_json::from_value(manifest).map_err(|e| e.to_string())?;
let volume_mount = spec
.spec
.as_ref()
.and_then(|s| s.containers.first())
.and_then(|c| c.volume_mounts.as_ref())
.and_then(|m| m.first());
let read_only = volume_mount.and_then(|m| m.read_only).unwrap_or(false);
let sub_path = volume_mount
.and_then(|m| m.sub_path.clone())
.unwrap_or_default();
let pods: Api<Pod> = Api::namespaced(client, ns);
let created = pods
.create(&PostParams::default(), &spec)
Expand All @@ -1223,7 +1424,8 @@ async fn start_helper(client: Client, ns: &str, manifest: Value) -> Result<Mount
pod: name,
container: "explore".into(),
path: HELPER_MOUNT.into(),
read_only: false,
sub_path: Some(sub_path),
read_only,
helper: true,
});
}
Expand Down
Loading