diff --git a/.github/workflows/release-pypi.yaml b/.github/workflows/release-pypi.yaml new file mode 100644 index 0000000..16111e2 --- /dev/null +++ b/.github/workflows/release-pypi.yaml @@ -0,0 +1,47 @@ +name: Release Krayt +on: + workflow_dispatch: + push: + paths: + - "krayt/**" + - "pyproject.toml" +permissions: + contents: write + pull-requests: write + issues: read + packages: none + id-token: write +jobs: + pypi-release-krayt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: waylonwalker/hatch-action@v4 + with: + before-command: "lint-format" + env: + # required for gh release + GH_TOKEN: ${{ github.token }} + - run: sudo rm -rf dist + - name: Install just + run: | + curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin + shell: bash + - name: Install uv + run: | + curl -LsSf https://astral.sh/uv/0.6.16/install.sh | sh + shell: bash + - name: Install hatch + run: | + uv tool install hatch + shell: bash + - name: Configure Git + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + shell: bash + - name: GitHub Release (just release) + run: just create-release + env: + GH_TOKEN: ${{ github.token }} + shell: bash diff --git a/.gitignore b/.gitignore index 6c311bf..6f4e31a 100644 --- a/.gitignore +++ b/.gitignore @@ -962,4 +962,4 @@ FodyWeavers.xsd # Additional files built by Visual Studio # End of https://www.toptal.com/developers/gitignore/api/vim,node,data,emacs,python,pycharm,executable,sublimetext,visualstudio,visualstudiocode -krayt +*.null-ls* diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f04f65..29ffba6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,29 @@ +## 0.4.3 + +- working out binary release process + +## 0.4.2 + +- working out binary release process + +## 0.4.1 + +- Automated release for both pypi and github + +## 0.4.0 + +- create now has --apply to apply the generated manifest to the cluster +- generic templates endpoint for cli +- better motd for volume mounts + +## 0.3.0 + +- created pypi release +- updated releases to use pyapp +- all new package +- port forward support +- additional_packages support + ## 0.2.0 ### Added diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..38ea521 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021-present Waylon S. Walker + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index fc9dd26..b554981 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Krayt - The Kubernetes Volume Inspector +![krayt hero image](./krayt.webp "A dark, cartoon-style wide-format illustration featuring a heroic explorer standing in a twilight desert beside a cracked-open dragon skull. The explorer holds a glowing pearl that reveals floating icons representing data and technology. The hero wears utility gear and a sword, with terminal and file icons on their belt. The desert backdrop includes jagged rocks, two moons in a starry sky, and moody blue and purple tones. At the top, the word “KRAYT” is displayed in bold, tech-inspired fantasy lettering.") + 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. diff --git a/justfile b/justfile index 938d946..f62f5c9 100644 --- a/justfile +++ b/justfile @@ -3,7 +3,7 @@ delete-tag: set -euo pipefail # Get the version - VERSION=$(cat version) + VERSION=$(hatch version) # Delete the tag git tag -d "v$VERSION" @@ -14,59 +14,65 @@ delete-release: set -euo pipefail # Get the version - VERSION=$(cat version) + VERSION=$(hatch version) # Delete the release gh release delete "v$VERSION" create-tag: #!/usr/bin/env bash - VERSION=$(cat version) + VERSION=$(hatch version) git tag -a "v$VERSION" -m "Release v$VERSION" git push origin "v$VERSION" create-archives: #!/usr/bin/env bash - VERSION=$(cat version) + VERSION=$(hatch version) rm -rf dist build - mkdir -p dist + hatch build -t binary + + krayt_bin=dist/binary/krayt-${VERSION} # 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}" - + outbin="krayt-${VERSION}-${platform}" # 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 .. + cp ${krayt_bin} "dist/binary/${outbin}" done # Generate install.sh - ./scripts/generate_install_script.py "$VERSION" - chmod +x dist/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) + VERSION=$(hatch 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 + + # Check if release already exists + if gh release view "v$VERSION" &>/dev/null; then + echo "Release v$VERSION already exists. Uploading binaries..." + # Upload binaries to existing release + gh release upload "v$VERSION" \ + dist/binary/krayt-${VERSION} \ + dist/binary/krayt-${VERSION}-aarch64-unknown-linux-gnu \ + dist/binary/krayt-${VERSION}-x86_64-unknown-linux-gnu || true + else + echo "Creating new release v$VERSION" + # Create new release with binaries + gh release create "v$VERSION" \ + --title "v$VERSION" \ + --notes-file release_notes.tmp \ + dist/binary/krayt-${VERSION} \ + dist/binary/krayt-${VERSION}-aarch64-unknown-linux-gnu \ + dist/binary/krayt-${VERSION}-x86_64-unknown-linux-gnu + fi rm release_notes.tmp preview-release-notes: #!/usr/bin/env bash - VERSION=$(cat version) + VERSION=$(hatch version) ./scripts/get_release_notes.py "$VERSION" | less -R release: create-release + diff --git a/krayt/__about__.py b/krayt/__about__.py new file mode 100644 index 0000000..f6b7e26 --- /dev/null +++ b/krayt/__about__.py @@ -0,0 +1 @@ +__version__ = "0.4.3" diff --git a/krayt/__init__.py b/krayt/__init__.py new file mode 100644 index 0000000..236e53a --- /dev/null +++ b/krayt/__init__.py @@ -0,0 +1,5 @@ +from krayt.__about__ import __version__ + +__all__ = [ + "__version__", +] diff --git a/krayt/bundles.py b/krayt/bundles.py new file mode 100644 index 0000000..cecc8d9 --- /dev/null +++ b/krayt/bundles.py @@ -0,0 +1,88 @@ +""" +Bundles of packages available in most package managers. +""" + +basics = [ + "curl", + "wget", + "jq", + "yq", + "bash", + "coreutils", +] +bundles = { + "basics": [ + *basics, + ], + "pretty": [ + *basics, + "starship", + "atuin", + "bash", + "zsh", + "fish", + "bat", + "eza", + ], + "networking": [ + *basics, + "mtr", + "bind-tools", + "aws-cli", + "curl", + "wget", + "iperf3", + "nmap", + "traceroute", + "netcat-openbsd", + ], + "database": [ + *basics, + "sqlite", + "sqlite-dev", + "sqlite-libs", + "postgresql", + "mysql", + "mariadb", + "redis", + "mongodb", + ], + "storage": [ + *basics, + "ncdu", + "dust", + "file", + "hexyl", + "ripgrep", + "fd", + "fzf", + "difftastic", + ], + "search": [ + *basics, + "ripgrep", + "fd", + "fzf", + "difftastic", + ], + "monitoring": [ + *basics, + "htop", + "bottom", + "mtr", + ], +} + +bundles["all"] = list( + set( + [ + *bundles["basics"], + *bundles["pretty"], + *bundles["networking"], + *bundles["database"], + *bundles["storage"], + *bundles["search"], + *bundles["monitoring"], + ] + ) +) diff --git a/krayt/cli/__init__.py b/krayt/cli/__init__.py new file mode 100644 index 0000000..035a6de --- /dev/null +++ b/krayt/cli/__init__.py @@ -0,0 +1,25 @@ +from krayt import __version__ +from krayt.cli.bundles import app as bundles_app +from krayt.cli.pod import app as pod_app, create, exec, logs, clean +from krayt.cli.templates import app as templates_app +from typer import Typer + +app = Typer() + +app.add_typer(templates_app, name="template", no_args_is_help=True) +app.add_typer(pod_app, name="pod", no_args_is_help=True) +app.command(name="create")(create) +app.command(name="c")(create) +app.command(name="clean")(clean) +app.command(name="exec")(exec) +app.command(name="logs")(logs) +app.add_typer(bundles_app, name="bundles", no_args_is_help=True) + + +@app.command() +def version(): + print(__version__) + + +def main(): + app() diff --git a/krayt/cli/bundles.py b/krayt/cli/bundles.py new file mode 100644 index 0000000..be30fe8 --- /dev/null +++ b/krayt/cli/bundles.py @@ -0,0 +1,25 @@ +from krayt import bundles +import typer + +app = typer.Typer() + + +@app.command() +def list( + verbose: bool = typer.Option( + False, + "--verbose", + "-v", + help="Verbose output", + ), +): + """List available bundles""" + typer.echo("Available bundles:") + # get all variables from bundles + for bundle in bundles.__dict__.keys(): + if bundle.startswith("__"): + continue + typer.echo(bundle) + if verbose: + for package in bundles.__dict__[bundle]: + typer.echo(f" - {package}") diff --git a/krayt/cli/pod.py b/krayt/cli/pod.py new file mode 100644 index 0000000..d8ac87a --- /dev/null +++ b/krayt/cli/pod.py @@ -0,0 +1,925 @@ +import iterfzf +from krayt.templates import env +from kubernetes.stream import stream +from kubernetes import client, config +import logging +import os +import time +import typer +from typing import Any, List, Optional +import yaml +from krayt.__about__ import __version__ +import sys +import tty +import termios +import select +import signal +import json + + +logging.basicConfig(level=logging.WARNING) + +app = typer.Typer() + + +def clean_dict(d: dict[str, Any]) -> dict[str, Any]: + """Remove None values and empty dicts from a dictionary recursively.""" + if not isinstance(d, dict): + return d + return { + k: clean_dict(v) + for k, v in d.items() + if v is not None and v != {} and not (isinstance(v, dict) and not clean_dict(v)) + } + + +def format_volume_mount(vm: client.V1VolumeMount) -> dict[str, Any]: + """Format volume mount with only relevant fields.""" + # Skip Kubernetes service account mounts + if vm.mount_path.startswith("/var/run/secrets/kubernetes.io/"): + return None + + return clean_dict( + { + "name": vm.name, + "mount_path": vm.mount_path, + "read_only": vm.read_only if vm.read_only else None, + } + ) + + +def format_volume(v: client.V1Volume) -> dict[str, Any]: + """Format volume into a dictionary, return None if it should be skipped""" + # Skip Kubernetes service account volumes + if v.name.startswith("kube-api-access-"): + return None + + volume_source = None + if v.persistent_volume_claim: + volume_source = { + "persistentVolumeClaim": {"claimName": v.persistent_volume_claim.claim_name} + } + elif v.config_map: + volume_source = {"configMap": {"name": v.config_map.name}} + elif v.secret: + volume_source = {"secret": {"secretName": v.secret.secret_name}} + elif v.host_path: # Add support for hostPath volumes (used for device mounts) + volume_source = { + "hostPath": { + "path": v.host_path.path, + "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, + } + } + + if not volume_source: + return None + + return clean_dict({"name": v.name, **volume_source}) + + +def fuzzy_select(items): + """Use fzf to select from a list of (name, namespace) tuples""" + if not items: + return None, None + + # If there's only one item, return it without prompting + if len(items) == 1: + return items[0] + + # Format items for display + formatted_items = [f"{name} ({namespace})" for name, namespace in items] + + # Use fzf for selection + try: + # selected = inquirer.fuzzy( + # message="Select a pod to clone:", choices=formatted_items + # ).execute() + + selected = iterfzf.iterfzf( + formatted_items, + prompt="Select a pod to clone:", + # preview='''kubectl describe pod "$(echo {} | awk -F'[(|)]' '{gsub(/\x1b\[[0-9;]*m/, "", $1); print $1}' | xargs)" -n "$(echo {} | awk -F'[(|)]' '{gsub(/\x1b\[[0-9;]*m/, "", $2); print $2}' | xargs)"''', + ) + if not selected: + 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: + typer.echo(f"Error during selection: {e}") + return None, None + + +def get_pods( + namespace=None, + label_selector: str = "app=krayt", +): + """Get list of pods in the specified namespace or all namespaces""" + try: + config.load_kube_config() + api = client.CoreV1Api() + if namespace: + pods = api.list_namespaced_pod( + namespace=namespace, + label_selector=label_selector, + ) + else: + pods = api.list_pod_for_all_namespaces( + label_selector=label_selector, + ) + + # Convert to list of (name, namespace) tuples + pod_list = [] + for pod in pods.items: + if pod.metadata.namespace not in PROTECTED_NAMESPACES: + pod_list.append((pod.metadata.name, pod.metadata.namespace)) + return pod_list + + except client.rest.ApiException as e: + typer.echo(f"Error listing pods: {e}") + raise typer.Exit(1) + + +def get_namespaces( + namespace=None, + label_selector: str = "app=krayt", +): + config.load_kube_config() + api = client.CoreV1Api() + + all_namespaces = [n.metadata.name for n in api.list_namespace().items] + return all_namespaces + + +def get_pod_spec(pod_name, namespace): + config.load_kube_config() + v1 = client.CoreV1Api() + return v1.read_namespaced_pod(pod_name, namespace) + + +def get_pod_volumes_and_mounts(pod_spec): + """Extract all volumes and mounts from a pod spec""" + volume_mounts = [] + for container in pod_spec.spec.containers: + if container.volume_mounts: + volume_mounts.extend(container.volume_mounts) + + # Filter out None values from volume mounts + volume_mounts = [vm for vm in volume_mounts if format_volume_mount(vm)] + + # Get all volumes, including device mounts + volumes = [] + if pod_spec.spec.volumes: + 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"), + ) + ) + elif v.name in ["coral-device"]: + 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" + ), + ) + ) + else: + volumes.append(v) + + # Filter out None values from volumes + volumes = [v for v in volumes if format_volume(v)] + + return volume_mounts, volumes + + +def get_env_vars_and_secret_volumes(api, namespace: str): + """Get environment variables and secret volumes for the inspector pod""" + env_vars = [] + volumes = [] + + # Add proxy environment variables if they exist in the host environment + proxy_vars = [ + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", + ] + + for var in proxy_vars: + if var in os.environ: + env_vars.append({"name": var, "value": os.environ[var]}) + + # Look for secret volumes in the namespace + try: + secrets = api.list_namespaced_secret(namespace) + for secret in secrets.items: + # Skip service account tokens and other system secrets + if secret.type != "Opaque" or secret.metadata.name.startswith( + "default-token-" + ): + continue + + # Mount each secret as a volume + volume_name = f"secret-{secret.metadata.name}" + volumes.append( + client.V1Volume( + name=volume_name, + secret=client.V1SecretVolumeSource( + secret_name=secret.metadata.name + ), + ) + ) + + except client.exceptions.ApiException as e: + if e.status != 404: # Ignore if no secrets found + logging.warning(f"Failed to list secrets in namespace {namespace}: {e}") + + return env_vars, volumes + + +def create_inspector_job( + api, + namespace: str, + pod_name: str, + volume_mounts: list, + volumes: list, + image: str = "alpine:latest", + imagepullsecret: Optional[str] = None, + additional_packages: Optional[List[str]] = None, + pre_init_scripts: Optional[List[str]] = None, + post_init_scripts: Optional[List[str]] = None, + pre_init_hooks: Optional[List[str]] = None, + post_init_hooks: Optional[List[str]] = None, +): + timestamp = int(time.time()) + job_name = f"{pod_name}-krayt-{timestamp}" + + env_vars, secret_volumes = get_env_vars_and_secret_volumes(api, namespace) + volumes.extend(secret_volumes) + + secret_mounts = [ + client.V1VolumeMount( + name=vol.name, + mount_path=f"/mnt/secrets/{vol.secret.secret_name}", + read_only=True, + ) + for vol in secret_volumes + ] + + formatted_mounts = [format_volume_mount(vm) for vm in volume_mounts] + formatted_mounts = [client.V1VolumeMount(**vm) for vm in formatted_mounts if vm] + formatted_mounts.extend(secret_mounts) + + pvc_info = [ + f"{v.name}:{v.persistent_volume_claim.claim_name}" + for v in volumes + if hasattr(v, "persistent_volume_claim") and v.persistent_volume_claim + ] + + template = env.get_template("base.sh") + command = template.render( + volumes=volumes, + pvcs=None, + additional_packages=additional_packages, + pre_init_scripts=None, + post_init_scripts=None, + pre_init_hooks=None, + post_init_hooks=None, + ) + + container = client.V1Container( + name="inspector", + image=image, + command=["sh", "-c", command], + env=env_vars, + volume_mounts=formatted_mounts, + ) + + spec = client.V1PodSpec( + containers=[container], + volumes=[format_volume(v) for v in volumes if format_volume(v)], + restart_policy="Never", + image_pull_secrets=[client.V1LocalObjectReference(name=imagepullsecret)] + if imagepullsecret + else None, + ) + + template = client.V1PodTemplateSpec( + metadata=client.V1ObjectMeta(labels={"app": "krayt"}), spec=spec + ) + + job_spec = client.V1JobSpec( + template=template, + ttl_seconds_after_finished=600, + ) + + job = client.V1Job( + api_version="batch/v1", + kind="Job", + metadata=client.V1ObjectMeta( + name=job_name, + namespace=namespace, + labels={"app": "krayt"}, + annotations={"pvcs": ",".join(pvc_info) if pvc_info else "none"}, + ), + spec=job_spec, + ) + + return job + + +PROTECTED_NAMESPACES = { + "kube-system", + "kube-public", + "kube-node-lease", + "argo-events", + "argo-rollouts", + "argo-workflows", + "argocd", + "cert-manager", + "ingress-nginx", + "monitoring", + "prometheus", + "istio-system", + "linkerd", +} + + +def setup_environment(): + """Set up the environment with proxy settings and other configurations""" + # Load environment variables for proxies + proxy_vars = [ + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", + ] + + for var in proxy_vars: + if var in os.environ: + # Make both upper and lower case versions available + os.environ[var.upper()] = os.environ[var] + os.environ[var.lower()] = os.environ[var] + + +def version_callback(value: bool): + if value: + typer.echo(f"Version: {__version__}") + raise typer.Exit() + + +def get_pod(namespace: Optional[str] = None): + config.load_kube_config() + batch_api = client.BatchV1Api() + + try: + if namespace: + logging.debug(f"Listing jobs in namespace {namespace}") + jobs = batch_api.list_namespaced_job( + 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=krayt") + + running_inspectors = [] + for job in jobs.items: + # Get the pod for this job + v1 = client.CoreV1Api() + pods = v1.list_namespaced_pod( + namespace=job.metadata.namespace, + 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) + ) + + if not running_inspectors: + typer.echo("No running inspector pods found.") + raise typer.Exit(1) + + if len(running_inspectors) == 1: + pod_name, pod_namespace = running_inspectors[0] + else: + pod_name, pod_namespace = fuzzy_select(running_inspectors) + if not pod_name: + typer.echo("No inspector selected.") + raise typer.Exit(1) + + except client.exceptions.ApiException as e: + logging.error(f"Failed to list jobs: {e}") + typer.echo(f"Failed to list jobs: {e}", err=True) + raise typer.Exit(1) + + return pod_name, pod_namespace + + +def interactive_exec(pod_name: str, namespace: str): + # Load kubeconfig from local context (or use load_incluster_config if running inside the cluster) + print(f"Connecting to pod {pod_name} in namespace {namespace}...") + try: + config.load_kube_config() + except Exception as e: + print(f"Error loading kubeconfig: {e}", file=sys.stderr) + return + + core_v1 = client.CoreV1Api() + command = ["/bin/bash", "-l"] + resp = None + + # Save the current terminal settings + oldtty = termios.tcgetattr(sys.stdin) + + # Function to handle window resize events + def handle_resize(signum, frame): + if resp and resp.is_open(): + # Get the current terminal size + cols, rows = os.get_terminal_size() + # Send terminal resize command via websocket + # Format matches kubectl's resize message format + resize_msg = json.dumps({"Width": cols, "Height": rows}) + resp.write_channel(4, resize_msg) + + # Function to handle exit signals + def handle_exit(signum, frame): + if resp and resp.is_open(): + # Send Ctrl+C to the remote process + resp.write_stdin("\x03") + + try: + # Put terminal into raw mode but don't handle local echo ourselves + # Let the remote terminal handle echoing and control characters + tty.setraw(sys.stdin.fileno()) + + # Set up signal handlers + signal.signal(signal.SIGWINCH, handle_resize) # Window resize + signal.signal(signal.SIGINT, handle_exit) # Ctrl+C + + # Create a TTY-enabled exec connection to the pod + try: + resp = stream( + core_v1.connect_get_namespaced_pod_exec, + pod_name, + namespace, + command=command, + stderr=True, + stdin=True, + stdout=True, + tty=True, + _preload_content=False, + ) + print(f"Connected to {pod_name}") + except Exception as e: + print(f"\nError connecting to pod: {e}", file=sys.stderr) + return + + # Wait for the connection to be ready + time.sleep(0.2) + + # Send initial terminal size + cols, rows = os.get_terminal_size() + resize_msg = json.dumps({"Width": cols, "Height": rows}) + resp.write_channel(4, resize_msg) + + # Make sure the size is set by sending a resize event + handle_resize(None, None) + + # Set up a simple select-based event loop to handle I/O + try: + while resp and resp.is_open(): + # Update the websocket connection + resp.update(timeout=0.1) + + # Handle output from the pod + if resp.peek_stdout(): + sys.stdout.write(resp.read_stdout()) + sys.stdout.flush() + if resp.peek_stderr(): + sys.stderr.write(resp.read_stderr()) + sys.stderr.flush() + + # Check for input from the user + rlist, _, _ = select.select([sys.stdin], [], [], 0.01) + if sys.stdin in rlist: + # Read input and forward it to the pod without local echo + data = os.read(sys.stdin.fileno(), 1024) + if not data: # EOF (e.g., user pressed Ctrl+D) + break + resp.write_stdin(data.decode()) + except Exception as e: + print(f"\nConnection error: {e}", file=sys.stderr) + + except KeyboardInterrupt: + # Handle Ctrl+C gracefully + print("\nSession terminated by user", file=sys.stderr) + except Exception as e: + print(f"\nError in interactive session: {e}", file=sys.stderr) + finally: + # Reset signal handlers + signal.signal(signal.SIGWINCH, signal.SIG_DFL) + signal.signal(signal.SIGINT, signal.SIG_DFL) + + # Close the connection if it's still open + if resp and resp.is_open(): + try: + resp.close() + except Exception: + pass + + # Always restore terminal settings + termios.tcsetattr(sys.stdin, termios.TCSADRAIN, oldtty) + print("\nConnection closed", file=sys.stderr) + + +@app.command() +def exec( + namespace: Optional[str] = typer.Option( + None, + help="Kubernetes namespace. If not specified, will search for inspectors across all namespaces.", + ), + shell: Optional[str] = typer.Option( + "/bin/bash", + "--shell", + "-s", + help="Shell to use for the inspector pod", + ), +): + """ + 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() # or config.load_incluster_config() if running inside a pod + client.CoreV1Api() + + pod_name, pod_namespace = get_pod(namespace) + + try: + pod_name, pod_namespace = get_pod(namespace) + exec_command = [ + "kubectl", + "exec", + "-it", + "-n", + pod_namespace, + pod_name, + "--", + shell, + "-l", + ] + + os.execvp("kubectl", exec_command) + except Exception as e: + print(f"Error executing command with kubectl trying python api: {e}") + + interactive_exec(pod_name, pod_namespace) + + +@app.command() +def port_forward( + namespace: Optional[str] = typer.Option( + None, + help="Kubernetes namespace. If not specified, will search for inspectors across all namespaces.", + ), + port: str = typer.Option( + "8080:8080", + "--port", + "-p", + help="Port to forward to the inspector pod", + ), +): + """ + 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. + """ + if ":" not in port: + # if port does not contain a ":" it should be an int + port = int(port) + port = f"{port}:{port}" + + pod_name, pod_namespace = get_pod(namespace) + port_forward_command = [ + "kubectl", + "port-forward", + "-n", + pod_namespace, + pod_name, + port, + ] + + os.execvp("kubectl", port_forward_command) + + +@app.command() +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", + help="Skip confirmation prompt.", + ), +): + """ + 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() + + try: + if namespace: + if namespace in PROTECTED_NAMESPACES: + typer.echo(f"Error: Cannot cleanup in protected namespace {namespace}") + raise typer.Exit(1) + logging.debug(f"Listing jobs in namespace {namespace}") + jobs = batch_api.list_namespaced_job( + 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=krayt") + + # Filter out jobs 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 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 + ) + 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.") + return + + # Delete each job + for job in jobs.items: + try: + logging.debug( + f"Deleting job {job.metadata.namespace}/{job.metadata.name}" + ) + batch_api.delete_namespaced_job( + name=job.metadata.name, + namespace=job.metadata.namespace, + body=client.V1DeleteOptions(propagation_policy="Background"), + ) + typer.echo(f"Deleted job: {job.metadata.namespace}/{job.metadata.name}") + except client.exceptions.ApiException as e: + logging.error( + f"Failed to delete job {job.metadata.namespace}/{job.metadata.name}: {e}" + ) + typer.echo( + f"Failed to delete job {job.metadata.namespace}/{job.metadata.name}: {e}", + err=True, + ) + + except client.exceptions.ApiException as e: + logging.error(f"Failed to list jobs: {e}") + typer.echo(f"Failed to list jobs: {e}", err=True) + raise typer.Exit(1) + + +@app.command() +def create( + namespace: Optional[str] = typer.Option( + None, + "--namespace", + "-n", + help="Kubernetes namespace. If not specified, will search for pods across all namespaces.", + ), + clone: Optional[str] = typer.Option( + None, + "--clone", + "-c", + help="Clone an existing pod", + ), + image: str = typer.Option( + "alpine:latest", + "--image", + "-i", + help="Container image to use for the inspector pod", + ), + imagepullsecret: Optional[str] = typer.Option( + None, + "--imagepullsecret", + help="Name of the image pull secret to use for pulling private images", + ), + additional_packages: Optional[List[str]] = typer.Option( + None, + "--additional-packages", + "-ap", + help="additional packages to install in the inspector pod", + ), + additional_package_bundles: Optional[List[str]] = typer.Option( + None, + "--additional-package-bundles", + "-ab", + help="additional packages to install in the inspector pod", + ), + pre_init_scripts: Optional[List[str]] = typer.Option( + None, + "--pre-init-scripts", + help="additional scripts to execute at the end of container initialization", + ), + post_init_scripts: Optional[List[str]] = typer.Option( + None, + "--post-init-scripts", + "--init-scripts", + help="additional scripts to execute at the start of container initialization", + ), + pre_init_hooks: Optional[List[str]] = typer.Option( + None, + "--pre-init-hooks", + help="additional hooks to execute at the end of container initialization", + ), + post_init_hooks: Optional[List[str]] = typer.Option( + None, + "--post-init-hooks", + help="additional hooks to execute at the start of container initialization", + ), + apply: bool = typer.Option( + False, + "--apply", + help="Automatically apply the changes instead of just echoing them.", + ), +): + """ + 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. + """ + # For create, we want to list all pods, not just Krayt pods + selected_namespace = namespace + selected_pod = clone + + if namespace is None and clone is not None and "/" in clone: + selected_namespace, selected_pod = clone.split("/", 1) + + get_namespaces(namespace) + pods = get_pods(namespace, label_selector="app!=krayt") + + if not pods: + typer.echo("No pods found.") + raise typer.Exit(1) + + if selected_pod not in (p[0] for p in pods) or selected_pod is None: + if selected_pod is not None: + pods = [p for p in pods if selected_pod in p[0]] + if len(pods) == 1: + selected_pod, selected_namespace = pods[0] + else: + selected_pod, selected_namespace = fuzzy_select(pods) + if not selected_pod: + typer.echo("No pod selected.") + raise typer.Exit(1) + + pod_spec = get_pod_spec(selected_pod, selected_namespace) + volume_mounts, volumes = get_pod_volumes_and_mounts(pod_spec) + + inspector_job = create_inspector_job( + client.CoreV1Api(), + selected_namespace, + selected_pod, + volume_mounts, + volumes, + image=image, + imagepullsecret=imagepullsecret, + additional_packages=additional_packages, + pre_init_scripts=pre_init_scripts, + post_init_scripts=post_init_scripts, + pre_init_hooks=pre_init_hooks, + post_init_hooks=post_init_hooks, + ) + + # Output the job manifest + api_client = client.ApiClient() + job_dict = api_client.sanitize_for_serialization(inspector_job) + job_yaml = yaml.dump(job_dict, sort_keys=False) + + if apply: + batch_api = client.BatchV1Api() + job = batch_api.create_namespaced_job( + namespace=selected_namespace, + body=inspector_job, + ) + print(f"Job {job.metadata.name} created.") + return job + else: + # Just echo the YAML + typer.echo(job_yaml) + + +@app.command() +def version(): + """Show the version of Krayt.""" + typer.echo(f"Version: {__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) + + +@app.command("list") +def list_pods(): + pods = get_pods() + if not pods: + typer.echo("No pods found.") + raise typer.Exit(1) + + for pod, namespace in pods: + typer.echo(f"{pod} ({namespace})") + + +# def main(): +# setup_environment() +# app() +# +# +# if __name__ == "__main__": +# main() diff --git a/krayt/cli/templates.py b/krayt/cli/templates.py new file mode 100644 index 0000000..7416db6 --- /dev/null +++ b/krayt/cli/templates.py @@ -0,0 +1,98 @@ +from krayt.templates import env +import typer +from typing import List, Optional + +app = typer.Typer() + + +@app.command() +def list(): + typer.echo("Available templates:") + for template in env.list_templates(): + typer.echo(template) + + +@app.command() +def render( + template_name: Optional[str] = typer.Option("base.sh", "--template-name", "-t"), + volumes: Optional[List[str]] = typer.Option( + None, + "--volume", + ), + pvcs: Optional[List[str]] = typer.Option( + None, + "--pvc", + ), + additional_packages: Optional[List[str]] = typer.Option( + None, "--additional-packages", "-ap" + ), + pre_init_scripts: Optional[List[str]] = typer.Option( + None, + "--pre-init-scripts", + help="additional scripts to execute at the end of container initialization", + ), + post_init_scripts: Optional[List[str]] = typer.Option( + None, + "--post-init-scripts", + "--init-scripts", + help="additional scripts to execute at the start of container initialization", + ), + pre_init_hooks: Optional[List[str]] = typer.Option( + None, + "--pre-init-hooks", + help="additional hooks to execute at the end of container initialization", + ), + post_init_hooks: Optional[List[str]] = typer.Option( + None, + "--post-init-hooks", + "--init-hooks", + help="additional hooks to execute at the start of container initialization", + ), +): + template = env.get_template(template_name) + rendered = template.render( + volumes=volumes, + pvcs=pvcs, + additional_packages=additional_packages, + pre_init_scripts=pre_init_scripts, + post_init_scripts=post_init_scripts, + pre_init_hooks=pre_init_hooks, + post_init_hooks=post_init_hooks, + ) + print(rendered) + + +# @app.command() +# def install( +# additional_packages: Optional[List[str]] = typer.Option( +# ..., "--additional-packages", "-ap" +# ), +# ): +# template_name = "install.sh" +# template = env.get_template(template_name) +# rendered = template.render(additional_packages=additional_packages) +# print(rendered) +# +# +# @app.command() +# def motd( +# volumes: Optional[List[str]] = typer.Option( +# None, +# "--volume", +# ), +# pvcs: Optional[List[str]] = typer.Option( +# None, +# "--pvc", +# ), +# additional_packages: Optional[List[str]] = typer.Option( +# ..., "--additional-packages", "-ap" +# ), +# ): +# template_name = "motd.sh" +# template = env.get_template(template_name) +# rendered = template.render( +# volumes=volumes, +# pvcs=pvcs, +# additional_packages=additional_packages, +# ) +# print(rendered) diff --git a/krayt/package.py b/krayt/package.py new file mode 100644 index 0000000..907a412 --- /dev/null +++ b/krayt/package.py @@ -0,0 +1,191 @@ +from krayt.bundles import bundles +from more_itertools import unique_everseen +from pydantic import BaseModel, BeforeValidator +from typing import Annotated, List, Literal, Optional, Union + + +SUPPORTED_KINDS = { + "system", + "uv", + "installer", + "i", + "curlbash", + "curlsh", + "cargo", + "pipx", + "npm", + "go", + "gh", + "group", + "bundle", +} + +DEPENDENCIES = { + "uv": [ + "curl", + "curlsh:https://astral.sh/uv/install.sh", + ], + "installer": [ + "curl", + ], + "i": ["curl"], + "curlbash": ["curl"], + "curlsh": ["curl"], + "cargo": ["cargo"], + "pipx": ["pipx"], + "npm": ["npm"], + "go": ["go"], + "gh": ["gh"], +} + + +def validate_kind(v): + if v not in SUPPORTED_KINDS: + raise ValueError( + f"Unknown installer kind: {v}\n Supported kinds: {SUPPORTED_KINDS}\n " + ) + return v + + +class Package(BaseModel): + """ + Represents a package to be installed, either via system package manager + or an alternative installer like uv, installer.sh, etc. + """ + + kind: Annotated[ + Literal[*SUPPORTED_KINDS], + BeforeValidator(validate_kind), + ] = "system" + value: str + # dependencies: Optional[List["Package"]] = None + pre_install_hook: Optional[str] = None + post_install_hook: Optional[str] = None + + @classmethod + def from_raw(cls, raw: str) -> "Package": + """ + Parse a raw input string like 'uv:copier' into a Package(kind='uv', value='copier') + """ + if ":" in raw: + prefix, value = raw.split(":", 1) + return cls(kind=prefix.strip(), value=value.strip()) + else: + return cls(kind="system", value=raw.strip()) + + # @model_validator(mode="after") + # def validate_dependencies(self) -> Self: + # if self.dependencies: + # return self + # dependencies = [] + # + # if self.kind in ["uv", "i", "installer", "curlbash", "curlsh", "gh"]: + # dependencies.append(Package.from_raw("curl")) + # dependencies.append( + # Package.from_raw("curlsh:https://astral.sh/uv/install.sh") + # ) + # if self.kind == "cargo": + # dependencies.append(Package.from_raw("cargo")) + # if self.kind == "pipx": + # dependencies.append(Package.from_raw("pipx")) + # if self.kind == "npm": + # dependencies.append(Package.from_raw("npm")) + # if self.kind == "go": + # dependencies.append(Package.from_raw("go")) + # + # self.dependencies = dependencies + # return self + # + def is_system(self) -> bool: + return self.kind == "system" + + def install_command(self) -> str: + """ + Generate the bash install command snippet for this package. + """ + cmd = "" + if self.kind in ["bundle", "group"]: + cmd = "" + elif self.kind == "system": + cmd = f"detect_package_manager_and_install {self.value}" + elif self.kind == "uv": + cmd = f"uv tool install {self.value}" + elif self.kind in ["i", "installer", "gh"]: + cmd = f"installer {self.value}" + elif self.kind == "curlsh": + cmd = f"curl -fsSL {self.value} | sh" + elif self.kind == "curlbash": + cmd = f"curl -fsSL {self.value} | bash" + elif self.kind == "cargo": + cmd = f"cargo install {self.value}" + elif self.kind == "pipx": + cmd = f"pipx install {self.value}" + elif self.kind == "npm": + cmd = f"npm install -g {self.value}" + elif self.kind == "go": + cmd = f"go install {self.value}@latest" + else: + raise ValueError(f"Unknown install method for kind={self.kind}") + + # Add pre-install hook if necessary + if self.pre_install_hook: + return f"{self.pre_install_hook} {cmd}" + else: + return cmd + + +def get_install_script(packages: Union[str, List[str]]) -> str: + if packages is None: + return [] + if isinstance(packages, str): + packages = [packages] + bundled_packages = [] + for package in packages: + if package.startswith("bundle:") or package.startswith("group:"): + _package = package.split(":")[1].strip() + bundled_packages.extend(bundles.get(_package, [])) + packages = list(unique_everseen([*bundled_packages, *packages])) + + packages = [Package.from_raw(raw) for raw in packages] + kinds_used = [package.kind for package in packages] + dependencies = [] + for kind in kinds_used: + dependencies.extend(DEPENDENCIES.get(kind, [])) + dependencies = list( + unique_everseen( + [Package.from_raw(raw).install_command() for raw in dependencies] + ) + ) + # for package in packages: + # if package.dependencies: + # dependencies.extend( + # [dependency.install_command() for dependency in package.dependencies] + # ) + installs = [package.install_command() for package in packages] + post_hooks = [] + for package in packages: + if package.post_install_hook: + post_hooks.append(package.post_install_hook.strip()) + pre_hooks = [] + for package in packages: + if package.pre_install_hook: + pre_hooks.append(package.pre_install_hook.strip()) + + # Final full script + full_script = list( + unique_everseen([*pre_hooks, *dependencies, *installs, *post_hooks]) + ) + return "\n".join(full_script) if full_script else full_script + + +if __name__ == "__main__": + raw_inputs = [ + "bundle:storage", + "wget", + "uv:copier", + "i:sharkdp/fd", + "curlsh:https://example.com/install.sh", + ] + full_script = get_install_script(raw_inputs) + + print("\n".join(full_script)) diff --git a/krayt/templates.py b/krayt/templates.py new file mode 100644 index 0000000..7b3ba95 --- /dev/null +++ b/krayt/templates.py @@ -0,0 +1,13 @@ +from jinja2 import Environment, FileSystemLoader +from krayt.package import get_install_script +from pathlib import Path + +# Get the two template directories +template_dirs = [ + Path(__file__).resolve().parents[0] / "templates", + Path.home() / ".config" / "krayt" / "templates", +] + +# Create the Jinja environment +env = Environment(loader=FileSystemLoader([str(path) for path in template_dirs])) +env.globals["get_install_script"] = get_install_script diff --git a/krayt/templates/.kraytrc b/krayt/templates/.kraytrc new file mode 100644 index 0000000..591b80e --- /dev/null +++ b/krayt/templates/.kraytrc @@ -0,0 +1,3 @@ +if [ -t 1 ] && [ -f /etc/motd ]; then + cat /etc/motd +fi diff --git a/krayt/templates/base.sh b/krayt/templates/base.sh new file mode 100644 index 0000000..c248460 --- /dev/null +++ b/krayt/templates/base.sh @@ -0,0 +1,25 @@ +mkdir -p /etc/krayt +cat <<'KRAYT_INIT_SH_EOF' >/etc/krayt/init.sh +{%- if pre_init_hooks %} +{% for hook in pre_init_hooks %}{{ hook }}{% endfor %} +{% endif -%} +{%- if pre_init_scripts %} +{% for script in pre_init_scripts %}{{ script }}{% endfor %} +{% endif -%} +{% include 'install.sh' %} +{% include 'motd.sh' %} +{% include 'kraytrc.sh' %} +{%- if post_init_scripts %} +{% for script in post_init_scripts %}{{ script }}{% endfor %} +{% endif %} +{%- if post_init_hooks %} +{% for hook in post_init_hooks %}{{ hook }}{% endfor %} +{% endif %} +echo "Krayt environment ready. Sleeping forever..." +trap "echo 'Received SIGTERM. Exiting...'; exit 0" TERM +tail -f /dev/null & +wait +KRAYT_INIT_SH_EOF + +chmod +x /etc/krayt/init.sh +/etc/krayt/init.sh diff --git a/krayt/templates/install.sh b/krayt/templates/install.sh new file mode 100644 index 0000000..de056ad --- /dev/null +++ b/krayt/templates/install.sh @@ -0,0 +1,88 @@ +{% if additional_packages %} +# Detect package manager +if command -v apt >/dev/null 2>&1; then + PKG_MANAGER="apt" + UPDATE_CMD="apt update" + INSTALL_CMD="apt install -y" +elif command -v dnf >/dev/null 2>&1; then + PKG_MANAGER="dnf" + UPDATE_CMD="" + INSTALL_CMD="dnf install -y" +elif command -v yum >/dev/null 2>&1; then + PKG_MANAGER="yum" + UPDATE_CMD="" + INSTALL_CMD="yum install -y" +elif command -v pacman >/dev/null 2>&1; then + PKG_MANAGER="pacman" + UPDATE_CMD="" + INSTALL_CMD="pacman -Sy --noconfirm" +elif command -v zypper >/dev/null 2>&1; then + PKG_MANAGER="zypper" + UPDATE_CMD="" + INSTALL_CMD="zypper install -y" +elif command -v apk >/dev/null 2>&1; then + PKG_MANAGER="apk" + UPDATE_CMD="" + INSTALL_CMD="apk add" +else + echo "No supported package manager found." + exit 2 +fi + +echo "Using package manager: $PKG_MANAGER" + +# Run update once if needed +if [ -n "$UPDATE_CMD" ]; then + echo "Running package manager update..." + eval "$UPDATE_CMD" +fi + +detect_package_manager_and_install() { + if [ $# -eq 0 ]; then + echo "Usage: detect_package_manager_and_install [package2] [...]" + return 1 + fi + + FAILED_PKGS="" + + for pkg in "$@"; do + echo "Installing package: $pkg" + if ! $INSTALL_CMD $pkg; then + echo "⚠️ Warning: Failed to install package: $pkg" + FAILED_PKGS="$FAILED_PKGS $pkg" + fi + done + {% raw %} + if [ -n "$FAILED_PKGS" ]; then + echo "⚠️ The following packages failed to install:" + for failed_pkg in $FAILED_PKGS; do + echo " - $failed_pkg" + done + else + echo "✅ All requested packages installed successfully." + fi + {% endraw %} +} + +installer() { + if [ $# -eq 0 ]; then + echo "Usage: installer [package2] [...]" + return 1 + fi + + for pkg in "$@"; do + echo "Installing package with installer: $pkg" + ( + orig_dir="$(pwd)" + cd /usr/local/bin || exit 1 + curl -fsSL https://i.jpillora.com/${pkg} | sh + cd "$orig_dir" || exit 1 + ) + done +} +{% endif %} + +{% if additional_packages %} +{{ get_install_script(additional_packages) | safe }} +{% endif %} + diff --git a/krayt/templates/kraytrc.sh b/krayt/templates/kraytrc.sh new file mode 100644 index 0000000..dd4fe85 --- /dev/null +++ b/krayt/templates/kraytrc.sh @@ -0,0 +1,116 @@ +KRAYT_MARKER_START="# >>> Added by krayt-inject <<<" +KRAYT_MARKER_END='# <<< End krayt-inject >>>' +KRAYT_BLOCK=' +if [ -t 1 ] && [ -f /etc/motd ] && [ -z "$MOTD_SHOWN" ]; then + cat /etc/motd + export MOTD_SHOWN=1 +fi + +# fix $SHELL, not set in some distros like alpine +if [ -n "$BASH_VERSION" ]; then + export SHELL=/bin/bash +elif [ -n "$ZSH_VERSION" ]; then + export SHELL=/bin/zsh +else + export SHELL=/bin/sh +fi + +# krayt ENVIRONMENT + +{%- if pvcs %} +export KRAYT_PVCS="{{ pvcs | join(' ') }}" +{% endif -%} +{%- if volumes %} +export KRAYT_VOLUMES="{{ volumes | join(' ') }}" +{% endif -%} +{%- if secrets %} +export KRAYT_SECRETS="{{ secrets | join(' ') }}" +{% endif -%} +{%- if additional_packages %} +export KRAYT_ADDITIONAL_PACKAGES="{{ additional_packages | join(' ') }}" +{% endif -%} + +# Universal shell initializers + +# Prompt +if command -v starship >/dev/null 2>&1; then + eval "$(starship init "$(basename "$SHELL")")" +fi + +# Smarter cd +if command -v zoxide >/dev/null 2>&1; then + eval "$(zoxide init "$(basename "$SHELL")")" +fi + +# Smarter shell history +if command -v atuin >/dev/null 2>&1; then + eval "$(atuin init "$(basename "$SHELL")")" +fi + +if command -v mcfly >/dev/null 2>&1; then + eval "$(mcfly init "$(basename "$SHELL")")" +fi + +# Directory-based environment +if command -v direnv >/dev/null 2>&1; then + eval "$(direnv hook "$(basename "$SHELL")")" +fi + +if command -v fzf >/dev/null 2>&1; then + case "$(basename "$SHELL")" in + bash|zsh|fish) + eval "$(fzf --$(basename "$SHELL"))" + ;; + *) + # shell not supported for fzf init + ;; + esac +fi +# "Did you mean...?" for mistyped commands +if command -v thefuck >/dev/null 2>&1; then + eval "$(thefuck --alias)" +fi +' +cat </etc/.kraytrc +$KRAYT_MARKER_START +$KRAYT_BLOCK +$KRAYT_MARKER_END +EOF + +KRAYT_RC_SOURCE=' +if [ -f /etc/.kraytrc ]; then + . /etc/.kraytrc +fi +' + +# List of common rc/profile files to patch +RC_FILES=" +/etc/profile +/etc/bash.bashrc +/etc/bash/bashrc +/etc/bashrc +/etc/ashrc +/etc/zsh/zshrc +/etc/zsh/zprofile +/etc/shinit +/etc/fish/config.fish +" + +echo "Searching for rc files..." + +for rc_file in $RC_FILES; do + if [ -f "$rc_file" ]; then + echo "* Found $rc_file" + + # Check if already patched + if grep -q "$KRAYT_MARKER_START" "$rc_file"; then + echo "- $rc_file already has krayt block. Skipping." + else + echo "+ Patching $rc_file" + echo "" >>"$rc_file" + echo "$KRAYT_MARKER_START" >>"$rc_file" + echo "$KRAYT_RC_SOURCE" >>"$rc_file" + echo "$KRAYT_MARKER_END" >>"$rc_file" + fi + fi +done diff --git a/krayt/templates/motd.sh b/krayt/templates/motd.sh new file mode 100644 index 0000000..5838161 --- /dev/null +++ b/krayt/templates/motd.sh @@ -0,0 +1,40 @@ +cat </etc/motd +┌───────────────────────────────────┐ +│Krayt Dragon's Lair │ +│A safe haven for volume inspection │ +└───────────────────────────────────┘ + +"Inside every volume lies a pearl of wisdom waiting to be discovered." +{%- if mounts %} + +Mounted Volumes: +{%- for mount in mounts %} +- {{ mount.name }}:{{ mount.mount_path }} +{%- endfor %} +{%- endif %} + +{%- if pvcs %} + +Persistent Volume Claims: +{%- for pvc in pvcs %} +- {{ pvc }} +{%- endfor %} +{%- endif %} + +{%- if secrets %} + +Mounted Secrets: +{%- for secret in secrets %} +- {{ secret }} +{%- endfor %} +{%- endif %} + +{%- if additional_packages %} + +Additional Packages: +{%- for package in additional_packages %} +- {{ package }} +{%- endfor %} +{%- endif %} + +EOF diff --git a/krayt.py b/krayt1.py similarity index 100% rename from krayt.py rename to krayt1.py diff --git a/krayt2.py b/krayt2.py new file mode 100755 index 0000000..e79d280 --- /dev/null +++ b/krayt2.py @@ -0,0 +1,337 @@ +#!/usr/bin/env -S uv run --quiet --script +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "typer", +# "kubernetes", +# "InquirerPy", +# ] +# /// + +from InquirerPy import inquirer +from kubernetes import client, config +import os +import typer +from typing import List, Optional + +app = typer.Typer(name="krayt") + +VERSION = "0.1.0" + +# Default values +container_image_default = "ubuntu:22.04" +container_name_default = "krayt-container" + +KNOWN_PACKAGE_MANAGERS = { + "apk": "apk add", + "dnf": "dnf install -y", + "yum": "yum install -y", + "apt-get": "apt-get update && apt-get install -y", + "apt": "apt update && apt install -y", + "zypper": "zypper install -y", + "pacman": "pacman -Sy --noconfirm", +} + + +def load_kube_config(): + try: + config.load_kube_config() + except Exception as e: + typer.secho(f"Failed to load kubeconfig: {e}", fg=typer.colors.RED) + raise typer.Exit(1) + + +def detect_package_manager_command() -> str: + checks = [ + f"which {pm} >/dev/null 2>&1 && echo {cmd}" + for pm, cmd in KNOWN_PACKAGE_MANAGERS.items() + ] + return " || ".join(checks) + + +def get_proxy_env_vars() -> List[client.V1EnvVar]: + proxy_vars = [ + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "NO_PROXY", + "no_proxy", + ] + env_vars = [] + for var in proxy_vars: + value = os.environ.get(var) + if value: + env_vars.append(client.V1EnvVar(name=var, value=value)) + return env_vars + + +def fuzzy_pick_pod(namespace: Optional[str] = None) -> str: + load_kube_config() + core_v1 = client.CoreV1Api() + if namespace is None: + pods = core_v1.list_pod_for_all_namespaces() + else: + pods = core_v1.list_namespaced_pod(namespace=namespace) + pods = {pod.metadata.name: pod for pod in pods.items} + if not pods: + typer.secho("No pods found to clone.", fg=typer.colors.RED) + raise typer.Exit(1) + choice = inquirer.fuzzy( + message="Select a pod to clone:", choices=pods.keys() + ).execute() + return pods[choice] + + +def clone_pod(core_v1, namespace: str, source_pod_name: str): + source_pod = core_v1.read_namespaced_pod(name=source_pod_name, namespace=namespace) + container = source_pod.spec.containers[0] + breakpoint() + return ( + container.image, + container.volume_mounts, + source_pod.spec.volumes, + container.env, + source_pod.spec.image_pull_secrets, + ) + + +@app.command() +def create( + image: str = typer.Option( + container_image_default, "--image", "-i", help="Image to use for the container" + ), + name: str = typer.Option( + container_name_default, "--name", "-n", help="Name for the krayt container" + ), + yes: bool = typer.Option( + False, "--yes", "-Y", help="Non-interactive, pull images without asking" + ), + fuzzy_clone: bool = typer.Option( + False, + "--fuzzy-clone", + "-f", + help="Clone an existing pod", + ), + clone: Optional[str] = typer.Option( + None, "--clone", "-c", help="Clone an existing krayt container" + ), + volume: List[str] = typer.Option( + [], + "--volume", + help="Additional volumes to add to the container (pvc-name:/mount/path)", + ), + additional_flags: List[str] = typer.Option( + [], + "--additional-flags", + "-a", + help="Additional flags to pass to the container manager command", + ), + additional_packages: List[str] = typer.Option( + [], + "--additional-packages", + "-ap", + help="Additional packages to install during setup", + ), + init_hooks: List[str] = typer.Option( + [], "--init-hooks", help="Commands to execute at the end of initialization" + ), + pre_init_hooks: List[str] = typer.Option( + [], + "--pre-init-hooks", + help="Commands to execute at the start of initialization", + ), + namespace: str = typer.Option(None, help="Kubernetes namespace"), + dry_run: bool = typer.Option( + False, "--dry-run", "-d", help="Only print the generated Kubernetes manifest" + ), + verbose: bool = typer.Option(False, "--verbose", "-v", help="Show more verbosity"), + image_pull_secret: Optional[str] = typer.Option( + None, + "--image-pull-secret", + help="Name of the Kubernetes secret for pulling the image", + ), +): + """Create a new Kubernetes pod inspired by distrobox.""" + load_kube_config() + core_v1 = client.CoreV1Api() + + if fuzzy_clone: + namespace, clone = fuzzy_pick_pod(namespace) + + if clone is not None: + image, volume_mounts, volumes, env_vars, image_pull_secrets = clone_pod( + core_v1, namespace, clone + ) + else: + volume_mounts = [] + volumes = [] + env_vars = get_proxy_env_vars() + for idx, pvc_entry in enumerate(volume): + try: + pvc_name, mount_path = pvc_entry.split(":", 1) + except ValueError: + typer.secho( + f"Invalid volume format: {pvc_entry}. Use pvc-name:/mount/path", + fg=typer.colors.RED, + ) + raise typer.Exit(1) + + volumes.append( + client.V1Volume( + name=f"volume-{idx}", + persistent_volume_claim=client.V1PersistentVolumeClaimVolumeSource( + claim_name=pvc_name + ), + ) + ) + volume_mounts.append( + client.V1VolumeMount( + name=f"volume-{idx}", + mount_path=mount_path, + ) + ) + + package_manager_detection = detect_package_manager_command() + package_manager_detection = """ +detect_package_manager_and_install_command() { + if [ $# -eq 0 ]; then + echo "Usage: detect_package_manager_and_install_command [package2] [...]" + return 1 + fi + + if command -v apt >/dev/null 2>&1; then + PKG_MANAGER="apt" + UPDATE_CMD="apt update &&" + INSTALL_CMD="apt install -y" + elif command -v dnf >/dev/null 2>&1; then + PKG_MANAGER="dnf" + UPDATE_CMD="" + INSTALL_CMD="dnf install -y" + elif command -v yum >/dev/null 2>&1; then + PKG_MANAGER="yum" + UPDATE_CMD="" + INSTALL_CMD="yum install -y" + elif command -v pacman >/dev/null 2>&1; then + PKG_MANAGER="pacman" + UPDATE_CMD="" + INSTALL_CMD="pacman -Sy --noconfirm" + elif command -v zypper >/dev/null 2>&1; then + PKG_MANAGER="zypper" + UPDATE_CMD="" + INSTALL_CMD="zypper install -y" + elif command -v apk >/dev/null 2>&1; then + PKG_MANAGER="apk" + UPDATE_CMD="" + INSTALL_CMD="apk add" + else + echo "No supported package manager found." + return 2 + fi + + PACKAGES="$*" + + if [ -n "$UPDATE_CMD" ]; then + echo "$UPDATE_CMD + echo $INSTALL_CMD $PACKAGES" + $UPDATE_CMD + $INSTALL_CMD $PACKAGES + + else + echo "$INSTALL_CMD $PACKAGES" + $INSTALL_CMD $PACKAGES + fi +} +""" + + pre_hooks_command = " && ".join(pre_init_hooks) if pre_init_hooks else "" + install_packages_command = "" + if additional_packages: + install_packages_command = f"{package_manager_detection}\n detect_package_manager_and_install_command {' '.join(additional_packages)}" + # install_packages_command = ( + # f"$({{package_manager_detection}} {' '.join(additional_packages)})" + # ) + post_hooks_command = " && ".join(init_hooks) if init_hooks else "" + + combined_command_parts = [ + cmd + for cmd in [pre_hooks_command, install_packages_command, post_hooks_command] + if cmd + ] + command = None + + if combined_command_parts: + combined_command = " && ".join(combined_command_parts) + command = ["/bin/sh", "-c", f"{combined_command} && tail -f /dev/null"] + + pod_spec = client.V1PodSpec( + containers=[ + client.V1Container( + name=name, + image=image, + command=command, + volume_mounts=volume_mounts if volume_mounts else None, + env=env_vars if env_vars else None, + ) + ], + volumes=volumes if volumes else None, + restart_policy="Never", + ) + + if image_pull_secret: + pod_spec.image_pull_secrets = [ + client.V1LocalObjectReference(name=image_pull_secret) + ] + elif clone and image_pull_secrets: + pod_spec.image_pull_secrets = image_pull_secrets + + pod = client.V1Pod( + metadata=client.V1ObjectMeta(name=name, namespace=namespace), spec=pod_spec + ) + + if dry_run or verbose: + typer.secho(f"Dry-run/Verbose: Pod definition:\n{pod}", fg=typer.colors.BLUE) + + if dry_run: + typer.secho("Dry run completed.", fg=typer.colors.GREEN) + raise typer.Exit() + + typer.secho( + f"Creating pod '{name}' in namespace '{namespace}'...", fg=typer.colors.GREEN + ) + core_v1.create_namespaced_pod(namespace=namespace, body=pod) + typer.secho("Pod created successfully.", fg=typer.colors.GREEN) + + +@app.command("fuzzy-pick-pod") +def cli_fuzzy_pick_pod( + namespace: str = typer.Option(None, help="Kubernetes namespace"), +): + load_kube_config() + pod = fuzzy_pick_pod(namespace) + + if not pod: + typer.secho("No pod selected.", fg=typer.colors.RED) + raise typer.Exit(1) + + typer.secho("Selected pod", fg=typer.colors.GREEN) + typer.secho(f"Name: {pod.metadata.name}", fg=typer.colors.GREEN) + typer.secho(f"Namespace: {pod.metadata.namespace}", fg=typer.colors.GREEN) + typer.secho(f"Image: {pod.spec.containers[0].image}", fg=typer.colors.GREEN) + typer.secho(f"Command: {pod.spec.containers[0].command}", fg=typer.colors.GREEN) + typer.secho(f"Volume mounts: {pod.spec.volumes}", fg=typer.colors.GREEN) + typer.secho( + f"Environment variables: {pod.spec.containers[0].env}", fg=typer.colors.GREEN + ) + + return pod + + +@app.command() +def version(show: bool = typer.Option(False, "--version", "-V", help="Show version")): + if show: + typer.echo(f"krayt version {VERSION}") + + +if __name__ == "__main__": + app() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5ef96cd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,70 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] + +[tool.hatch.build.targets.sdist] +exclude = ["/.github"] + +[tool.hatch.build.targets.binary] + +[project] +name = "krayt" +dynamic = ["version"] +description = 'kubernetes volume explorer' +readme = "README.md" +requires-python = ">=3.8" +keywords = [] +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +dependencies = [ + "typer", + "kubernetes", + "inquirerPy", + "inquirer", + "jinja2", + "iterfzf", + "pydantic", + "more-itertools", +] + +[[project.authors]] +name = "Waylon Walker" +email = "waylon@waylonwalker.com" + +[project.license] +file = "LICENSE" + +[project.urls] +Homepage = "https://github.com/waylonwalker/krayt#readme" +Documentation = "https://github.com/waylonwalker/krayt#readme" +Changelog = "https://github.com/waylonwalker/krayt#changelog" +Issues = "https://github.com/waylonwalker/krayt/issues" +Source = "https://github.com/waylonwalker/krayt" + +[tool.hatch.version] +path = "krayt/__about__.py" + +[project.scripts] +krayt = "krayt.cli:app" + +[tool.hatch.envs.default] +dependencies = [ + "ruff", + "pyinstrument", +] + +[tool.hatch.envs.default.scripts] +lint = "ruff check krayt" +format = "ruff format krayt" +lint-format = ['lint', 'format'] diff --git a/scripts/get_release_notes.py b/scripts/get_release_notes.py index d782a15..f564136 100755 --- a/scripts/get_release_notes.py +++ b/scripts/get_release_notes.py @@ -21,7 +21,11 @@ def get_release_notes(version): You can install krayt using one of these methods: -> !krayt requires [uv](https://docs.astral.sh/uv/getting-started/installation/) to be installed +## pypi + +``` bash +pip install krayt +``` ### Using i.jpillora.com (recommended) @@ -37,8 +41,8 @@ curl -fsSL https://github.com/waylonwalker/krayt/releases/download/v{version}/in ### 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)""" +- [x86_64-unknown-linux-gnu](https://github.com/waylonwalker/krayt/releases/download/v{version}/krayt-{version}-x86_64-unknown-linux-gnu) +- [aarch64-unknown-linux-gnu](https://github.com/waylonwalker/krayt/releases/download/v{version}/krayt-{version}-aarch64-unknown-linux-gnu)""" # Get help output for main command and all subcommands try: @@ -46,17 +50,23 @@ You can also manually download the archive for your platform from the releases p # Get main help output main_help = subprocess.check_output( - ["./krayt.py", "--help"], + ["krayt", "--help"], stderr=subprocess.STDOUT, universal_newlines=True, ) help_outputs.append(("Main Command", main_help)) # Get help for each subcommand - subcommands = ["create", "exec", "clean", "version"] + subcommands = [ + "create", + "exec", + "clean", + "version", + "pod", + ] for cmd in subcommands: cmd_help = subprocess.check_output( - ["./krayt.py", cmd, "--help"], + ["krayt", cmd, "--help"], stderr=subprocess.STDOUT, universal_newlines=True, ) diff --git a/scripts/install.sh.template b/scripts/install.sh.template index 5fc0fb5..8e930c9 100644 --- a/scripts/install.sh.template +++ b/scripts/install.sh.template @@ -148,11 +148,11 @@ function install { FTYPE="" case "${OS}_${ARCH}" in "linux_amd64") - URL="https://github.com/WaylonWalker/nvim-manager/releases/download/v${RELEASE}/nvim-manager-${RELEASE}-x86_64-unknown-linux-gnu.tar.gz" + URL="https://github.com/WaylonWalker/krayt/releases/download/v${RELEASE}/krayt-${RELEASE}-x86_64-unknown-linux-gnu.tar.gz" FTYPE=".tar.gz" ;; "linux_arm64") - URL="https://github.com/WaylonWalker/nvim-manager/releases/download/v${RELEASE}/nvim-manager-${RELEASE}-aarch64-unknown-linux-gnu.tar.gz" + URL="https://github.com/WaylonWalker/krayt/releases/download/v${RELEASE}/krayt-${RELEASE}-aarch64-unknown-linux-gnu.tar.gz" FTYPE=".tar.gz" ;; *) fail "No asset for platform ${OS}-${ARCH}" ;; @@ -193,7 +193,7 @@ function install { unzip -o -qq tmp.zip || fail "unzip failed" rm tmp.zip || fail "cleanup failed" elif [[ $FTYPE = ".bin" ]]; then - bash -c "$GET $URL" >"nvim-manager_${OS}_${ARCH}" || fail "download failed" + bash -c "$GET $URL" >"krayt_${OS}_${ARCH}" || fail "download failed" else fail "unknown file type: $FTYPE" fi diff --git a/test.sh b/test.sh new file mode 100644 index 0000000..1643cab --- /dev/null +++ b/test.sh @@ -0,0 +1,247 @@ +mkdir -p /etc/krayt +cat <<'KRAYT_INIT_SH_EOF' >/etc/krayt/init.sh +detect_package_manager_and_install() { + if [ $# -eq 0 ]; then + echo "Usage: detect_package_manager_and_install [package2] [...]" + return 1 + fi + + if command -v apt >/dev/null 2>&1; then + PKG_MANAGER="apt" + UPDATE_CMD="apt update &&" + INSTALL_CMD="apt install -y" + elif command -v dnf >/dev/null 2>&1; then + PKG_MANAGER="dnf" + UPDATE_CMD="" + INSTALL_CMD="dnf install -y" + elif command -v yum >/dev/null 2>&1; then + PKG_MANAGER="yum" + UPDATE_CMD="" + INSTALL_CMD="yum install -y" + elif command -v pacman >/dev/null 2>&1; then + PKG_MANAGER="pacman" + UPDATE_CMD="" + INSTALL_CMD="pacman -Sy --noconfirm" + elif command -v zypper >/dev/null 2>&1; then + PKG_MANAGER="zypper" + UPDATE_CMD="" + INSTALL_CMD="zypper install -y" + elif command -v apk >/dev/null 2>&1; then + PKG_MANAGER="apk" + UPDATE_CMD="" + INSTALL_CMD="apk add" + else + echo "No supported package manager found." + return 2 + fi + + echo "Using package manager: $PKG_MANAGER" + + if [ -n "$UPDATE_CMD" ]; then + echo "Running package manager update..." + eval "$UPDATE_CMD" + fi + + FAILED_PKGS="" + + for pkg in "$@"; do + echo "Installing package: $pkg" + if ! eval "$INSTALL_CMD $pkg"; then + echo "⚠️ Warning: Failed to install package: $pkg" + FAILED_PKGS="$FAILED_PKGS $pkg" + fi + done + + if [ -n "$FAILED_PKGS" ]; then + echo "⚠️ The following packages failed to install:" + for failed_pkg in $FAILED_PKGS; do + echo " - $failed_pkg" + done + else + echo "✅ All requested packages installed successfully." + fi + +} + +installer() { + if [ $# -eq 0 ]; then + echo "Usage: installer [package2] [...]" + return 1 + fi + + for pkg in "$@"; do + echo "Installing package with installer: $pkg" + ( + orig_dir="$(pwd)" + cd /usr/local/bin || exit 1 + curl -fsSL https://i.jpillora.com/${pkg} | sh + cd "$orig_dir" || exit 1 + ) + done +} + + + +detect_package_manager_and_install eza +detect_package_manager_and_install hexyl +detect_package_manager_and_install mariadb +detect_package_manager_and_install coreutils +detect_package_manager_and_install ncdu +detect_package_manager_and_install postgresql +detect_package_manager_and_install atuin +detect_package_manager_and_install redis +detect_package_manager_and_install file +detect_package_manager_and_install netcat-openbsd +detect_package_manager_and_install traceroute +detect_package_manager_and_install fd +detect_package_manager_and_install iperf3 +detect_package_manager_and_install aws-cli +detect_package_manager_and_install dust +detect_package_manager_and_install sqlite-dev +detect_package_manager_and_install fish +detect_package_manager_and_install bat +detect_package_manager_and_install ripgrep +detect_package_manager_and_install difftastic +detect_package_manager_and_install zsh +detect_package_manager_and_install sqlite-libs +detect_package_manager_and_install bind-tools +detect_package_manager_and_install nmap +detect_package_manager_and_install mysql +detect_package_manager_and_install htop +detect_package_manager_and_install sqlite +detect_package_manager_and_install fzf +detect_package_manager_and_install bottom +detect_package_manager_and_install wget +detect_package_manager_and_install mtr +detect_package_manager_and_install bash +detect_package_manager_and_install curl +detect_package_manager_and_install starship +detect_package_manager_and_install mongodb +detect_package_manager_and_install jq +detect_package_manager_and_install yq + + + +cat </etc/motd +┌───────────────────────────────────┐ +│Krayt Dragon's Lair │ +│A safe haven for volume inspection │ +└───────────────────────────────────┘ + +"Inside every volume lies a pearl of wisdom waiting to be discovered." + +Additional Packages: +- bundle:all + +EOF +KRAYT_MARKER_START="# >>> Added by krayt-inject <<<" +KRAYT_MARKER_END='# <<< End krayt-inject >>>' +KRAYT_BLOCK=' +if [ -t 1 ] && [ -f /etc/motd ] && [ -z "$MOTD_SHOWN" ]; then + cat /etc/motd + export MOTD_SHOWN=1 +fi + +# fix $SHELL, not set in some distros like alpine +if [ -n "$BASH_VERSION" ]; then + export SHELL=/bin/bash +elif [ -n "$ZSH_VERSION" ]; then + export SHELL=/bin/zsh +else + export SHELL=/bin/sh +fi + +# krayt ENVIRONMENT +export KRAYT_ADDITIONAL_PACKAGES="bundle:all" +# Universal shell initializers + +# Prompt +if command -v starship >/dev/null 2>&1; then + eval "$(starship init "$(basename "$SHELL")")" +fi + +# Smarter cd +if command -v zoxide >/dev/null 2>&1; then + eval "$(zoxide init "$(basename "$SHELL")")" +fi + +# Smarter shell history +if command -v atuin >/dev/null 2>&1; then + eval "$(atuin init "$(basename "$SHELL")")" +fi + +if command -v mcfly >/dev/null 2>&1; then + eval "$(mcfly init "$(basename "$SHELL")")" +fi + +# Directory-based environment +if command -v direnv >/dev/null 2>&1; then + eval "$(direnv hook "$(basename "$SHELL")")" +fi + +if command -v fzf >/dev/null 2>&1; then + case "$(basename "$SHELL")" in + bash|zsh|fish) + eval "$(fzf --$(basename "$SHELL"))" + ;; + *) + # shell not supported for fzf init + ;; + esac +fi +# "Did you mean...?" for mistyped commands +if command -v thefuck >/dev/null 2>&1; then + eval "$(thefuck --alias)" +fi +' +cat </etc/.kraytrc +$KRAYT_MARKER_START +$KRAYT_BLOCK +$KRAYT_MARKER_END +EOF + +KRAYT_RC_SOURCE=' +if [ -f /etc/.kraytrc ]; then + . /etc/.kraytrc +fi +' + +# List of common rc/profile files to patch +RC_FILES=" +/etc/profile +/etc/bash.bashrc +/etc/bash/bashrc +/etc/bashrc +/etc/ashrc +/etc/zsh/zshrc +/etc/zsh/zprofile +/etc/shinit +/etc/fish/config.fish +" + +echo "Searching for rc files..." + +for rc_file in $RC_FILES; do + if [ -f "$rc_file" ]; then + echo "* Found $rc_file" + + # Check if already patched + if grep -q "$KRAYT_MARKER_START" "$rc_file"; then + echo "- $rc_file already has krayt block. Skipping." + else + echo "+ Patching $rc_file" + echo "" >>"$rc_file" + echo "$KRAYT_MARKER_START" >>"$rc_file" + echo "$KRAYT_RC_SOURCE" >>"$rc_file" + echo "$KRAYT_MARKER_END" >>"$rc_file" + fi + fi +done +echo "Krayt environment ready. Sleeping forever..." +trap "echo 'Received SIGTERM. Exiting...'; exit 0" TERM +tail -f /dev/null & +wait +KRAYT_INIT_SH_EOF + +chmod +x /etc/krayt/init.sh +/etc/krayt/init.sh diff --git a/test.yaml b/test.yaml new file mode 100644 index 0000000..b2b19a0 --- /dev/null +++ b/test.yaml @@ -0,0 +1,206 @@ +mkdir -p /etc/krayt +cat <<'KRAYT_INIT_SH_EOF' >/etc/krayt/init.sh +detect_package_manager_and_install() { + if [ $# -eq 0 ]; then + echo "Usage: detect_package_manager_and_install [package2] [...]" + return 1 + fi + + if command -v apt >/dev/null 2>&1; then + PKG_MANAGER="apt" + UPDATE_CMD="apt update &&" + INSTALL_CMD="apt install -y" + elif command -v dnf >/dev/null 2>&1; then + PKG_MANAGER="dnf" + UPDATE_CMD="" + INSTALL_CMD="dnf install -y" + elif command -v yum >/dev/null 2>&1; then + PKG_MANAGER="yum" + UPDATE_CMD="" + INSTALL_CMD="yum install -y" + elif command -v pacman >/dev/null 2>&1; then + PKG_MANAGER="pacman" + UPDATE_CMD="" + INSTALL_CMD="pacman -Sy --noconfirm" + elif command -v zypper >/dev/null 2>&1; then + PKG_MANAGER="zypper" + UPDATE_CMD="" + INSTALL_CMD="zypper install -y" + elif command -v apk >/dev/null 2>&1; then + PKG_MANAGER="apk" + UPDATE_CMD="" + INSTALL_CMD="apk add" + else + echo "No supported package manager found." + return 2 + fi + + echo "Using package manager: $PKG_MANAGER" + + if [ -n "$UPDATE_CMD" ]; then + echo "Running package manager update..." + eval "$UPDATE_CMD" + fi + + FAILED_PKGS=() + + for pkg in "$@"; do + echo "Installing package: $pkg" + if ! eval "$INSTALL_CMD $pkg"; then + echo "⚠️ Warning: Failed to install package: $pkg" + FAILED_PKGS+=("$pkg") + fi + done + + if [ ${#FAILED_PKGS[@]} -ne 0 ]; then + echo "⚠️ The following packages failed to install:" + for failed_pkg in "${FAILED_PKGS[@]}"; do + echo " - $failed_pkg" + done + else + echo "✅ All requested packages installed successfully." + fi + +} + + + + +cat </etc/motd +┌───────────────────────────────────┐ +│Krayt Dragon's Lair │ +│A safe haven for volume inspection │ +└───────────────────────────────────┘ + +"Inside every volume lies a pearl of wisdom waiting to be discovered." + +Mounted Volumes: +- hi + +Persistent Volume Claims: +- hi +- hello + +Additional Packages: +- htop +- ripgrep +- uv:copier + +EOF +KRAYT_MARKER_START="# >>> Added by krayt-inject <<<" +KRAYT_MARKER_END='# <<< End krayt-inject >>>' +KRAYT_BLOCK=' +if [ -t 1 ] && [ -f /etc/motd ] && [ -z "$MOTD_SHOWN" ]; then + cat /etc/motd + export MOTD_SHOWN=1 +fi + +# fix $SHELL, not set in some distros like alpine +if [ -n "$BASH_VERSION" ]; then + export SHELL=/bin/bash +elif [ -n "$ZSH_VERSION" ]; then + export SHELL=/bin/zsh +else + export SHELL=/bin/sh +fi + +# krayt ENVIRONMENT +export KRAYT_PVCS="hi hello" + +export KRAYT_VOLUMES="hi" + +export KRAYT_ADDITIONAL_PACKAGES="htop ripgrep uv:copier" +# Universal shell initializers + +# Prompt +if command -v starship >/dev/null 2>&1; then + eval "$(starship init "$(basename "$SHELL")")" +fi + +# Smarter cd +if command -v zoxide >/dev/null 2>&1; then + eval "$(zoxide init "$(basename "$SHELL")")" +fi + +# Smarter shell history +if command -v atuin >/dev/null 2>&1; then + eval "$(atuin init "$(basename "$SHELL")")" +fi + +if command -v mcfly >/dev/null 2>&1; then + eval "$(mcfly init "$(basename "$SHELL")")" +fi + +# Directory-based environment +if command -v direnv >/dev/null 2>&1; then + eval "$(direnv hook "$(basename "$SHELL")")" +fi + +if command -v fzf >/dev/null 2>&1; then + case "$(basename "$SHELL")" in + bash|zsh|fish) + eval "$(fzf --$(basename "$SHELL"))" + ;; + *) + # shell not supported for fzf init + ;; + esac +fi +# "Did you mean...?" for mistyped commands +if command -v thefuck >/dev/null 2>&1; then + eval "$(thefuck --alias)" +fi +' +cat </etc/.kraytrc +$KRAYT_MARKER_START +$KRAYT_BLOCK +$KRAYT_MARKER_END +EOF + +KRAYT_RC_SOURCE=' +if [ -f /etc/.kraytrc ]; then + . /etc/.kraytrc +fi +' + +# List of common rc/profile files to patch +RC_FILES=" +/etc/profile +/etc/bash.bashrc +/etc/bash/bashrc +/etc/bashrc +/etc/ashrc +/etc/zsh/zshrc +/etc/zsh/zprofile +/etc/shinit +/etc/fish/config.fish +" + +echo "Searching for rc files..." + +for rc_file in $RC_FILES; do + if [ -f "$rc_file" ]; then + echo "* Found $rc_file" + + # Check if already patched + if grep -q "$KRAYT_MARKER_START" "$rc_file"; then + echo "- $rc_file already has krayt block. Skipping." + else + echo "+ Patching $rc_file" + echo "" >>"$rc_file" + echo "$KRAYT_MARKER_START" >>"$rc_file" + echo "$KRAYT_RC_SOURCE" >>"$rc_file" + echo "$KRAYT_MARKER_END" >>"$rc_file" + fi + fi +done +touch here.txt + +echo "Krayt environment ready. Sleeping forever..." +trap "echo 'Received SIGTERM. Exiting...'; exit 0" TERM +tail -f /dev/null & +wait +KRAYT_INIT_SH_EOF + +chmod +x /etc/krayt/init.sh +/etc/krayt/init.sh