Hedronite · Backend Dev · Rust · Sun 2026-10-25

Kubernetes, typed kube-rs checked without a cluster

Generate the CRD from one struct, let serde catch wrong types, compute REST paths from the Resource trait, and read the client's error when no cluster is there.

Lesson Class: Dev (Rust backend · Kubernetes client)
Tested with: kube 4.2.0 (derive) · k8s-openapi 0.28.0 (v1_36) · schemars 1.2.3 · rustc/cargo 1.99.0
Lab: crate/ · cargo test (7) · no cluster
Source: kube 4.2.0 docs (CustomResource, Client) · crates.io metadata
One Struct, One Schema
#[derive(CustomResource)] turns WidgetSpec into a root type and its CRD.
Types Are Not Every Rule
serde rejects a string for i32, but a Pod with no containers still parses.
Two Sources, One Error
Client::try_default names both the kubeconfig and in-cluster failures.
Read the schema before the cluster does.

You want a Rust service to read and write Kubernetes objects. You also want the compiler to catch shape mistakes, such as a string where a number belongs or a misspelled field, before a request ever reaches the cluster.

Some vocabulary first. Kubernetes keeps its state in the apiserver, an HTTP service that stores objects and serves them over a REST interface. Every object has a kind (for example Pod) and an apiVersion that combines an API group and a version (for example apps/v1; the core group is written as plain v1). A Custom Resource Definition (CRD) is an object you submit to the apiserver to teach it a new kind, such as Widget, with a schema describing its fields. A kubeconfig is a YAML file, usually ~/.kube/config, that says which cluster to talk to and how to authenticate. A controller is a program that watches objects and runs a reconcile step: compare the desired state in an object's spec with the observed state, then act to close the gap.

The Rust crate kube (version 4.2.0 here) provides the client, the derive macros, and the controller runtime. The crate k8s-openapi (0.28.0) provides ready-made Rust structs for the built-in kinds such as Pod. Everything below can be verified without a cluster: generating a CRD from a struct, building typed objects, computing REST paths, building list filters, and seeing what the client reports when it finds no cluster. The lab machine had no cluster, no kubectl, and no kind. Nothing in this lesson touched an apiserver.

The crate and its files

The lab is a small library crate with one integration test file. Here are all three files in full.

Cargo.toml:

[package]
name = "kube_offline_lab"
version = "0.1.0"
edition = "2024"

[dependencies]
kube = { version = "=4.2.0", features = ["derive"] }
k8s-openapi = { version = "=0.28.0", features = ["latest"] }
schemars = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

The derive feature on kube turns on the CustomResource macro. k8s-openapi must be told which Kubernetes version's types to compile. Its feature list runs from v1_32 to v1_36, and one must be chosen. latest is an alias for the newest, v1_36. The schemars crate produces JSON Schema descriptions from Rust types, and the kube documentation requires serde_json, k8s_openapi, and schemars to be in scope under their own names, not renamed. tokio is the async runtime, needed only for the last test.

src/lib.rs:

use kube::CustomResource;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

/// Desired state of a Widget: what the user asks for.
#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, JsonSchema)]
#[kube(
    group = "demo.example.com",
    version = "v1",
    kind = "Widget",
    namespaced,
    status = "WidgetStatus",
    shortname = "wd"
)]
pub struct WidgetSpec {
    pub image: String,
    pub replicas: i32,
}

/// Observed state, written by a controller through the status subresource.
#[derive(Serialize, Deserialize, Clone, Debug, Default, JsonSchema)]
pub struct WidgetStatus {
    pub ready_replicas: i32,
}

tests/offline.rs:

use k8s_openapi::api::core::v1::Pod;
use kube::{api::ListParams, core::Resource, CustomResourceExt};
use kube_offline_lab::{Widget, WidgetSpec};

#[test]
fn t1_crd_from_struct() {
    let crd = Widget::crd();
    let j = serde_json::to_value(&crd).unwrap();
    println!("t1 name={} scope={} kind={} plural={} short={}",
        j["metadata"]["name"], j["spec"]["scope"], j["spec"]["names"]["kind"],
        j["spec"]["names"]["plural"], j["spec"]["names"]["shortNames"]);
    let v = &j["spec"]["versions"][0];
    println!("t1 version={} served={} storage={} status_sub={}",
        v["name"], v["served"], v["storage"], v["subresources"]["status"]);
    let spec = &v["schema"]["openAPIV3Schema"]["properties"]["spec"];
    println!("t1 spec.required={} replicas={}", spec["required"], spec["properties"]["replicas"]);
    assert_eq!(j["metadata"]["name"], "widgets.demo.example.com");
}

