// STRYKE-K8S — KUBERNETES CLIENT FOR STRYKE // GET + APPLY + DELETE + SCALE + LOGS + WATCH + EXEC

stryke package · cdylib libstryke_k8s.{dylib,so} · dlopen'd in-process on use K8s · opt-in (kept out of stryke core)

Report GitHub Issues
// Color scheme

>_STRYKE-K8S

Any kubeconfig-reachable cluster, no kubectl. Kubernetes client for stryke. Get / apply / delete / scale / logs / watch / exec against any kubeconfig-reachable cluster (kind, k3s, minikube, EKS, GKE, AKS, OpenShift, vanilla). Opt-in package, kept out of the stryke core binary so the daily-driver install stays slim; ships as a Rust cdylib that stryke dlopens in-process on first use K8s.

Install

# from a release (no rustc on the consumer machine)
s pkg install -g github.com/MenkeTechnologies/stryke-k8s

# from a local checkout
cd ~/projects/stryke-k8s
cargo build --release            # produces target/release/libstryke_k8s.{dylib,so}
s pkg install -g .               # cdylib lands in ~/.stryke/store/k8s@<version>/

# one-liner
make install

The cdylib is dlopened in-process on first use K8s — no helper-binary fork per call. A shared tokio runtime + kube::Client cache keyed by kubeconfig context is held in OnceCell for the life of the process. Any stryke script that declares use K8s resolves the package automatically.

Quick start: use K8s

Connection is implicit — $KUBECONFIG, ~/.kube/config, or an in-cluster service account, in that resolution order, with no setup call. Every public fn accepts the same per-call connection overrides (see Per-call overrides).

use K8s

# Connection: $KUBECONFIG / ~/.kube/config / in-cluster SA — no setup.
p K8s::current_context()
p K8s::version()->{gitVersion}

# List — `kind` accepts short forms or strict GVK.
val @pods   = K8s::get "pods",  namespace => "default"
val @deploy = K8s::get "apps/v1/Deployment", namespace => "kube-system"

# Get one.
val $pod = K8s::get_one "pod", "echo-7d9f", namespace => "default"

# Server-side apply (full doc; idempotent).
K8s::apply {
    apiVersion => "v1",
    kind       => "ConfigMap",
    metadata   => { name => "cfg", namespace => "ci" },
    data       => { hello => "world" },
}

# Scale.
K8s::scale "deploy", "echo", 5, namespace => "ci"

# Logs (buffered).
val $text = K8s::logs "echo-7d9f", namespace => "ci", tail => 100

# Delete (cascading on Namespace).
K8s::delete_resource "namespace", "ci"
current contextp K8s::current_context()
server versionp K8s::version()->{gitVersion}
list podsval @pods = K8s::get "pods", namespace => "default"
strict GVKval @d = K8s::get "apps/v1/Deployment", namespace => "kube-system"
get oneval $pod = K8s::get_one "pod", "echo-7d9f", namespace => "default"
server-side applyK8s::apply { apiVersion => "v1", kind => "ConfigMap", metadata => { name => "cfg", namespace => "ci" }, data => { hello => "world" } }
scaleK8s::scale "deploy", "echo", 5, namespace => "ci"
logs (buffered)val $text = K8s::logs "echo-7d9f", namespace => "ci", tail => 100
delete (cascading on Namespace)K8s::delete_resource "namespace", "ci"

Per-call connection overrides

Every public fn takes the same connection options at the tail of its %opts. Pass a context for a single call without changing the ambient kubeconfig:

val %prod = (context => "prod-eks")
K8s::get "pods", namespace => "payments", %prod
contextkubeconfig context to use ($KUBE_CONTEXT)
kubeconfigexplicit kubeconfig file path ($KUBECONFIG)
namespacetarget namespace; all_namespaces => 1 on list reads spans the cluster

GVK shortcuts

Anywhere a kind is accepted, the helper resolves the input against the cluster's discovery API. All of the following work for Pods:

pod        pods        po        v1/Pod        /v1/Pod

For namespaced typed kinds you can use the strict triple:

apps/v1/Deployment            batch/v1/Job
networking.k8s.io/v1/Ingress  rbac.authorization.k8s.io/v1/Role

Custom resources work the same way once installed:

example.com/v1/Widget

API reference

