kobectl 0.50.1

kobe — CLI for the kobe cluster-pool operator: lease, inspect and manage instant CI/dev Kubernetes clusters
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};

#[derive(Debug, Default, Serialize, Deserialize)]
struct CliState {
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    lease_artifacts: BTreeMap<String, LeaseArtifact>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
struct LeaseArtifact {
    kubeconfig_path: String,
}

impl CliState {
    fn load() -> Result<Self> {
        let path = state_path()?;
        if !path.exists() {
            return Ok(Self::default());
        }
        let data = std::fs::read_to_string(&path)?;
        Ok(serde_json::from_str(&data)?)
    }

    fn save(&self) -> Result<()> {
        let path = state_path()?;
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let data = serde_json::to_string_pretty(self)?;
        std::fs::write(&path, data)?;
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
        }
        Ok(())
    }
}

pub(crate) fn record_kubeconfig(endpoint: &str, lease_id: &str, path: &Path) -> Result<()> {
    let mut state = CliState::load()?;
    state.lease_artifacts.insert(
        lease_key(endpoint, lease_id),
        LeaseArtifact {
            kubeconfig_path: path.display().to_string(),
        },
    );
    state.save()
}

pub(crate) fn forget_kubeconfig(endpoint: &str, lease_id: &str) -> Result<()> {
    let mut state = CliState::load()?;
    if state
        .lease_artifacts
        .remove(&lease_key(endpoint, lease_id))
        .is_some()
    {
        state.save()?;
    }
    Ok(())
}

pub(crate) fn remove_kubeconfig(endpoint: &str, lease_id: &str) -> Result<Option<PathBuf>> {
    // Load state up front and propagate a failure BEFORE deleting anything.
    //
    // This used to tolerate an unreadable state file and fall through to the
    // guessed default path, which was harmless only because the subsequent
    // `forget_kubeconfig` re-read the file and errored out before the removal.
    // Now that the removal happens first, that tolerance would delete a
    // kubeconfig on the strength of a guess — and `release` calls this before
    // it validates the DELETE response and discards the error, so a corrupt
    // state file plus a failed release would remove the local kubeconfig for a
    // lease that is still active.
    let state = CliState::load()?;
    let recorded = state
        .lease_artifacts
        .get(&lease_key(endpoint, lease_id))
        .map(|artifact| PathBuf::from(&artifact.kubeconfig_path));

    let path = recorded.unwrap_or_else(|| default_kubeconfig_path(lease_id));

    // Remove the file FIRST, and drop the tracking entry only once it is
    // actually gone. Forgetting first — as this did — meant that a file which
    // could not be removed (a custom `--kubeconfig` path that is now a
    // directory, or one the user made read-only) lost the state entry pointing
    // at it, so `purge --orphans-only` could never rediscover it, and a custom
    // path outside `~/.kube/kobe-*.yaml` escaped a future full purge too. Same
    // ordering rule `purge_orphans_only` already documents.
    match std::fs::remove_file(&path) {
        Ok(()) => {
            forget_kubeconfig(endpoint, lease_id)?;
            Ok(Some(path))
        }
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
            // Nothing on disk, so the entry is stale — dropping it is correct.
            forget_kubeconfig(endpoint, lease_id)?;
            Ok(None)
        }
        Err(err) => Err(err).with_context(|| format!("remove kubeconfig {}", path.display())),
    }
}

pub(crate) fn endpoint_kubeconfigs(endpoint: &str) -> Result<Vec<PathBuf>> {
    let state = CliState::load()?;
    let prefix = format!("{endpoint}::");
    Ok(state
        .lease_artifacts
        .iter()
        .filter(|(key, _)| key.starts_with(&prefix))
        .map(|(_, artifact)| PathBuf::from(&artifact.kubeconfig_path))
        .collect())
}

/// State-tracked kubeconfigs whose lease is not in the supplied active set.
///
/// Conservative: only considers entries we recorded ourselves (`record_kubeconfig`).
/// Freestanding `~/.kube/kobe-*.yaml` files we never tracked are left alone — we
/// cannot prove they correspond to an expired lease without parsing the filename
/// and risking a false positive on an unrelated user-managed file.
pub(crate) fn find_orphan_kubeconfigs(
    endpoint: &str,
    active_lease_ids: &BTreeSet<String>,
) -> Result<Vec<OrphanKubeconfig>> {
    let state = CliState::load()?;
    let prefix = format!("{endpoint}::");
    let mut orphans = Vec::new();
    for (key, artifact) in &state.lease_artifacts {
        let Some(lease_id) = key.strip_prefix(&prefix) else {
            continue;
        };
        if active_lease_ids.contains(lease_id) {
            continue;
        }
        let path = PathBuf::from(&artifact.kubeconfig_path);
        if !path_is_present(&path) {
            continue;
        }
        orphans.push(OrphanKubeconfig {
            lease_id: lease_id.to_string(),
            path,
        });
    }
    Ok(orphans)
}

