container-pid 1.0.0

Resolve container names to their PIDs
Documentation
//! This module uses `kubectl` to get a containerd id and then searches cgroups for one with that
//! id as name. It returns any pid which is a member of that group.
//!
//! Possible container_id inputs:
//!
//! - `podname` to use default namespace and first container in that pod
//! - one `/`: `namespace/podname` to override default namespace
//! - two `/`: `namespace/podname/container` to be super explicit

use crate::cmd;
use crate::result::Result;
use crate::Container;
use anyhow::{bail, Context};
use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::str::from_utf8;
use std::str::FromStr;

#[derive(Clone, Debug)]
pub(crate) struct Kubernetes {}

pub(crate) const DEFAULT_NAMESPACE: &str = "default";

impl Container for Kubernetes {
    /// There is many ways to do this:
    ///  - similar to command.rs: a bit looser pattern matching on /proc/$pid/cmdline
    ///  - the following:
    fn lookup(&self, container_id: &str) -> Result<libc::pid_t> {
        let (namespace, pod_name, container_name) = parse_userinput(container_id)
            .with_context(|| format!("failed to parse container ID '{}'", container_id))?;
        let containerdid =
            get_containerd_id(namespace, pod_name, container_name).with_context(|| {
                format!(
                    "failed to get containerd ID for pod '{}' in namespace '{}'",
                    pod_name, namespace
                )
            })?;
        let cgroup = find_cgroup(containerdid.clone()).with_context(|| {
            format!("failed to find cgroup for containerd ID '{}'", containerdid)
        })?;
        let pid = get_cgroup_pid(&cgroup)
            .with_context(|| format!("failed to get PID from cgroup '{}'", cgroup.display()))?;
        Ok(pid)
    }

    fn check_required_tools(&self) -> Result<()> {
        if cmd::which("kubectl").is_some() {
            Ok(())
        } else {
            bail!("kubernetes runtime not found: 'kubectl' command is not available")
        }
    }
}

/// allows the user to prepend the pod name with `custom-namespace/pod-name` to override the
/// namespace (`default`). By default this will take the first container of the pod. That however
/// can be overridden by appending it like `namespace/podname/container`.
pub(crate) fn parse_userinput(container_id: &str) -> Result<(&str, &str, Option<&str>)> {
    let fields = container_id.splitn(3, '/').collect::<Vec<&str>>();
    if fields.len() == 1 {
        return Ok((DEFAULT_NAMESPACE, container_id, None));
    } else if fields.len() == 2 {
        return Ok((fields[0], fields[1], None));
    } else if fields.len() == 3 {
        return Ok((fields[0], fields[1], Some(fields[2])));
    }
    unreachable!();
}

/// find `containerd://hash` id and return hash.
/// Potentially vulnerable: passes unchecked user supplied strings to command.
pub(crate) fn get_containerd_id(
    namespace: &str,
    pod_name: &str,
    container_name: Option<&str>,
) -> Result<String> {
    let jsonpath = format!("jsonpath='{{range .items[?(@.metadata.name==\"{}\")].status.containerStatuses[*]}}{{.name}}{{\"\\t\"}}{{.containerID}}{{\"\\n\"}}{{end}}'", pod_name);
    let result = Command::new("kubectl")
        .arg("get")
        .arg("pod")
        .arg("-o")
        .arg(jsonpath)
        .arg("-n")
        .arg(namespace)
        .output()
        .context("failed to execute 'kubectl get pod'")?;

    if !result.status.success() {
        let stderr = String::from_utf8_lossy(&result.stderr);
        bail!(
            "kubectl get pod failed (exit status {}): {}",
            result.status,
            stderr
        );
    }

    let containers =
        from_utf8(&result.stdout).context("kubectl response contains non-UTF8 data")?;
    let containerid = containers.split('\n').find_map(|line| {
        // line = "containername\tcontainerdid"
        let cols: Vec<&str> = line.split('\t').collect();
        if cols.len() != 2 {
            return None;
        }
        if let Some(name) = container_name {
            // return name-matching containerid
            if cols[0] == name {
                return Some(cols[1]);
            }
        } else {
            // return any containerid
            return Some(cols[1]);
        }
        None
    });

    let containerid = containerid.ok_or_else(|| {
        if let Some(name) = container_name {
            anyhow::anyhow!("no container named '{}' found in pod '{}'", name, pod_name)
        } else {
            anyhow::anyhow!("no containers found in pod '{}'", pod_name)
        }
    })?;

    let containerid = containerid.strip_prefix("containerd://").ok_or_else(|| {
        anyhow::anyhow!(
            "container ID does not have expected 'containerd://' prefix: {}",
            containerid
        )
    })?;
    Ok(String::from(containerid))
}

pub(crate) fn find_cgroup(containerdid: String) -> Result<PathBuf> {
    let path = visit_dirs(
        &PathBuf::from("/sys/fs/cgroup"),
        &OsString::from(containerdid),
    )?;
    Ok(path)
}

// one possible implementation of walking a directory from
// https://doc.rust-lang.org/std/fs/fn.read_dir.html
fn visit_dirs(dir: &Path, containerdid: &OsString) -> Result<PathBuf> {
    for entry in std::fs::read_dir(dir)
        .with_context(|| format!("failed to read directory '{}'", dir.display()))?
    {
        let entry = entry
            .with_context(|| format!("failed to read entry in directory '{}'", dir.display()))?;
        if &entry.file_name() == containerdid {
            return Ok(entry.path());
        }
        let path = entry.path();
        if path.is_dir() {
            if let Ok(path) = visit_dirs(&path, containerdid) {
                return Ok(path);
            }
        }
    }
    bail!("cgroup not found in directory tree");
}

/// return any pid part of this cgroup
pub(crate) fn get_cgroup_pid(cgroup: &Path) -> Result<libc::pid_t> {
    let path = cgroup.join("cgroup.procs");
    let bytes = fs::read(&path)
        .with_context(|| format!("failed to read cgroup.procs file at '{}'", path.display()))?;
    let pids = String::from_utf8(bytes).context("cgroup.procs contains non-UTF8 data")?;
    let pids = pids.splitn(2, '\n').collect::<Vec<&str>>()[0]; // first line
    let pid: u64 = u64::from_str(pids)
        .with_context(|| format!("invalid PID value '{}' in cgroup.procs", pids))?;
    Ok(pid as libc::pid_t)
}