release 0.0.0

This commit is contained in:
Waylon S. Walker 2025-03-24 14:53:16 -05:00
parent 24256b767d
commit 778f7fbd7c
8 changed files with 513 additions and 57 deletions

3
CHANGELOG.md Normal file
View file

@ -0,0 +1,3 @@
## 0.0.0
- Initial release

80
README.md Normal file
View file

@ -0,0 +1,80 @@
# Krayt - The Kubernetes Volume Inspector
Like cracking open a Krayt dragon pearl, this tool helps you inspect what's inside your Kubernetes volumes.
Hunt down storage issues and explore your persistent data like a true Tatooine dragon hunter.
## Features
- 🔍 Create inspector pods with all the tools you need
- 📦 Access volumes and device mounts from any pod
- 🔎 Fuzzy search across all namespaces
- 🛠️ Built-in tools for file exploration and analysis
- 🧹 Automatic cleanup of inspector pods
## Installation
### Quick Install (Linux)
```bash
# Install latest version
curl -sSL https://github.com/waylonwalker/krayt/releases/latest/download/install.sh | sudo bash
# Install specific version
curl -sSL https://github.com/waylonwalker/krayt/releases/download/v0.1.0/install.sh | sudo bash
```
This will install the `krayt` command to `/usr/local/bin`.
### Manual Installation
1. Download the latest release for your platform from the [releases page](https://github.com/waylonwalker/krayt/releases)
2. Extract the archive: `tar xzf krayt-*.tar.gz`
3. Move the binary: `sudo mv krayt-*/krayt /usr/local/bin/krayt`
4. Make it executable: `sudo chmod +x /usr/local/bin/krayt`
## Usage
```bash
# Create a new inspector and apply it directly
krayt create | kubectl apply -f -
# Or review the manifest first
krayt create > inspector.yaml
kubectl apply -f inspector.yaml
# Connect to a running inspector
krayt exec
# Clean up inspectors
krayt clean
# Show version
krayt version
```
### Available Tools
Your inspector pod comes equipped with a full arsenal of tools:
- **File Navigation**: `lf`, `exa`, `fd`
- **Search & Analysis**: `ripgrep`, `bat`, `hexyl`
- **Disk Usage**: `ncdu`, `dust`
- **File Comparison**: `difftastic`
- **System Monitoring**: `bottom`, `htop`
- **JSON/YAML Tools**: `jq`, `yq`
- **Network Tools**: `mtr`, `dig`
- **Cloud & Database**: `aws-cli`, `sqlite3`
## Quotes from the Field
> "Inside every volume lies a pearl of wisdom waiting to be discovered."
>
> -- Ancient Tatooine proverb
> "The path to understanding your storage is through exploration."
>
> -- Krayt dragon hunter's manual
## May the Force be with your volumes!
Remember: A Krayt dragon's pearl is valuable not just for what it is, but for what it reveals about the dragon that created it. Similarly, your volumes tell a story about your application's data journey.

72
justfile Normal file
View file

@ -0,0 +1,72 @@
delete-tag:
#!/usr/bin/env bash
set -euo pipefail
# Get the version
VERSION=$(cat version)
# Delete the tag
git tag -d "v$VERSION"
git push origin ":refs/tags/v$VERSION"
delete-release:
#!/usr/bin/env bash
set -euo pipefail
# Get the version
VERSION=$(cat version)
# Delete the release
gh release delete "v$VERSION"
create-tag:
#!/usr/bin/env bash
VERSION=$(cat version)
git tag -a "v$VERSION" -m "Release v$VERSION"
git push origin "v$VERSION"
create-archives:
#!/usr/bin/env bash
VERSION=$(cat version)
rm -rf dist build
mkdir -p dist
# Create the binary for each platform
for platform in "x86_64-unknown-linux-gnu" "aarch64-unknown-linux-gnu"; do
outdir="krayt-${VERSION}-${platform}"
mkdir -p "dist/${outdir}"
# Copy the Python script and update version
cp krayt.py "dist/${outdir}/krayt.py"
sed -i "s/NIGHTLY/${VERSION}/" "dist/${outdir}/krayt.py"
cd dist
tar czf "${outdir}.tar.gz" "${outdir}"
sha256sum "${outdir}.tar.gz" > "${outdir}.tar.gz.sha256"
cd ..
done
# Generate install.sh
./scripts/generate_install_script.py "$VERSION"
chmod +x dist/install.sh
create-release: create-tag create-archives
#!/usr/bin/env bash
VERSION=$(cat version)
./scripts/get_release_notes.py "$VERSION" > release_notes.tmp
gh release create "v$VERSION" \
--title "v$VERSION" \
--notes-file release_notes.tmp \
dist/krayt-${VERSION}-x86_64-unknown-linux-gnu.tar.gz \
dist/krayt-${VERSION}-x86_64-unknown-linux-gnu.tar.gz.sha256 \
dist/krayt-${VERSION}-aarch64-unknown-linux-gnu.tar.gz \
dist/krayt-${VERSION}-aarch64-unknown-linux-gnu.tar.gz.sha256 \
dist/install.sh
rm release_notes.tmp
preview-release-notes:
#!/usr/bin/env bash
VERSION=$(cat version)
./scripts/get_release_notes.py "$VERSION" | less -R
release: create-release

View file

@ -1,21 +1,38 @@
#!/usr/bin/env -S uv run --quiet --script
# /// script
# requires-python = ">=3.12"
# requires-python = ">=3.10"
# dependencies = [
# "typer",
# "kubernetes",
# "iterfzf"
# ]
# ///
"""
Krayt - The Kubernetes Volume Inspector
Like cracking open a Krayt dragon pearl, this tool helps you inspect what's inside your Kubernetes volumes.
Hunt down storage issues and explore your persistent data like a true Tatooine dragon hunter.
Features:
- Create inspector pods with all the tools you need
- Access volumes and device mounts from any pod
- Fuzzy search across all namespaces
- Built-in tools for file exploration and analysis
- Automatic cleanup of inspector pods
May the Force be with your volumes!
"""
from iterfzf import iterfzf
from kubernetes import client, config
import logging
import os
import time
import typer
from typing import Any, Optional
import yaml
import os
KRAYT_VERSION = "NIGHTLY"
logging.basicConfig(level=logging.WARNING)
@ -57,9 +74,7 @@ def format_volume(v: client.V1Volume) -> dict[str, Any]:
volume_source = None
if v.persistent_volume_claim:
volume_source = {
"persistentVolumeClaim": {
"claimName": v.persistent_volume_claim.claim_name
}
"persistentVolumeClaim": {"claimName": v.persistent_volume_claim.claim_name}
}
elif v.config_map:
volume_source = {"configMap": {"name": v.config_map.name}}
@ -69,14 +84,14 @@ def format_volume(v: client.V1Volume) -> dict[str, Any]:
volume_source = {
"hostPath": {
"path": v.host_path.path,
"type": v.host_path.type if v.host_path.type else None
"type": v.host_path.type if v.host_path.type else None,
}
}
elif v.empty_dir: # Add support for emptyDir volumes (used for /dev/shm)
volume_source = {
"emptyDir": {
"medium": v.empty_dir.medium if v.empty_dir.medium else None,
"sizeLimit": v.empty_dir.size_limit if v.empty_dir.size_limit else None
"sizeLimit": v.empty_dir.size_limit if v.empty_dir.size_limit else None,
}
}
@ -157,34 +172,36 @@ def get_pod_volumes_and_mounts(pod_spec):
for v in pod_spec.spec.volumes:
# Handle device mounts
if v.name in ["cache-volume"]:
volumes.append(client.V1Volume(
name=v.name,
empty_dir=client.V1EmptyDirVolumeSource(
medium="Memory"
volumes.append(
client.V1Volume(
name=v.name,
empty_dir=client.V1EmptyDirVolumeSource(medium="Memory"),
)
))
)
elif v.name in ["coral-device"]:
volumes.append(client.V1Volume(
name=v.name,
host_path=client.V1HostPathVolumeSource(
path="/dev/apex_0",
type="CharDevice"
volumes.append(
client.V1Volume(
name=v.name,
host_path=client.V1HostPathVolumeSource(
path="/dev/apex_0", type="CharDevice"
),
)
))
)
elif v.name in ["qsv-device"]:
volumes.append(client.V1Volume(
name=v.name,
host_path=client.V1HostPathVolumeSource(
path="/dev/dri",
type="Directory"
volumes.append(
client.V1Volume(
name=v.name,
host_path=client.V1HostPathVolumeSource(
path="/dev/dri", type="Directory"
),
)
))
)
else:
volumes.append(v)
# Filter out None values from volumes
volumes = [v for v in volumes if format_volume(v)]
return volume_mounts, volumes
@ -230,9 +247,12 @@ def get_pod_env_and_secrets(api, namespace, pod_name):
return env_vars, secret_volumes
def create_inspector_job(api, namespace, pod_name, volume_mounts, volumes):
def create_inspector_job(
api, namespace: str, pod_name: str, volume_mounts: list, volumes: list
):
"""Create a Krayt inspector job with the given mounts"""
timestamp = int(time.time())
job_name = f"{pod_name}-inspector-{timestamp}"
job_name = f"{pod_name}-krayt-{timestamp}"
# Get environment variables and secrets from the target pod
env_vars, secret_volumes = get_pod_env_and_secrets(api, namespace, pod_name)
@ -258,7 +278,8 @@ def create_inspector_job(api, namespace, pod_name, volume_mounts, volumes):
# Format mount and PVC info for MOTD
mount_info = []
for vm in formatted_mounts:
mount_info.append(f"{vm['name']}:{vm['mountPath']}")
if vm:
mount_info.append(f"{vm['name']}:{vm['mountPath']}")
pvc_info = []
for v in volumes:
@ -271,16 +292,16 @@ def create_inspector_job(api, namespace, pod_name, volume_mounts, volumes):
"metadata": {
"name": job_name,
"namespace": namespace,
"labels": {"app": "pvc-inspector"},
"labels": {"app": "krayt"},
},
"spec": {
"ttlSecondsAfterFinished": 0, # Delete immediately after completion
"template": {
"metadata": {"labels": {"app": "pvc-inspector"}},
"metadata": {"labels": {"app": "krayt"}},
"spec": {
"containers": [
{
"name": "inspector",
"name": "krayt",
"image": "alpine:latest", # Use Alpine as base for package management
"command": [
"sh",
@ -304,8 +325,10 @@ apk add ripgrep exa ncdu dust \
update_motd() {
cat << EOF > /etc/motd
====================================
PVC Inspector Pod
Krayt Dragon's Lair
====================================
"Inside every volume lies a pearl of wisdom waiting to be discovered."
Mounted Volumes:
$(echo "$MOUNTS" | tr ',' '\\n' | sed 's/^/- /')
@ -318,7 +341,7 @@ $(for d in /mnt/secrets/*; do if [ -d "$d" ]; then echo "- $(basename $d)"; fi;
Environment Variables:
$(env | sort | sed 's/^/- /')
Available Tools:
Your Hunting Tools:
File Navigation:
- lf: Terminal file manager (run 'lf')
- exa: Modern ls (run 'ls', 'll', or 'tree')
@ -377,7 +400,7 @@ alias cat='bat --paging=never'
# Function to show detailed tool help
tools-help() {
echo "PVC Inspector Tools Guide:"
echo "Krayt Dragon Hunter's Guide:"
echo
echo "File Navigation:"
echo " lf : Navigate with arrow keys, q to quit, h for help"
@ -475,16 +498,37 @@ PROTECTED_NAMESPACES = {
"linkerd",
}
def version_callback(value: bool):
if value:
typer.echo(f"Version: {KRAYT_VERSION}")
raise typer.Exit()
@app.callback(invoke_without_command=True)
def main(
ctx: typer.Context,
version: bool = typer.Option(
False, "--version", "-v", help="Show version", callback=version_callback
),
):
"""
Krack open a Krayt dragon!
"""
if ctx.invoked_subcommand is None:
ctx.get_help()
@app.command()
def exec_inspector(
def exec(
namespace: Optional[str] = typer.Option(
None,
help="Kubernetes namespace. If not specified, will search for inspectors across all namespaces.",
),
):
"""
Execute a shell in a running inspector pod. If multiple inspectors are found,
presents a fuzzy finder to select one.
Enter the Krayt dragon's lair! Connect to a running inspector pod.
If multiple inspectors are found, you'll get to choose which one to explore.
"""
config.load_kube_config()
batch_api = client.BatchV1Api()
@ -493,13 +537,11 @@ def exec_inspector(
if namespace:
logging.debug(f"Listing jobs in namespace {namespace}")
jobs = batch_api.list_namespaced_job(
namespace=namespace, label_selector="app=pvc-inspector"
namespace=namespace, label_selector="app=krayt"
)
else:
logging.debug("Listing jobs in all namespaces")
jobs = batch_api.list_job_for_all_namespaces(
label_selector="app=pvc-inspector"
)
jobs = batch_api.list_job_for_all_namespaces(label_selector="app=krayt")
running_inspectors = []
for job in jobs.items:
@ -507,11 +549,13 @@ def exec_inspector(
v1 = client.CoreV1Api()
pods = v1.list_namespaced_pod(
namespace=job.metadata.namespace,
label_selector=f"job-name={job.metadata.name}"
label_selector=f"job-name={job.metadata.name}",
)
for pod in pods.items:
if pod.status.phase == "Running":
running_inspectors.append((pod.metadata.name, pod.metadata.namespace))
running_inspectors.append(
(pod.metadata.name, pod.metadata.namespace)
)
if not running_inspectors:
typer.echo("No running inspector pods found.")
@ -527,7 +571,10 @@ def exec_inspector(
# Execute the shell
typer.echo(f"Connecting to inspector {pod_namespace}/{pod_name}...")
os.execvp("kubectl", ["kubectl", "exec", "-it", "-n", pod_namespace, pod_name, "--", "sh", "-l"])
os.execvp(
"kubectl",
["kubectl", "exec", "-it", "-n", pod_namespace, pod_name, "--", "sh", "-l"],
)
except client.exceptions.ApiException as e:
logging.error(f"Failed to list jobs: {e}")
@ -536,19 +583,21 @@ def exec_inspector(
@app.command()
def cleanup_inspectors(
def clean(
namespace: Optional[str] = typer.Option(
None,
help="Kubernetes namespace. If not specified, will cleanup in all namespaces.",
),
yes: bool = typer.Option(
False,
"--yes", "-y",
"--yes",
"-y",
help="Skip confirmation prompt.",
),
):
"""
Delete all PVC inspector jobs in the specified namespace or all namespaces
Clean up after your hunt! Remove all Krayt inspector jobs.
Use --yes/-y to skip confirmation and clean up immediately.
"""
config.load_kube_config()
batch_api = client.BatchV1Api()
@ -560,24 +609,28 @@ def cleanup_inspectors(
raise typer.Exit(1)
logging.debug(f"Listing jobs in namespace {namespace}")
jobs = batch_api.list_namespaced_job(
namespace=namespace, label_selector="app=pvc-inspector"
namespace=namespace, label_selector="app=krayt"
)
else:
logging.debug("Listing jobs in all namespaces")
jobs = batch_api.list_job_for_all_namespaces(
label_selector="app=pvc-inspector"
)
jobs = batch_api.list_job_for_all_namespaces(label_selector="app=krayt")
# Filter out jobs in protected namespaces
jobs.items = [job for job in jobs.items if job.metadata.namespace not in PROTECTED_NAMESPACES]
jobs.items = [
job
for job in jobs.items
if job.metadata.namespace not in PROTECTED_NAMESPACES
]
if not jobs.items:
typer.echo("No PVC inspector jobs found.")
typer.echo("No Krayt inspector jobs found.")
return
# Show confirmation prompt
if not yes:
job_list = "\n".join(f" {job.metadata.namespace}/{job.metadata.name}" for job in jobs.items)
job_list = "\n".join(
f" {job.metadata.namespace}/{job.metadata.name}" for job in jobs.items
)
typer.echo(f"The following inspector jobs will be deleted:\n{job_list}")
if not typer.confirm("Are you sure you want to continue?"):
typer.echo("Operation cancelled.")
@ -611,15 +664,16 @@ def cleanup_inspectors(
@app.command()
def create_inspector(
def create(
namespace: Optional[str] = typer.Option(
None,
help="Kubernetes namespace. If not specified, will search for pods across all namespaces.",
),
):
"""
Create a PVC inspector job. If namespace is not specified, will search for pods across all namespaces.
The inspector job will be created in the same namespace as the selected pod.
Krack open a Krayt dragon! Create an inspector pod to explore what's inside your volumes.
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.
"""
pods = get_pods(namespace)
if not pods:
@ -642,5 +696,11 @@ def create_inspector(
typer.echo(yaml.dump(clean_dict(inspector_job), sort_keys=False))
@app.command()
def version():
"""Show the version of Krayt."""
typer.echo(f"Version: {KRAYT_VERSION}")
if __name__ == "__main__":
app()

View file

@ -0,0 +1,25 @@
#!/usr/bin/env -S uv run --quiet --script
# /// script
# requires-python = ">=3.10"
# ///
import sys
def generate_install_script(version):
with open("scripts/install.sh.template", "r") as f:
template = f.read()
script = template.replace("{{VERSION}}", version)
with open("dist/install.sh", "w") as f:
f.write(script)
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: generate_install_script.py VERSION", file=sys.stderr)
sys.exit(1)
version = sys.argv[1]
generate_install_script(version)

89
scripts/get_release_notes.py Executable file
View file

@ -0,0 +1,89 @@
#!/usr/bin/env -S uv run --quiet --script
# /// script
# requires-python = ">=3.10"
# ///
import subprocess
import sys
def get_release_notes(version):
with open("CHANGELOG.md", "r") as f:
content = f.read()
sections = content.split("\n## ")
# First section won't start with ## since it's split on that
sections = ["## " + s if i > 0 else s for i, s in enumerate(sections)]
for section in sections:
if section.startswith(f"## {version}"):
install_instructions = f"""## Installation
You can install krayt using one of these methods:
> !krayt requires [uv](https://docs.astral.sh/uv/getting-started/installation/) to be installed
### Using i.jpillora.com (recommended)
``` bash
curl https://i.jpillora.com/waylonwalker/krayt | bash
```
### Direct install script
``` bash
curl -fsSL https://github.com/waylonwalker/krayt/releases/download/v{version}/install.sh | bash
```
### Manual download
You can also manually download the archive for your platform from the releases page:
- [x86_64-unknown-linux-gnu](https://github.com/waylonwalker/krayt/releases/download/v{version}/krayt-{version}-x86_64-unknown-linux-gnu.tar.gz)
- [aarch64-unknown-linux-gnu](https://github.com/waylonwalker/krayt/releases/download/v{version}/krayt-{version}-aarch64-unknown-linux-gnu.tar.gz)"""
# Get help output for main command and all subcommands
try:
help_outputs = []
# Get main help output
main_help = subprocess.check_output(
["./krayt.py", "--help"],
stderr=subprocess.STDOUT,
universal_newlines=True,
)
help_outputs.append(("Main Command", main_help))
# Get help for each subcommand
subcommands = ["create", "exec", "clean", "version"]
for cmd in subcommands:
cmd_help = subprocess.check_output(
["./krayt.py", cmd, "--help"],
stderr=subprocess.STDOUT,
universal_newlines=True,
)
help_outputs.append((f"Subcommand: {cmd}", cmd_help))
# Format all help outputs
help_text = "\n\n".join(
f"### {title}\n\n``` bash\n{output}```"
for title, output in help_outputs
)
return f"{section.strip()}\n\n{install_instructions.format(version=version)}\n\n## Command Line Usage\n\n{help_text}"
except subprocess.CalledProcessError as e:
return f"{section.strip()}\n\n{install_instructions.format(version=version)}\n\n## Command Line Usage\n\nError getting help: {e.output}"
return None
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: get_release_notes.py VERSION", file=sys.stderr)
sys.exit(1)
version = sys.argv[1]
notes = get_release_notes(version)
if notes:
print(notes)
else:
print(f"Error: No release notes found for version {version}", file=sys.stderr)
sys.exit(1)

126
scripts/install.sh.template Normal file
View file

@ -0,0 +1,126 @@
#!/bin/bash
if [ "$DEBUG" == "1" ]; then
set -x
fi
TMP_DIR=$(mktemp -d -t krayt-installer-XXXXXXXXXX)
function cleanup {
rm -rf $TMP_DIR >/dev/null
}
function fail {
cleanup
msg=$1
echo "============"
echo "Error: $msg" 1>&2
exit 1
}
function check_deps {
if ! command -v uv &>/dev/null; then
echo " Error: uv is not installed"
echo "krayt requires uv to run. You can install it with:"
echo " curl -LsSf https://astral.sh/uv/install.sh | sh"
echo ""
echo "Or visit: https://github.com/astral/uv for more installation options"
echo ""
fail "uv not found"
fi
}
function install {
#settings
USER="waylonwalker"
PROG="krayt"
ASPROG="krayt"
MOVE="true"
RELEASE="{{VERSION}}"
INSECURE="false"
OUT_DIR="/usr/local/bin"
GH="https://github.com"
#bash check
[ ! "$BASH_VERSION" ] && fail "Please use bash instead"
[ ! -d $OUT_DIR ] && fail "output directory missing: $OUT_DIR"
#dependency check, assume we are a standard POISX machine
which find >/dev/null || fail "find not installed"
which xargs >/dev/null || fail "xargs not installed"
which sort >/dev/null || fail "sort not installed"
which tail >/dev/null || fail "tail not installed"
which cut >/dev/null || fail "cut not installed"
which du >/dev/null || fail "du not installed"
#choose an HTTP client
GET=""
if which curl >/dev/null; then
GET="curl"
if [[ $INSECURE = "true" ]]; then GET="$GET --insecure"; fi
GET="$GET --fail -# -L"
elif which wget >/dev/null; then
GET="wget"
if [[ $INSECURE = "true" ]]; then GET="$GET --no-check-certificate"; fi
GET="$GET -qO-"
else
fail "neither wget/curl are installed"
fi
#find OS
case $(uname -s) in
Darwin) OS="darwin" ;;
Linux) OS="linux" ;;
*) fail "unknown os: $(uname -s)" ;;
esac
#find ARCH
if uname -m | grep -E '(arm|aarch)64' >/dev/null; then
ARCH="aarch64"
elif uname -m | grep 64 >/dev/null; then
ARCH="x86_64"
else
fail "unknown arch: $(uname -m)"
fi
#choose from asset list
URL=""
FTYPE=""
VERSION=${RELEASE#v}
if [[ $VERSION == "" ]]; then
VERSION=$(curl -s https://api.github.com/repos/$USER/$PROG/releases/latest | grep -o '"tag_name": "[^"]*' | cut -d'"' -f4)
fi
if [[ $VERSION == "" ]]; then
fail "cannot find latest version"
fi
VERSION=${VERSION#v}
ASSET_URL="$GH/$USER/$PROG/releases/download/v$VERSION/${PROG}-${VERSION}-${ARCH}-unknown-${OS}-gnu.tar.gz"
echo "Installing $PROG v$VERSION..."
echo "Downloading binary from $ASSET_URL"
#enter tempdir
cd $TMP_DIR
#download and unpack
if [[ $ASSET_URL =~ \.gz$ ]]; then
which tar >/dev/null || fail "tar not installed"
if [[ $GET =~ ^curl ]]; then
curl -s ${ASSET_URL} | tar zx || fail "download failed"
else
wget -qO- ${ASSET_URL} | tar zx || fail "download failed"
fi
else
fail "unknown file type: $ASSET_URL"
fi
#check for error
cd ${PROG}-${VERSION}-${ARCH}-unknown-${OS}-gnu
#move binary
if [[ -f "${PROG}.py" ]]; then
chmod +x "${PROG}.py"
if [[ $MOVE == "true" ]]; then
echo "Moving binary to $OUT_DIR/$ASPROG"
# Create a wrapper script to ensure uv is used
cat > "$OUT_DIR/$ASPROG" << EOF
#!/bin/bash
exec uv run --quiet --script "$OUT_DIR/${ASPROG}.py" "\$@"
EOF
chmod +x "$OUT_DIR/$ASPROG"
mv "${PROG}.py" "$OUT_DIR/${ASPROG}.py" || fail "Cannot move binary to $OUT_DIR"
else
echo "Moving binary to $OUT_DIR/${ASPROG}.py"
mv "${PROG}.py" "$OUT_DIR/${ASPROG}.py" || fail "Cannot move binary to $OUT_DIR"
fi
else
fail "cannot find binary"
fi
echo "Installation complete!"
cleanup
}
check_deps
install

1
version Normal file
View file

@ -0,0 +1 @@
0.0.0