Operators
Controllers with reconcile, requeue, finalizers and status; admission and conversion webhooks; CRDs and RBAC from Rust types; tests against a real API server.
An operator watches custom resources and makes the cluster match them. To start from a working one — a custom resource, a controller, webhooks, --manifests, tests — generate the starter:
cargo generate --git https://github.com/bohdaq/rust-web-server starters/operatorThe k8s feature (18.35.0) has the parts on top of kube-rs's controller runtime: controllers with finalizers and status, an operator that runs them under leader election with metrics and a readiness check, admission and conversion webhooks with their certificate, CRD and RBAC manifests generated from the code, and tests against a real API server.
A custom resource
A resource's types are Rust structs; #[derive(CustomResource)] (kube) makes the kind and its CRD:
use rust_web_server::k8s::{kube::CustomResource, schemars::JsonSchema};
use serde::{Deserialize, Serialize};
#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)]
#[kube(group = "example.com", version = "v1", kind = "Backup", namespaced, status = "BackupStatus")]
pub struct BackupSpec {
pub schedule: String,
pub database: String,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
pub struct BackupStatus {
pub last_run: Option<String>,
}Controllers
use std::sync::Arc;
use std::time::Duration;
use rust_web_server::k8s::controller::{Action, Context, Controller, Operator};
use rust_web_server::k8s::kube::api::Api;
use rust_web_server::k8s::Error;
use k8s_openapi::api::batch::v1::CronJob;
async fn reconcile(backup: Arc<Backup>, ctx: Arc<Context>) -> Result<Action, Error> {
ctx.apply_owned(&*backup, cron_job_for(&backup)).await?; // owned: deleted with it
ctx.patch_status(&*backup, serde_json::json!({ "last_run": null })).await?;
ctx.event(&*backup, false, "Scheduled", "the CronJob is up to date").await?;
Ok(Action::requeue(Duration::from_secs(300)))
}
let kube = rust_web_server::k8s::client::client().await?;
Operator::new(kube.clone(), "backup-operator")
.leader_election(&rust_web_server::k8s::client::current_namespace(), "backup-operator")
.controller(
Controller::new(Api::<Backup>::all(kube.clone()))
.owns(Api::<CronJob>::all(kube.clone())) // a change to its CronJob reconciles the Backup
.reconcile(reconcile)
.finalizer("example.com/cleanup", |backup, ctx| async move { delete_backups_of(&backup).await }),
)
.run()
.await;Controller |
What |
|---|---|
new(api) |
Watch api's objects (Api::all or Api::namespaced) |
watching(config) |
Only those a label or field selector picks |
owns(api) |
Also reconcile when an object it owns (owner reference) changes |
reconcile(f) |
async fn(Arc<K>, Arc<Context>) -> Result<Action, Error>: make the world match, answer when to look again (Action::requeue(d), Action::await_change()) |
finalizer(name, cleanup) |
Add the finalizer to each object; on deletion run cleanup, then remove it |
on_error(delay) |
First retry delay after a failed reconcile (5 s), doubling per failure of the same object up to 5 min |
rule(rule) |
An extra RBAC rule the reconcile needs |
Context has the client and helpers: patch_status(obj, json) (merge into the status subresource), apply_owned(owner, child) (server-side apply with an owner reference, field manager = the operator's name) and event(obj, warning, reason, note) (a Kubernetes Event, shown by kubectl describe).
The operator
Operator::run runs every controller and registers a readiness check (/readyz, see health checks). Serve it beside the probes:
let operator = tokio::spawn(operator.run());
let (listener, pool) = Server::setup()?;
Server::run_tls(listener, pool, App::new()).await; // /livez, /readyz, /metrics/metrics gets rws_controller_reconciles_total and rws_controller_reconcile_errors_total per controller.
Leader election
With .leader_election(namespace, lease) the controllers run only on the replica holding the Lease; the others wait. A replica that loses the Lease exits, and Kubernetes restarts it as a follower — kube's controllers can't be stopped and restarted in place, and an exit is the safe way to never reconcile on two replicas.
Webhooks
webhook::Webhooks is an rws application answering the API server's AdmissionReview and ConversionReview requests:
use rust_web_server::k8s::webhook::{Webhooks, WebhookCertificate};
let webhooks = Webhooks::new()
.validate::<Backup, _>("/validate-backups", |request| match &request.object {
Some(b) if b.spec.schedule.is_empty() => Err("spec.schedule is required".into()),
_ => Ok(()),
})
.mutate::<Backup, _>("/mutate-backups", |mut backup| {
backup.labels_mut().insert("example.com/managed".into(), "true".into());
Ok(backup)
})
.convert("/convert-backups", |object, desired_version| v1_to_v2(object, desired_version))
.wrap(App::new()); // probes and metrics on the same server
// The API server only calls webhooks over TLS it trusts.
let cert = WebhookCertificate::shared(&kube, &namespace, "backup-operator-webhook-tls", "backup-operator").await?;
cert.install("/tmp/tls")?; // RWS_CONFIG_TLS_CERT_FILE / KEY_FILE
cert.patch_ca_bundle(&kube, Some("backup-operator"), None, None).await?;
Server::run_tls(listener, pool, webhooks).await;| Builder | The handler gets | Answers |
|---|---|---|
validate::<K, _>(path, f) |
The request: operation, object, old_object, user_info, name, namespace |
Ok(()) allows, Err(message) denies (kubectl shows the message) |
mutate::<K, _>(path, f) |
The object (on create and update) | The object changed; the JSON patch from old to new is computed for you |
convert(path, f) |
Each object and the desired apiVersion |
The object in that version (apiVersion set for you) |
With more than one replica, use WebhookCertificate::shared(&client, namespace, secret, service): the first replica makes the certificate and stores it in a kubernetes.io/tls Secret, the others read it (a create race is settled by the API server) — replicas each making their own would overwrite each other's caBundle, and the API server would reject every replica but one. The pod needs get and create on secrets; to_secret/from_secret convert (cert-manager's Secrets have the same keys).
WebhookCertificate::generate(service, namespace) makes a CA and a serving certificate for <service>.<namespace>.svc (generate_for(name, dns_names) for others); install(dir) writes tls.crt, tls.key, ca.crt and points rws's TLS settings at them; patch_ca_bundle puts the CA into the webhook configurations (and a CRD's conversion webhook) at startup, so no cert-manager is needed. With cert-manager, mount its Secret and set the TLS settings instead.
Manifests
The CRDs, RBAC and webhook configurations come from the code, so they never drift from it:
use rust_web_server::k8s::manifests::{self, Rbac, WebhookRule, WebhookTarget};
manifests::print_on_request(|| { // `backup-operator --manifests | kubectl apply -f -`
let rbac = Rbac::new("backup-operator", "backups").rules(operator.rules());
let target = WebhookTarget { service: "backup-operator".into(), namespace: "backups".into(), port: 443, path: "/validate-backups".into(), ca_pem: ca.clone() };
let mut docs = vec![manifests::crd::<Backup>()];
docs.extend(rbac.documents());
docs.push(manifests::webhook_configuration("backups", false, &target, &[WebhookRule::new("example.com", "v1", "backups", &["CREATE", "UPDATE"])], true));
docs
});| Function | Writes |
|---|---|
crd::<K>() |
The CustomResourceDefinition from the type's schema |
with_conversion_webhook(crd, target) |
The CRD with conversion.strategy: Webhook |
Rbac::new(name, ns).rules(…).documents() |
ServiceAccount, ClusterRole (rules merged per resource), ClusterRoleBinding; .leader_election() adds the Lease rule |
Operator::rules() |
What its controllers need: their kinds and status, what they own, events, the Lease |
webhook_configuration(name, mutating, target, rules, fail_closed) |
A Validating/MutatingWebhookConfiguration |
to_yaml(&docs) |
A YAML stream: each document as JSON (which YAML accepts) after a --- line |
Testing against a real API server
A fake API server doesn't run admission, garbage collection, finalizers or status subresources — the things operators get wrong. testing::TestCluster uses a real one (kind, k3d, or envtest's kube-apiserver) from RWS_TEST_KUBECONFIG, with a namespace of its own per test:
use rust_web_server::k8s::testing::{eventually, TestCluster};
#[tokio::test]
async fn backups_get_a_cron_job() {
let Some(cluster) = TestCluster::from_env().await else { return }; // skipped without a cluster
cluster.install_crd::<Backup>().await.unwrap(); // applied, then waited for
let ns = cluster.namespace("backup").await.unwrap(); // backup-<random>, labelled rws-test=true
tokio::spawn(operator(cluster.client.clone()).run());
ns.api::<Backup>().create(&Default::default(), &Backup::new("nightly", spec())).await.unwrap();
let job = eventually(Duration::from_secs(30), || async { ns.api::<CronJob>().get_opt("nightly").await.ok().flatten() }).await;
assert!(job.is_some());
ns.delete().await;
}
kind create cluster --name test --kubeconfig /tmp/kubeconfig
RWS_TEST_KUBECONFIG=/tmp/kubeconfig cargo testWith RWS_TEST_REQUIRE_KUBERNETES set, a missing cluster fails the test instead of skipping it — for CI. rws's own CI runs its Kubernetes tests this way on kind, and deploys the operator starter into the cluster (ci/e2e/operator.sh): the API server calls its webhooks over TLS, its controller reconciles, one of two replicas leads. Never point it at a cluster that matters: the tests create and delete namespaces and cluster-wide CRDs.