#[test]
fn t2_new_object_type_meta() {
    let w = Widget::new("w1", WidgetSpec { image: "nginx:1.29".into(), replicas: 2 });
    let j = serde_json::to_value(&w).unwrap();
    println!("t2 {}", serde_json::to_string(&j).unwrap());
    assert_eq!(j["apiVersion"], "demo.example.com/v1");
    assert!(w.status.is_none());
}

#[test]
fn t3_url_paths() {
    let p = <Pod as Resource>::url_path(&(), Some("default"));
    let w = <Widget as Resource>::url_path(&(), Some("team-a"));
    let all = <Pod as Resource>::url_path(&(), None);
    println!("t3 pod={p} widget={w} pods_all_ns={all}");
    println!("t3 Pod KIND={} API_VERSION={}", <Pod as Resource>::kind(&()), <Pod as Resource>::api_version(&()));
}

#[test]
fn t4_typed_pod_accepts_missing_containers() {
    let ok = r#"{"apiVersion":"v1","kind":"Pod","metadata":{"name":"p"},"spec":{"containers":[{"name":"c","image":"nginx"}]}}"#;
    let bad = r#"{"apiVersion":"v1","kind":"Pod","metadata":{"name":"p"},"spec":{}}"#;
    let pod: Pod = serde_json::from_str(ok).unwrap();
    println!("t4 ok image={:?}", pod.spec.unwrap().containers[0].image);
    // Surprise from the first run: this is NOT a deserialize error.
    let pod: Pod = serde_json::from_str(bad).unwrap();
    let n = pod.spec.unwrap().containers.len();
    println!("t4 bad parsed ok, containers.len()={n}");
    assert_eq!(n, 0);
}

#[test]
fn t5_widget_spec_type_error() {
    let bad = r#"{"apiVersion":"demo.example.com/v1","kind":"Widget","metadata":{"name":"w"},"spec":{"image":"nginx","replicas":"two"}}"#;
    let err = serde_json::from_str::<Widget>(bad).unwrap_err();
    println!("t5 err={err}");
}

#[test]
fn t6_list_params() {
    let lp = ListParams::default().labels("app=web").fields("status.phase=Running").limit(50);
    println!("t6 label_selector={:?} field_selector={:?} limit={:?}", lp.label_selector, lp.field_selector, lp.limit);
}

#[tokio::test]
async fn t7_client_without_cluster() {
    // No cluster on this machine: show the exact failure a controller hits at startup.
    unsafe {
        std::env::set_var("KUBECONFIG", "/nonexistent/kubeconfig");
        std::env::remove_var("KUBERNETES_SERVICE_HOST");
        std::env::remove_var("KUBERNETES_SERVICE_PORT");
    }
    let err = match kube::Client::try_default().await {
        Ok(_) => panic!("unexpected: a client was built"),
        Err(e) => e,
    };
    println!("t7 err={err}");
    println!("t7 dbg={err:?}");
}

The lab ran on macOS with rustc and cargo 1.99.0, edition 2024. The command was:

cargo test --test offline -- --nocapture --test-threads=1

The last line of the final run:

test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

The 0.01 s is test time only. The first cargo test compiled the whole dependency tree (tokio, rustls, ring, k8s-openapi and the kube crates) before any test ran, so budget for that once per fresh checkout.

--nocapture lets the println! output reach the terminal. --test-threads=1 is there because t7 changes environment variables, and in edition 2024 std::env::set_var is unsafe: the compiler cannot rule out another thread reading the environment at the same moment. Running the tests one at a time removes that race from this binary.

What the derive generates

WidgetSpec is an ordinary struct with two fields. The attribute #[derive(CustomResource)] plus the #[kube(...)] block turn it into a full Kubernetes kind. The attributes used here:

The macro generates a new struct named Widget, the root object, and it is the type you hand to the client. The kube documentation says Widget can be used as an Api<Widget> and that the spec struct cannot. The root struct has these fields: api_version, kind, metadata: ObjectMeta, spec: WidgetSpec, and status: Option<WidgetStatus>. It implements the Resource trait, which describes where an object lives on the apiserver. It gets a constructor Widget::new(name, spec), and through the CustomResourceExt trait it gets Widget::crd(), which returns a CustomResourceDefinition value. The documentation recommends reviewing the generated schema before sending it anywhere, and the next section does that.

Reading the generated CRD (t1)

t1 name="widgets.demo.example.com" scope="Namespaced" kind="Widget" plural="widgets" short=["wd"]
t1 version="v1" served=true storage=true status_sub={}
t1 spec.required=["image","replicas"] replicas={"format":"int32","type":"integer"}