The package exposes 74 public K8s::* functions, backed by 70 k8s__* FFI symbols on the cdylib (the authoritative export list is [ffi].exports in stryke.toml). They split into cluster operations (which dispatch over FFI to kube) and pure helpers (which compute locally, no cluster contact). Streaming-only ops (watch, logs_follow, exec) are deferred — calling them dies with a message until the callback FFI ships.

Read paths

FunctionReturns / opts
K8s::get $kind, %opts→ @objects. opts: namespace, label_selector, field_selector, limit, all_namespaces
K8s::get_one $kind, $name, %opts→ \%doc | undef
K8s::get_yaml $kind, $name, %opts→ $yaml — one resource as a YAML manifest string
K8s::exists $kind, $name, %opts→ 1 | 0 — presence probe, no object body fetched
K8s::namespaces %opts→ @{ {name, status, labels} }
K8s::nodes %opts→ @{ {name, ready, schedulable, roles, version} } — kubectl get nodes columns
K8s::api_resources %opts→ @{ {group, version, kind, plural, namespaced, verbs} }
K8s::watch $kind, %opts→ $count — deferred; dies

Events for one resource are reachable as K8s::get "events", field_selector => "involvedObject.name=$name".

Write paths

FunctionReturns / opts
K8s::apply \%doc, %opts→ \%applied — server-side apply; namespace from doc.metadata
K8s::create \%doc, %opts→ \%created
K8s::replace \%doc, %opts→ \%replaced (resource must already exist)
K8s::patch $kind, $name, \%patch, %opts→ \%patched. opts: type (merge|strategic, default merge), namespace
K8s::delete_resource $kind, $name, %opts→ \%result. opts: namespace
K8s::delete_collection $kind, %opts→ { deleted, pending }. opts: label_selector, field_selector, namespace
K8s::scale $kind, $name, $replicas, %opts→ \%scale
K8s::get_scale $name, %opts→ { name, replicas, current_replicas, selector } — reads /scale. opts: kind (default Deployment), namespace

Rollouts + workload ops

FunctionReturns / opts
K8s::set_image $name, $container, $image, %opts→ \%obj. opts: kind, namespace
K8s::rollout_restart $name, %opts→ \%obj. opts: kind (default Deployment), namespace
K8s::rollout_status $name, %opts→ \%status — replicas / readyReplicas / conditions
K8s::rollout_history $name, %opts→ @revisions — owned ReplicaSets, newest first
K8s::autoscale $target_name, $max, %opts→ \%hpa — create HPA. opts: min, cpu_percent, target_kind
K8s::label $kind, $name, \%labels, %opts→ \%obj — merge-patch labels; key => undef removes
K8s::annotate $kind, $name, \%annotations, %opts→ \%obj

Nodes + scheduling + eviction

FunctionReturns / opts
K8s::cordon $name, %opts→ \%node — spec.unschedulable = true
K8s::uncordon $name, %opts→ \%node — spec.unschedulable = false
K8s::taint $node, $key, %opts→ \%node. opts: value, effect (default NoSchedule)
K8s::untaint $node, $key, %opts→ \%node — remove taint by key
K8s::evict $name, %opts→ \%result — graceful pod eviction; namespace required

Events + metrics + wait

FunctionReturns / opts
K8s::events %opts→ @events. opts: namespace, name (one object), limit
K8s::top_pods %opts→ @podmetrics — metrics.k8s.io; needs metrics-server
K8s::top_nodes %opts→ @nodemetrics
K8s::wait $kind, $name, %opts→ \%ok. opts: condition (default Ready, or "delete"), timeout (s, default 300), namespace

Logs + exec

FunctionReturns / opts
K8s::logs $pod, %opts→ $text. opts: namespace (required), container, tail
K8s::logs_follow $pod, %opts→ $count — deferred; dies
K8s::exec $pod, \@cmd, %opts→ $count — deferred; dies

Connection + plumbing

FunctionReturns / opts
K8s::version %opts→ \%info — gitVersion, platform, etc.
K8s::ping %opts→ 1 | ""
K8s::contexts %opts→ @{ {name, cluster, user, namespace, current} }
K8s::current_context %opts→ $name
K8s::healthz %opts→ { ok, path, body } — probe /livez|/readyz|/healthz. opts: path (default readyz); ok=false (not die) on failure
K8s::raw $path, %opts→ { path, method, status, body, json } — raw GET|DELETE against any apiserver path. opts: method (default GET)
K8s::pkg_version()→ $version_string — the cdylib's CARGO_PKG_VERSION

Pure helpers (no cluster contact)