#[derive(Debug, Clone)]
pub(crate) struct OrphanKubeconfig {
    pub lease_id: String,
    pub path: PathBuf,
}

pub(crate) fn forget_endpoint_kubeconfigs(endpoint: &str) -> Result<()> {
    let mut state = CliState::load()?;
    let prefix = format!("{endpoint}::");
    state
        .lease_artifacts
        .retain(|key, _| !key.starts_with(&prefix));
    state.save()?;
    Ok(())
}

pub(crate) fn local_kubeconfig_candidates() -> Result<Vec<PathBuf>> {
    let kube_dir = dirs::home_dir()
        .unwrap_or_else(|| PathBuf::from("."))
        .join(".kube");
    if !kube_dir.exists() {
        return Ok(Vec::new());
    }

    let mut candidates = Vec::new();
    for entry in std::fs::read_dir(&kube_dir)? {
        let entry = entry?;
        let path = entry.path();
        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
            continue;
        };

        let is_current_style = name.starts_with("kobe-") && name.ends_with(".yaml");
        let is_legacy_style = name.starts_with("kobe-lease-");
        if !(is_current_style || is_legacy_style) {
            continue;
        }
        candidates.push(path);
    }

    Ok(candidates)
}

pub(crate) fn resolve_kubeconfig_path(endpoint: &str, lease_id: &str) -> Option<String> {
    if let Ok(state) = CliState::load()
        && let Some(artifact) = state.lease_artifacts.get(&lease_key(endpoint, lease_id))
        && Path::new(&artifact.kubeconfig_path).exists()
    {
        return Some(artifact.kubeconfig_path.clone());
    }

    let default = default_kubeconfig_path(lease_id);
    if default.exists() {
        return Some(default.display().to_string());
    }

    None
}

pub(crate) fn default_kubeconfig_path(lease_id: &str) -> PathBuf {
    dirs::home_dir()
        .unwrap_or_else(|| PathBuf::from("."))
        .join(".kube")
        .join(format!("kobe-{lease_id}"))
}

/// Whether something exists at `path`, INCLUDING a broken symlink.
///
/// `Path::exists()` follows symlinks and reports false for a dangling one, so
/// using it here made orphan discovery skip exactly the case `purge` was fixed
/// to clean up: the link stayed on disk, its tracking entry stayed behind it,
/// and `--orphans-only` reported "No orphan kubeconfigs found".
fn path_is_present(path: &Path) -> bool {
    path.symlink_metadata().is_ok()
}

fn lease_key(endpoint: &str, lease_id: &str) -> String {
    format!("{endpoint}::{lease_id}")
}

fn state_path() -> Result<PathBuf> {
    let dir =
        dirs::config_dir().ok_or_else(|| anyhow::anyhow!("Cannot determine config directory"))?;
    Ok(dir.join("kobe").join("state.json"))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn path_is_present_for_an_ordinary_file() {
        let dir = tempfile::tempdir().unwrap();
        let f = dir.path().join("kobe-lease-x.yaml");
        std::fs::write(&f, "x").unwrap();
        assert!(path_is_present(&f));
    }

    #[test]
    fn path_is_absent_when_nothing_is_there() {
        let dir = tempfile::tempdir().unwrap();
        assert!(!path_is_present(&dir.path().join("nope.yaml")));
    }

    /// The case orphan discovery used to skip. `Path::exists()` is false here,
    /// which is why it must not be the predicate.
    #[test]
    #[cfg(unix)]
    fn path_is_present_for_a_dangling_symlink() {
        let dir = tempfile::tempdir().unwrap();
        let link = dir.path().join("kobe-lease-dangling.yaml");
        std::os::unix::fs::symlink(dir.path().join("missing"), &link).unwrap();
        assert!(!link.exists(), "precondition: exists() is false");
        assert!(
            path_is_present(&link),
            "a dangling symlink is still on disk and must be discoverable"
        );
    }

    #[test]
    fn lease_key_is_endpoint_scoped() {
        assert_eq!(lease_key("https://a", "lease-1"), "https://a::lease-1");
        // The separator is what stops one endpoint's prefix matching another's
        // keys when one endpoint string is a prefix of the other.
        assert!(!lease_key("https://a/api", "lease-1").starts_with("https://a::"));
    }
}