Skip to main content

Server-Side Apply

Server-Side Apply (SSA) is Kubernetes' field-ownership-based patching mechanism. Using SSA when creating/modifying resources in a reconciler enables safe, conflict-free multi-actor modifications.

Why SSA

Limitations of traditional patching approaches:

ApproachLimitation
Merge patchOverwrites entire arrays. Field deletion is not explicit.
Strategic merge patchOnly works with k8s-openapi types. Incomplete for CRDs.
JSON patchRequires exact path specification. Vulnerable to race conditions.

Advantages of SSA:

  • Field ownership: The server records "this controller owns this field"
  • Conflict detection: Touching another owner's field results in a 409 Conflict
  • Declarative: You only declare "these fields should have these values" and leave everything else untouched
  • Naturally fits the idempotent pattern of reconcilers

Basic Pattern

use kube::api::{Patch, PatchParams};

let patch = Patch::Apply(serde_json::json!({
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": { "name": "my-cm" },
"data": { "key": "value" }
}));
let pp = PatchParams::apply("my-controller"); // field manager name
api.patch("my-cm", &pp, &patch).await?;

The "my-controller" in PatchParams::apply("my-controller") is the field manager name. Field ownership is recorded under this name. Applying again with the same field manager updates the owned fields, while leaving other field managers' fields untouched.

Common Mistakes

Missing apiVersion and kind

// ✗ 400 Bad Request
let patch = Patch::Apply(serde_json::json!({
"data": { "key": "value" }
}));

// ✓ apiVersion and kind are required
let patch = Patch::Apply(serde_json::json!({
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": { "name": "my-cm" },
"data": { "key": "value" }
}));

Unlike merge patch, SSA requires apiVersion and kind.

Missing field manager

// ✗ field_manager is None → API server rejects the request
let pp = PatchParams::default();

// ✓ Explicit field manager
let pp = PatchParams::apply("my-controller");

A field manager is required for SSA. When field_manager is None (the default), the API server returns an error. Always use PatchParams::apply("my-controller") for SSA operations.

Overusing force

// Caution: forcefully takes over fields owned by other field managers
let pp = PatchParams::apply("my-controller").force();

force: true forcefully takes ownership of fields from other controllers. Use it only in single-owner scenarios such as CRD registration.

Including unnecessary fields

When serializing an entire Rust struct, fields with Default values are also included. SSA takes ownership of those fields, causing conflicts when another controller tries to modify them.

Status Patching

Status is modified through the /status subresource.

let status_patch = serde_json::json!({
"apiVersion": "example.com/v1",
"kind": "MyResource",
"status": {
"phase": "Ready",
"conditions": [{
"type": "Available",
"status": "True",
"lastTransitionTime": "2024-01-01T00:00:00Z",
}]
}
});
let pp = PatchParams::apply("my-controller");
api.patch_status("name", &pp, &Patch::Apply(status_patch)).await?;
Must be wrapped in the full object structure
// ✗ Don't send just the status
serde_json::json!({ "phase": "Ready" })

// ✓ Must be wrapped with apiVersion, kind, and status structure
serde_json::json!({
"apiVersion": "example.com/v1",
"kind": "MyResource",
"status": { "phase": "Ready" }
})

This is because the Kubernetes API expects the full object structure even at the /status endpoint.

Typed SSA Pattern

Using Rust types instead of serde_json::json!() gives you type safety and IDE autocompletion.

let cm = ConfigMap {
metadata: ObjectMeta {
name: Some("my-cm".into()),
..Default::default()
},
data: Some(BTreeMap::from([("key".into(), "value".into())])),
..Default::default()
};
let pp = PatchParams::apply("my-controller");
api.patch("my-cm", &pp, &Patch::Apply(cm)).await?;

k8s-openapi types use custom serialization that omits None fields (Option fields are only serialized when they contain a value), so None fields are excluded from the patch. For your own types, you need to add skip_serializing_if explicitly.

Current limitation: no ApplyConfigurations in Rust

Go's client-go provides ApplyConfigurations — fully optional builder types designed specifically for SSA where every field is Option, so you only include the fields you want to own. Rust does not have an equivalent yet (kube#649). Because k8s-openapi mirrors the upstream Go structs, some fields are not Option (e.g. max_replicas: i32 in HorizontalPodAutoscalerSpec), which means serializing a default struct will include those fields and SSA will take ownership of them. Using serde_json::json!() for partial patches is the recommended workaround.

#[derive(Serialize)]
struct MyStatus {
phase: String,
#[serde(skip_serializing_if = "Option::is_none")]
message: Option<String>,
}