These compute locally. Validation and quantity math mirror apimachinery rules; selector and reference helpers come in parse/build inverse pairs.

FunctionReturns / behavior
K8s::valid_name $name, %opts→ { name, mode, valid, reason }. opts: mode => subdomain|label
K8s::valid_label_value $value→ { value, valid, reason } — empty ok, ≤63, alnum start/end, -_. + uppercase
K8s::valid_label_key $key→ { key, prefix, name, valid, reason } — IsQualifiedName: optional DNS-subdomain prefix + / + ≤63 name
K8s::parse_selector $selector→ @{ {key, op, value?, values?} } — =, ==, !=, in, notin, exists
K8s::build_selector $reqs→ $selector — inverse of parse_selector
K8s::parse_field_selector $selector→ @{ {field, operator, value} } — =, ==, != only; == normalizes to =
K8s::build_field_selector $reqs→ $selector — inverse of parse_field_selector
K8s::selector_matches \%labels, $selector→ bool — AND semantics; absent key matches NotIn/NotEqual
K8s::field_selector_matches \%fields, $selector→ bool — =, ==, != only; absent field compares as empty string; ANDed
K8s::parse_resource_ref $ref→ { kind, name } — kind/name
K8s::build_resource_ref $kind, $name?→ { ref, kind, name } — bare kind when no name
K8s::parse_gvk $gvk→ { gvk, group, version, kind, core } — apps/v1/Deployment, v1/Pod, or bare Pod
K8s::build_gvk $kind, $version?, $group?→ { gvk, group, version, kind, core } — a group requires a version
K8s::parse_api_version $apiVersion→ { api_version, group, version, core } — v1 → core; apps/v1 → group=apps
K8s::build_api_version $group, $version→ { api_version, group, version, core } — ("","v1")→v1; ("apps","v1")→apps/v1
K8s::parse_image_ref $image→ { image, registry, repository, tag, digest } — registry only when host-like
K8s::build_image_ref $repo, %opts→ { image, registry, repository, tag, digest }. opts: registry, tag, digest
K8s::parse_quantity $qty→ { quantity, number, suffix, value } — 100Mi→bytes, 500m→0.5 cores
K8s::format_quantity $value, $suffix?→ { quantity, number, suffix, value } — bytes→100Mi; inverse of parse_quantity
K8s::normalize_quantity $qty→ { input, quantity, value } — 1024Mi→1Gi, 0.5→500m, 1500m→1.5
K8s::compare_quantity $a, $b→ { a, b, a_value, b_value, cmp } — cmp -1/0/1 across units
K8s::sum_quantities @quantities→ { count, value, quantity } — 100Mi+256Mi+128Mi→484Mi
K8s::sub_quantity $a, $b, $suffix?→ { quantity, number, suffix, value, negative } — 8Gi-2Gi→6Gi; negative reported, not clamped
K8s::scale_quantity $quantity, $factor, $suffix?→ { quantity, number, suffix, value, factor } — 256Mi×3→768Mi, keeps the unit
K8s::resource_ratio $used, $total→ { used, total, used_value, total_value, ratio, percent } — 512Mi of 1Gi → 50%
K8s::pod_status $pod→ { phase, ready, ready_containers, total_containers, restarts }
K8s::container_images $object→ { containers, images } — init first, dedup; spec.containers or spec.template.spec.containers
K8s::condition $object, $type→ { type, status, reason, message, found, is_true } — found=false when absent
K8s::age_seconds $object→ { timestamp, age_seconds } — from metadata.creationTimestamp; future clamps to 0
K8s::owner_refs $object→ { owners, controller } — metadata.ownerReferences; controller undef if none
K8s::diff_merge_patch $from, $to→ \%patch — RFC 7386 merge patch; removed keys → null
K8s::drain_filter @pods→ { evictable, skipped } — skips mirror/DaemonSet/terminated pods

parse_quantity resolves a resource quantity to its base-unit value: binary suffixes (Ki/Mi/Gi/Ti/Pi/Ei) are powers of 1024, decimal suffixes (n/u/m/k/M/G/T/P/E) powers of 1000 — so 100Mi104857600 bytes and 500m0.5 cores.


Selectors + quantity math (examples)

Pure helpers run with no cluster — safe to use anywhere, including CI without a kubeconfig:

# label-selector round trip
val @reqs = K8s::parse_selector("app=web,tier in (db,cache)")
val $sel  = K8s::build_selector(\@reqs)        # inverse

