>_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 context | p K8s::current_context() |
| server version | p K8s::version()->{gitVersion} |
| list pods | val @pods = K8s::get "pods", namespace => "default" |
| strict GVK | val @d = K8s::get "apps/v1/Deployment", namespace => "kube-system" |
| get one | val $pod = K8s::get_one "pod", "echo-7d9f", namespace => "default" |
| server-side apply | 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" |
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
context | kubeconfig context to use ($KUBE_CONTEXT) |
kubeconfig | explicit kubeconfig file path ($KUBECONFIG) |
namespace | target 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
| Function | Returns / 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
| Function | Returns / 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
| Function | Returns / 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
| Function | Returns / 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
| Function | Returns / 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
| Function | Returns / 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
| Function | Returns / 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.
| Function | Returns / 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 100Mi → 104857600 bytes and 500m → 0.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 discovery | Typed wrappers for top-N kinds (zero-alloc Pod/Deployment/Service) |
| Server-side apply | kubectl 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 SA | OIDC / 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:
- stryke-arrow — Apache Arrow / Parquet / Feather
- stryke-aws — S3, DynamoDB, SQS, Lambda, STS
- stryke-azure — Blob, Queues, Cosmos DB, Key Vault, Entra
- stryke-clickhouse — ClickHouse
- stryke-demo — live demos for every connector
- stryke-docker — Docker daemon API
- stryke-duckdb — embedded DuckDB SQL engine
- stryke-email — email + campaign client
- stryke-fleet — parallel expect/PTY automation
- stryke-gcp — Cloud Storage, Pub/Sub, Secret Manager, BigQuery, Firestore
- stryke-grpc — reflection-based gRPC client
- stryke-gui — mouse / keyboard / screen / clipboard automation
- stryke-kafka — Apache Kafka
- stryke-mcpd — MCP servers without a runtime
- stryke-mongo — MongoDB
- stryke-mssql — Microsoft SQL Server
- stryke-mysql — MySQL / MariaDB
- stryke-neo4j — Neo4j graph (Bolt)
- stryke-office — Office docs / PDF / images
- stryke-parquet — Parquet file inspector
- stryke-polars — Polars + ndarray + linalg + FFT
- stryke-postgres — PostgreSQL
- stryke-redis — Redis / Valkey
- stryke-scrape — web scraping / crawling
- stryke-scylla — ScyllaDB / Cassandra
- stryke-search — Elasticsearch / OpenSearch
- stryke-selenium — browser automation (WebDriver)
- stryke-spark — Spark Connect (no JVM)
- stryke-utils — pure-stryke utility belt
- stryke-zmq — ZeroMQ messaging