The test serializes the CRD to JSON and prints selected parts. The first line shows the CRD's own name, widgets.demo.example.com, which is always <plural>.<group>. The scope is Namespaced, matching the namespaced attribute, and the short name wd arrived as a list.

The second line covers the version entry. served=true means the apiserver will answer requests for this version, and storage=true marks it as the stored version. A CRD with a single version has both flags set, as here. status_sub={} looks empty but is the correct sign: the status key exists under subresources, and an empty object is how a CRD turns the status subresource on.

The third line is the useful one for catching mistakes. Both image and replicas appear in required, because neither is an Option. The Rust type i32 became {"format":"int32","type":"integer"}. The Rust struct is therefore the single source of truth: the schema the apiserver would enforce and the type your code compiles against come from the same declaration. The documentation warns that changes to the struct, to validation rules, or to serialization attributes require re-applying the generated schema, and that kube does not implement conversion webhooks. A schema change is a deployment step, not only a code change.

Typed objects and serde as the first validator (t2, t4, t5)

serde is the Rust serialization library. It converts structs to JSON and back, and it is the first checkpoint your data passes through.

t2 {"apiVersion":"demo.example.com/v1","kind":"Widget","metadata":{"name":"w1"},"spec":{"image":"nginx:1.29","replicas":2}}

Widget::new("w1", ...) filled in apiVersion and kind without being told, and the metadata carries only the name. There is no status key in the JSON because the field is None. The test also asserts w.status.is_none(), so a freshly built object has no observed state, which is correct, since only a controller should write it.

The type system catches a wrong value on the way in:

t5 err=invalid type: string "two", expected i32 at line 1 column 116

The input JSON had "replicas":"two". serde refused to build a Widget from it and named both sides of the mismatch: it found a string and expected an i32. The position is where the parser was in the single-line input. This check runs in your process, with no network involved.

Typed structs have limits, though, and t4 shows one:

t4 ok image=Some("nginx")
t4 bad parsed ok, containers.len()=0

A Pod needs at least one container to be useful, so the test fed in a Pod with "spec":{} and expected a parse error. The first version of the test used unwrap_err() and panicked with `called Result::unwrap_err() on an Ok value. With k8s-openapi 0.28.0 the missing containers array deserializes as an empty Vec`. The lesson is that a typed struct checks shapes and primitive types, not every rule of the API. This lab did not send that Pod anywhere, so it makes no claim about what an apiserver would do with it. The only fact established is that the Rust type did not object.

REST paths from the Resource trait (t3)

t3 pod=/api/v1/namespaces/default/pods widget=/apis/demo.example.com/v1/namespaces/team-a/widgets pods_all_ns=/api/v1/pods
t3 Pod KIND=Pod API_VERSION=v1

The Resource trait carries the facts needed to compute a URL, and url_path returns the collection path for a type. Because these types are known at compile time, the first argument is &(). The second argument is an optional namespace.

Two path shapes appear. Built-in kinds in the core group live under /api/v1, as in /api/v1/namespaces/default/pods. Kinds in a named group live under /apis/<group>/<version>, as in /apis/demo.example.com/v1/namespaces/team-a/widgets. Passing None for the namespace drops the namespaces/<ns> segment and gives /api/v1/pods, which lists pods across all namespaces. Pod's KIND is Pod and its API_VERSION is v1, the same two facts that appear in every Pod's JSON. The generated Widget implements the same trait, so Api<Widget> computes its URLs by the same rule as Api<Pod>. Nothing here is special-cased for custom kinds.

ListParams (t6)

t6 label_selector=Some("app=web") field_selector=Some("status.phase=Running") limit=Some(50)

ListParams holds the filters for a list request. A label selector filters on labels, the key-value tags that users attach to objects. A field selector filters on selected built-in fields, such as status.phase. limit caps how many items come back per request. The test builds one with chained calls and prints the stored values. Each selector is stored as a plain string inside an Option, which means a typo in a selector is not caught by the compiler. It is a string until the apiserver parses it. This lab never sent a list request, so nothing here shows what the server would return.

The client boundary (t7)

Everything above ran in a single process. A Client is the first piece that needs the outside world. Client::try_default() calls Config::infer, which, per the documentation, tries the local kubeconfig first and then the in-cluster environment variables. The in-cluster path is for a program running inside the cluster, and it starts by reading environment variables; KUBERNETES_SERVICE_HOST and KUBERNETES_SERVICE_PORT are the two this test removes. The method fails if neither source loads.