# does a label map satisfy the selector?
val $ok = K8s::selector_matches({ app => "web", tier => "db" }, "app=web")

# total container memory requests across units
val $r = K8s::sum_quantities("100Mi", "256Mi", "128Mi")
p $r->{quantity}                                # 484Mi

# headroom: allocatable - requested
val $free = K8s::sub_quantity("8Gi", "2Gi")
p $free->{quantity}                             # 6Gi

# utilization across units
p K8s::resource_ratio("512Mi", "1Gi")->{percent}   # 50

# readiness summary from a Pod object
val $st = K8s::pod_status($pod)
p "$st->{ready_containers}/$st->{total_containers}"

Deferred streaming ops

K8s::watch, K8s::logs_follow, and K8s::exec are deferred in the current cdylib — they die with a clear message until the callback FFI ships. Streaming-only ops need a callback FFI shape that the v1 FfiSig::StrToStr envelope does not model. Buffered logs (K8s::logs) work today.


Why a package, not a builtin

Built on the kube crate (kube-client + kube-runtime) with k8s-openapi typed APIs. Drop-in replacement for kubectl one-liners that pipe through jq / yq / awk.

The stryke side is a thin .stk wrapper that calls k8s__* FFI symbols on the cdylib; the heavy code lives in libstryke_k8s.{dylib,so}, dlopened in-process on first use K8s. Core stryke is never linked against this package's deps, so the daily-driver install stays slim.

FFI layer

Each K8s::* wrapper builds a JSON args dict and calls a sibling k8s__* symbol resolved out of libstryke_k8s.{dylib,so}. The cdylib is dlopened in-process on first use K8s (via stryke's pkg::commands::try_load_ffi_for resolver hook); a shared tokio RUNTIME + a kube::Client cache keyed by kubeconfig context is held in OnceCell — no fork-per-call, no fresh TLS+auth handshake each time. Responses are JSON; errors come back as {"error": "<msg>"} and the wrapper dies with the message.

The exports cover version/discovery, get/list (get / get_one / get_yaml / exists / nodes), write paths (create / replace / apply / delete / delete_collection / scale / get_scale / patch), rollouts (set_image / rollout_restart / rollout_status / rollout_history / autoscale / label / annotate), node scheduling (cordon / uncordon / taint / untaint / evict), events, metrics (top_pods / top_nodes), wait, snapshot logs, raw HTTP (raw / healthz), plus the cluster-free helper set. The authoritative list is [ffi].exports in stryke.toml — 70 symbols.

Persistent state: RUNTIME is one shared tokio multi-thread runtime driving every async call; CLIENTS is the kube::Client cache keyed by kubeconfig context, reusing the same client + underlying HTTP pool across calls rather than rebuilding the TLS+auth handshake each time.


Tests

cargo test                                   # compiles, no live cluster
KUBECONFIG=~/.kube/config s test t/          # live round-trip

Tests use a unique stryke-test-$$ namespace and tear it down at exit. A local test cluster works fine:

# kind
kind create cluster --name stryke

# or k3s in docker
docker run --rm --name k3s -p 6443:6443 \
    -v $PWD/k3s-data:/output rancher/k3s:latest \
    server --disable=traefik --tls-san=127.0.0.1

Dev workflow

make             # release build
make debug
make test
make install
make clean

Roadmap

Today (helper era)Future
Generic dynamic resources via discoveryTyped wrappers for top-N kinds (zero-alloc Pod/Deployment/Service)
Server-side applykubectl diff equivalent (dry-run + server-side three-way)
Logs (buffered)Streaming logs (--follow) once callback FFI ships
Exec / watch surface defined (deferred)Stdin attach + interactive TTY; informer-style cache with resync
kubeconfig + in-cluster SAOIDC / EKS-token / GKE-gcloud exec plugins parity; port-forward (TCP tunnel)

Layout

stryke-k8s/
├── Cargo.toml             # cdylib crate manifest (crate-type = ["cdylib"], publish = false)
├── src/
│   └── lib.rs            # cdylib — k8s__* extern "C" exports
├── lib/                   # stryke .stk wrapper(s) — `use K8s`
├── stryke.toml            # stryke package manifest ([ffi] exports table)
├── t/                     # zunit-style tests
├── examples/              # runnable .stk examples
├── Makefile               # `make install` builds + installs
└── docs/                  # this site (GitHub Pages)

Sibling packages

Part of the stryke package family. Browse the others via the MenkeTechnologiesMeta umbrella repo: