This commit is contained in:
Waylon S. Walker 2025-03-25 09:42:49 -05:00
parent 5238ef5f6b
commit 256e62377e

390
krayt.py
View file

@ -16,12 +16,11 @@ Hunt down storage issues and explore your persistent data like a true Tatooine d
May the Force be with your volumes! May the Force be with your volumes!
""" """
import os
import glob
from pathlib import Path
from iterfzf import iterfzf from iterfzf import iterfzf
from kubernetes import client, config from kubernetes import client, config
import logging import logging
import os
from pathlib import Path
import time import time
import typer import typer
from typing import Any, Optional from typing import Any, Optional
@ -101,47 +100,57 @@ def fuzzy_select(items):
if not items: if not items:
return None, None return None, None
# Format items as "namespace/name" for display # If there's only one item, return it without prompting
formatted_items = [f"{ns}/{name}" for name, ns in items] if len(items) == 1:
logging.debug(f"Found {len(formatted_items)} pods") return items[0]
# Format items for display
formatted_items = [f"{name} ({namespace})" for name, namespace in items]
# Use fzf for selection
try: try:
# Use iterfzf for selection
selected = iterfzf(formatted_items) selected = iterfzf(formatted_items)
if not selected:
if selected:
namespace, name = selected.split("/")
logging.debug(f"Selected pod {name} in namespace {namespace}")
return name, namespace
else:
logging.debug("No selection made")
return None, None return None, None
# Parse selection back into name and namespace
# Example: "pod-name (namespace)" -> ("pod-name", "namespace")
name = selected.split(" (")[0]
namespace = selected.split(" (")[1][:-1]
return name, namespace
except Exception as e: except Exception as e:
logging.error(f"Error during selection: {e}", exc_info=True) typer.echo(f"Error during selection: {e}")
typer.echo(f"Error during selection: {e}", err=True) return None, None
raise typer.Exit(1)
def get_pods(namespace=None): def get_pods(
namespace=None,
label_selector: str = "app=krayt",
):
"""Get list of pods in the specified namespace or all namespaces""" """Get list of pods in the specified namespace or all namespaces"""
config.load_kube_config()
v1 = client.CoreV1Api()
try: try:
config.load_kube_config()
api = client.CoreV1Api()
if namespace: if namespace:
logging.debug(f"Listing pods in namespace {namespace}") pods = api.list_namespaced_pod(
pod_list = v1.list_namespaced_pod(namespace=namespace) namespace=namespace,
label_selector=label_selector,
)
else: else:
logging.debug("Listing pods in all namespaces") pods = api.list_pod_for_all_namespaces(
pod_list = v1.list_pod_for_all_namespaces() label_selector=label_selector,
)
pods = [(pod.metadata.name, pod.metadata.namespace) for pod in pod_list.items] # Convert to list of (name, namespace) tuples
logging.debug(f"Found {len(pods)} pods") pod_list = []
return pods for pod in pods.items:
except client.exceptions.ApiException as e: if pod.metadata.namespace not in PROTECTED_NAMESPACES:
logging.error(f"Error listing pods: {e}") pod_list.append((pod.metadata.name, pod.metadata.namespace))
typer.echo(f"Error listing pods: {e}", err=True) return pod_list
except client.rest.ApiException as e:
typer.echo(f"Error listing pods: {e}")
raise typer.Exit(1) raise typer.Exit(1)
@ -206,8 +215,14 @@ def get_env_vars_and_secret_volumes(api, namespace: str):
volumes = [] volumes = []
# Add proxy environment variables if they exist in the host environment # Add proxy environment variables if they exist in the host environment
proxy_vars = ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", proxy_vars = [
"http_proxy", "https_proxy", "no_proxy"] "HTTP_PROXY",
"HTTPS_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"no_proxy",
]
for var in proxy_vars: for var in proxy_vars:
if var in os.environ: if var in os.environ:
@ -218,9 +233,8 @@ def get_env_vars_and_secret_volumes(api, namespace: str):
secrets = api.list_namespaced_secret(namespace) secrets = api.list_namespaced_secret(namespace)
for secret in secrets.items: for secret in secrets.items:
# Skip service account tokens and other system secrets # Skip service account tokens and other system secrets
if ( if secret.type != "Opaque" or secret.metadata.name.startswith(
secret.type != "Opaque" "default-token-"
or secret.metadata.name.startswith("default-token-")
): ):
continue continue
@ -259,15 +273,15 @@ def get_init_scripts():
for script in scripts: for script in scripts:
try: try:
with open(script, 'r') as f: with open(script, "r") as f:
script_content = f.read() script_content = f.read()
if script_content: if script_content:
init_script += f"echo '=== Running {script.name} ==='\n" init_script += f"echo '=== Running {script.name} ==='\n"
# Write each script to a separate file # Write each script to a separate file
init_script += f"cat > /tmp/{script.name} << 'EOFSCRIPT'\n" init_script += f"cat > /tmp/{script.name} << 'EOFSCRIPT'\n"
init_script += script_content init_script += script_content
if not script_content.endswith('\n'): if not script_content.endswith("\n"):
init_script += '\n' init_script += "\n"
init_script += "EOFSCRIPT\n\n" init_script += "EOFSCRIPT\n\n"
# Make it executable and run it # Make it executable and run it
init_script += f"chmod +x /tmp/{script.name}\n" init_script += f"chmod +x /tmp/{script.name}\n"
@ -284,7 +298,7 @@ def get_motd_script(mount_info, pvc_info):
"""Generate the MOTD script with proper escaping""" """Generate the MOTD script with proper escaping"""
return f""" return f"""
# Create MOTD # Create MOTD
cat << 'EOF' > /etc/motd cat << EOF > /etc/motd
==================================== ====================================
Krayt Dragon's Lair Krayt Dragon's Lair
A safe haven for volume inspection A safe haven for volume inspection
@ -293,21 +307,27 @@ A safe haven for volume inspection
"Inside every volume lies a pearl of wisdom waiting to be discovered." "Inside every volume lies a pearl of wisdom waiting to be discovered."
Mounted Volumes: Mounted Volumes:
$(echo "{','.join(mount_info)}" | tr ',' '\\n' | sed 's/^/- /') $(echo "{",".join(mount_info)}" | tr ',' '\\n' | sed 's/^/- /')
Persistent Volume Claims: Persistent Volume Claims:
$(echo "{','.join(pvc_info)}" | tr ',' '\\n' | sed 's/^/- /') $(echo "{",".join(pvc_info)}" | tr ',' '\\n' | sed 's/^/- /')
Mounted Secrets: Mounted Secrets:
$(for d in /mnt/secrets/*; do if [ -d "$d" ]; then echo "- $(basename $d)"; fi; done) $(for d in /mnt/secrets/*; do if [ -d "$d" ]; then echo "- $(basename $d)"; fi; done)
Init Script Status: Init Script Status:
$(if [ -f /tmp/init.log ]; then echo "View initialization log at /tmp/init.log"; fi) $(if [ -f /tmp/init.log ]; then echo "View initialization log at /tmp/init.log"; fi)
EOF""" EOF
"""
def create_inspector_job( def create_inspector_job(
api, namespace: str, pod_name: str, volume_mounts: list, volumes: list, image: str = "alpine:latest" api,
namespace: str,
pod_name: str,
volume_mounts: list,
volumes: list,
image: str = "alpine:latest",
): ):
"""Create a Krayt inspector job with the given mounts""" """Create a Krayt inspector job with the given mounts"""
timestamp = int(time.time()) timestamp = int(time.time())
@ -351,82 +371,134 @@ def create_inspector_job(
command_parts = [] command_parts = []
# Configure apk proxy settings BEFORE any package installation # Configure apk proxy settings BEFORE any package installation
command_parts.extend([ command_parts.extend(
"# Configure apk proxy settings", [
"mkdir -p /etc/apk", "# Configure apk proxy settings",
"cat > /etc/apk/repositories << 'EOF'", "mkdir -p /etc/apk",
"https://dl-cdn.alpinelinux.org/alpine/latest-stable/main", "cat > /etc/apk/repositories << 'EOF'",
"https://dl-cdn.alpinelinux.org/alpine/latest-stable/community", "https://dl-cdn.alpinelinux.org/alpine/latest-stable/main",
"EOF", "https://dl-cdn.alpinelinux.org/alpine/latest-stable/community",
"", "EOF",
"if [ ! -z \"$HTTP_PROXY\" ]; then", "",
" echo \"Setting up apk proxy configuration...\"", 'if [ ! -z "$HTTP_PROXY" ]; then',
" mkdir -p /etc/apk/", ' echo "Setting up apk proxy configuration..."',
" cat > /etc/apk/repositories << EOF", " mkdir -p /etc/apk/",
"#/media/cdrom/apks", " cat > /etc/apk/repositories << EOF",
"http://dl-cdn.alpinelinux.org/alpine/latest-stable/main", "#/media/cdrom/apks",
"http://dl-cdn.alpinelinux.org/alpine/latest-stable/community", "http://dl-cdn.alpinelinux.org/alpine/latest-stable/main",
"", "http://dl-cdn.alpinelinux.org/alpine/latest-stable/community",
"# Configure proxy", "",
"proxy=$HTTP_PROXY", "# Configure proxy",
"EOF", "proxy=$HTTP_PROXY",
"fi", "EOF",
"" "fi",
]) "",
"# Install basic tools first",
"apk update",
"apk add curl",
"",
"# Install uv CLI",
"echo 'Installing uv CLI...'",
"curl -LsSf https://astral.sh/uv/install.sh | sh",
"echo 'uv version:'",
"uv --version",
"",
"echo 'Installing starship...'",
"curl -sS https://starship.rs/install.sh | sh -s -- -y",
"echo 'starship version:'",
"starship --version",
"",
"",
"# Install additional tools",
"apk add "
+ " ".join(
[
"ripgrep",
"exa",
"ncdu",
"dust",
"file",
"hexyl",
"jq",
"yq",
"bat",
"fd",
"fzf",
"htop",
"bottom",
"difftastic",
"mtr",
"bind-tools",
"aws-cli",
"sqlite",
"sqlite-dev",
"sqlite-libs",
"bash",
"neovim",
]
),
"",
]
)
# Add init scripts if present # Add init scripts if present
if init_scripts: if init_scripts:
command_parts.extend([ command_parts.extend(
"# Write and run init scripts", [
"mkdir -p /tmp/init.d", "# Write and run init scripts",
"cat > /tmp/init.sh << 'EOFSCRIPT'", "mkdir -p /tmp/init.d",
init_scripts, "cat > /tmp/init.sh << 'EOFSCRIPT'",
"EOFSCRIPT", init_scripts,
"", "EOFSCRIPT",
"# Make init script executable and run it", "",
"chmod +x /tmp/init.sh", "# Make init script executable and run it",
"/tmp/init.sh 2>&1 | tee /tmp/init.log", "chmod +x /tmp/init.sh",
"echo 'Init script log available at /tmp/init.log'", "/tmp/init.sh 2>&1 | tee /tmp/init.log",
"" "echo 'Init script log available at /tmp/init.log'",
]) "",
]
)
# Add base installation commands AFTER proxy configuration # Add shell setup and MOTD
command_parts.extend([ command_parts.extend(
"# Install basic tools first", [
"apk update", "# Create .ashrc with MOTD",
"apk add curl", "cat > /root/.ashrc << 'EOF'",
"", "# Display MOTD on login",
"# Install additional tools", "[ -f /etc/motd ] && cat /etc/motd",
"apk add ripgrep exa ncdu dust file hexyl jq yq bat fd fzf htop bottom difftastic mtr bind-tools aws-cli sqlite sqlite-dev sqlite-libs", "# Set up shell environment",
"", "export EDITOR=vi",
"# Create .ashrc with MOTD", "export PAGER=less",
"cat > /root/.ashrc << 'EOF'", "# Set up aliases",
"# Display MOTD on login", "alias ll='ls -la'",
"[ -f /etc/motd ] && cat /etc/motd", "alias l='ls -la'",
"EOF", "alias la='ls -la'",
"", "alias vi='vim'",
"# Set up shell environment", "# Set up PATH",
"export EDITOR=vi", "export PATH=/root/.local/bin:$PATH",
"export PAGER=less", 'eval "$(starship init bash)"',
"", "EOF",
"# Set up environment to always source our RC file", "",
"echo 'export ENV=/root/.ashrc' > /etc/profile", "",
"echo 'export ENV=/root/.ashrc' > /etc/environment", "# Set up environment to always source our RC file",
"", "echo 'export ENV=/root/.ashrc' > /etc/profile",
"# Make RC file available to all shells", "echo 'export ENV=/root/.ashrc' > /etc/environment",
"mkdir -p /etc/profile.d", "",
"cp /root/.ashrc /etc/profile.d/motd.sh", "# Make RC file available to all shells",
"ln -sf /root/.ashrc /root/.profile", "mkdir -p /etc/profile.d",
"ln -sf /root/.ashrc /root/.bashrc", "cp /root/.ashrc /etc/profile.d/motd.sh",
"ln -sf /root/.ashrc /root/.mkshrc", "ln -sf /root/.ashrc /root/.profile",
"ln -sf /root/.ashrc /etc/shinit", "ln -sf /root/.ashrc /root/.bashrc",
"", "ln -sf /root/.ashrc /root/.mkshrc",
"# Update MOTD", "ln -sf /root/.ashrc /etc/shinit",
get_motd_script(mount_info, pvc_info), "",
"", "# Update MOTD",
"# Keep container running", get_motd_script(mount_info, pvc_info),
"tail -f /dev/null" "",
]) "# Keep container running",
"tail -f /dev/null",
]
)
inspector_job = { inspector_job = {
"apiVersion": "batch/v1", "apiVersion": "batch/v1",
@ -435,25 +507,17 @@ def create_inspector_job(
"name": job_name, "name": job_name,
"namespace": namespace, "namespace": namespace,
"labels": {"app": "krayt"}, "labels": {"app": "krayt"},
"annotations": { "annotations": {"pvcs": ",".join(pvc_info) if pvc_info else "none"},
"pvcs": ",".join(pvc_info) if pvc_info else "none"
}
}, },
"spec": { "spec": {
"template": { "template": {
"metadata": { "metadata": {"labels": {"app": "krayt"}},
"labels": {"app": "krayt"}
},
"spec": { "spec": {
"containers": [ "containers": [
{ {
"name": "inspector", "name": "inspector",
"image": image, "image": image,
"command": [ "command": ["sh", "-c", "\n".join(command_parts)],
"sh",
"-c",
"\n".join(command_parts)
],
"env": env_vars, "env": env_vars,
"volumeMounts": formatted_mounts, "volumeMounts": formatted_mounts,
} }
@ -495,7 +559,7 @@ def load_init_scripts():
for script in scripts: for script in scripts:
try: try:
with open(script, 'r') as f: with open(script, "r") as f:
exec(f.read(), globals()) exec(f.read(), globals())
logging.debug(f"Loaded init script: {script}") logging.debug(f"Loaded init script: {script}")
except Exception as e: except Exception as e:
@ -505,8 +569,14 @@ def load_init_scripts():
def setup_environment(): def setup_environment():
"""Set up the environment with proxy settings and other configurations""" """Set up the environment with proxy settings and other configurations"""
# Load environment variables for proxies # Load environment variables for proxies
proxy_vars = ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", proxy_vars = [
"http_proxy", "https_proxy", "no_proxy"] "HTTP_PROXY",
"HTTPS_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"no_proxy",
]
for var in proxy_vars: for var in proxy_vars:
if var in os.environ: if var in os.environ:
@ -596,7 +666,7 @@ def exec(
"--", "--",
"/bin/sh", "/bin/sh",
"-c", "-c",
"cat /etc/motd; exec /bin/ash -l" "cat /etc/motd; exec /bin/ash -l",
] ]
os.execvp("kubectl", exec_command) os.execvp("kubectl", exec_command)
@ -706,7 +776,8 @@ def create(
If namespace is not specified, will search for pods across all namespaces. If namespace is not specified, will search for pods across all namespaces.
The inspector will be created in the same namespace as the selected pod. The inspector will be created in the same namespace as the selected pod.
""" """
pods = get_pods(namespace) # For create, we want to list all pods, not just Krayt pods
pods = get_pods(namespace, label_selector=None)
if not pods: if not pods:
typer.echo("No pods found.") typer.echo("No pods found.")
raise typer.Exit(1) raise typer.Exit(1)
@ -720,7 +791,12 @@ def create(
volume_mounts, volumes = get_pod_volumes_and_mounts(pod_spec) volume_mounts, volumes = get_pod_volumes_and_mounts(pod_spec)
inspector_job = create_inspector_job( inspector_job = create_inspector_job(
client.CoreV1Api(), selected_namespace, selected_pod, volume_mounts, volumes, image=image client.CoreV1Api(),
selected_namespace,
selected_pod,
volume_mounts,
volumes,
image=image,
) )
# Output the job manifest # Output the job manifest
@ -733,6 +809,54 @@ def version():
typer.echo(f"Version: {KRAYT_VERSION}") typer.echo(f"Version: {KRAYT_VERSION}")
@app.command()
def logs(
namespace: Optional[str] = typer.Option(
None,
help="Kubernetes namespace. If not specified, will search for inspectors across all namespaces.",
),
follow: bool = typer.Option(
False,
"--follow",
"-f",
help="Follow the logs in real-time",
),
):
"""
View logs from a Krayt inspector pod.
If multiple inspectors are found, you'll get to choose which one to explore.
"""
pods = get_pods(namespace)
if not pods:
typer.echo("No pods found.")
raise typer.Exit(1)
selected_pod, selected_namespace = fuzzy_select(pods)
if not selected_pod:
typer.echo("No pod selected.")
raise typer.Exit(1)
try:
config.load_kube_config()
api = client.CoreV1Api()
logs = api.read_namespaced_pod_log(
name=selected_pod,
namespace=selected_namespace,
follow=follow,
_preload_content=False,
)
if follow:
for line in logs:
typer.echo(line.decode("utf-8").strip())
else:
typer.echo(logs.read().decode("utf-8"))
except client.rest.ApiException as e:
typer.echo(f"Error reading logs: {e}")
raise typer.Exit(1)
def main(): def main():
setup_environment() setup_environment()
load_init_scripts() load_init_scripts()