The test points KUBECONFIG at a path that does not exist and removes the two in-cluster variables, which simulates a machine with no cluster:

t7 err=Failed to infer configuration: failed to infer config: in-cluster: (failed to read an incluster environment variable: environment variable not found), kubeconfig: (failed to read kubeconfig from '"/nonexistent/kubeconfig"': No such file or directory (os error 2))
t7 dbg=InferConfig(InferConfigError { in_cluster: ReadEnvironmentVariable(NotPresent), kubeconfig: ReadConfig(Os { code: 2, kind: NotFound, message: "No such file or directory" }, "/nonexistent/kubeconfig") })

The error reports both failures, one per source. The message happens to print the in-cluster half first, though the documentation says kubeconfig is attempted first, so do not read the order of the text as the order of attempts. The debug form shows the structure: variant InferConfig, holding an InferConfigError with an in_cluster field (ReadEnvironmentVariable(NotPresent)) and a kubeconfig field (ReadConfig with the OS error and the path). A controller that starts on a laptop with no kubeconfig, or in a container without a service account, fails at startup with this message.

Pitfalls as diagnostics

**called Result::unwrap_err() on an Ok value** in a test that feeds a typed struct incomplete data. The struct accepted the input. For Pod, a missing containers array becomes an empty Vec. The fix is to stop treating deserialization as complete validation. Assert on the values you care about (as t4 does with containers.len()), or add your own checks.

**invalid type: string "two", expected i32 at line 1 column 116** means the JSON field has the wrong type for the Rust field. The fix is on the data side: send a number, or change the Rust type if the schema should differ. Remember that changing it also changes the CRD, which must then be re-applied.

**Failed to infer configuration: failed to infer config: in-cluster: (...), kubeconfig: (...)** means Client::try_default found neither source. Read both parenthesized halves. environment variable not found means the in-cluster variables are absent. No such file or directory with a path means the kubeconfig location is wrong or empty. The fix is to set KUBECONFIG to a real file, put a kubeconfig at the default location, or run inside a cluster. If you already have a Config value, the documentation says to use Client::try_from instead.

**Compile error E0277 on unwrap_err for Client.** The first t7 called unwrap_err() on the Result<Client, _>, which requires the Ok type to implement Debug, and kube::Client does not. The fix, used in the final test, is to match on the result and extract the error yourself.

**set_var needs unsafe and breaks parallel tests.** In edition 2024, std::env::set_var and remove_var are unsafe. Wrap them in an unsafe block, and run the test binary with --test-threads=1 so no other test reads the environment concurrently.

Enums in a spec. The documentation says the generated schema must be structural, which in practice means only externally tagged enums, and unit variants must not be mixed with struct or tuple variants. Otherwise, in the documentation's words, crd() may panic or the API may reject the CRD. This lab used no enums, so this item comes from the documentation and was not run.

What was not run

This lab had no cluster. There was no kubectl and no kind. No object or CRD was applied to an apiserver, no request was sent, no watch was opened, and no controller or reconcile loop ran. Every output above came from local code: JSON generation, serde, string formatting, and one failed attempt to find a configuration.

The next step needs a cluster. The kube documentation shows this usage for registering a generated CRD, quoted here for its own example type Foo. It was not run in this lab:

let crds: Api<CustomResourceDefinition> = Api::all(client.clone());
crds.patch("foos.clux.dev", &PatchParams::apply("myapp"), &Patch::Apply(Foo::crd())).await;

PatchParams::apply("myapp") names the caller, and Patch::Apply(Foo::crd()) carries the generated CRD as the body. The documentation says that once the CRD has been accepted, which it puts at a few seconds at most, you can use the new kind as a normal kube Api object. For this lab's types the analogue would patch Widget::crd() under the name widgets.demo.example.com, and then construct Api<Widget>. The url_path result in t3 suggests the request URLs, but only a real apiserver can confirm that they work.

A reconcile loop would sit on top of that, with a controller reading Widget objects and writing WidgetStatus through the status subresource. That is also unrun here.

Takeaway

A Rust struct with a derive attribute gives you three things you can check without a cluster: a CRD whose required fields and integer formats match the struct, a typed root object that serializes with the right apiVersion and kind, and REST paths computed from the same trait for built-in and custom kinds. serde rejects wrong value types locally, but it accepts shapes that the real API may care about, so read the generated CRD, assert on what matters, and treat the first contact with an apiserver as the point where the remaining rules are tested. When Client::try_default fails, its error names both sources it tried, which tells you whether to fix KUBECONFIG or the in-cluster environment.

Sources

Related