k8s-objectmatcher is a small, focused Go library that answers a single
question for Kubernetes operator authors: "is the live object in the
cluster meaningfully different from the object I want to apply?" It does
this by computing a strategic-merge or JSON-merge patch between a desired
object and the object the API server currently holds, then stripping the
fields the server itself fills in (status, defaulted values, clusterIP,
auto-injected metadata). What's left is a real, reconcilable diff — or an
empty patch, which means "no update needed."
The library originated at Banzai Cloud and lives under cisco-open today.
It is the diff engine inside several production operators and exposes a
deliberately tiny surface: one PatchMaker.Calculate call, a handful of
CalculateOption filters, and an annotation-based three-way merge so your
controller can compare current state, desired state, and the last-applied
state stored on the object.
This guide covers installation, the public API, the seven options most controllers use, and recipes for the patterns that come up daily in controller-runtime reconcile loops.
1. Quick Start
k8s-objectmatcher is a Go library. There is no binary, no daemon, no CLI.
You add the import, instantiate a PatchMaker, and call Calculate(current, desired, opts...) from inside your controller's Reconcile method. If
PatchResult.IsEmpty() returns true, skip the update; otherwise, apply
the modified object and persist the new last-applied annotation.
Prerequisites: Go 1.22 or newer, a project that already imports
k8s.io/apimachinery (every controller-runtime project does).
Install:
go get github.com/cisco-open/k8s-objectmatcher/patch
Minimal example — the canonical 10-line wiring:
package main
import (
"context"
"fmt"
"github.com/cisco-open/k8s-objectmatcher/patch"
appsv1 "k8s.io/api/apps/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
)
func reconcileDeployment(ctx context.Context, c client.Client, desired *appsv1.Deployment) error {
current := &appsv1.Deployment{}
if err := c.Get(ctx, client.ObjectKeyFromObject(desired), current); err != nil {
return err
}
result, err := patch.DefaultPatchMaker.Calculate(current, desired, patch.IgnoreStatusFields())
if err != nil {
return err
}
if result.IsEmpty() {
return nil // already in sync — skip the update
}
fmt.Printf("patch needed: %s\n", string(result.Patch))
return c.Update(ctx, desired)
}
Expected behaviour:
- First reconcile of a new object:
IsEmpty()returnsfalse, you create it and callpatch.DefaultAnnotator.SetLastAppliedAnnotation(desired)before the create so future diffs are three-way. - Steady state:
IsEmpty()returnstrue. No spurious updates, no fights with controllers that ownstatus.
Tip
Skip per-controller wiring for the common case —
patch.DefaultPatchMakerandpatch.DefaultAnnotatorare package-level globals that work out of the box. Build your ownNewPatchMaker(...)only when you need a custom annotation key or you're injecting fakes for tests.
What to try next: Section 4 walks through the public types and every
option. Section 5 has recipes for Service clusterIP, StatefulSet
volumeClaimTemplates, and a wired-in controller-runtime reconcile.
2. Installation
The library is distributed as a Go module. There is nothing to compile ahead of time and no separate runtime to deploy.
2.1 Requirements
| Requirement | Value | Source |
|---|---|---|
| Go toolchain | 1.22 or newer | go.mod line go 1.22 |
k8s.io/apimachinery |
v0.19.16 or compatible (the indirect kept by go mod tidy) |
go.mod |
emperror.dev/errors |
v0.8.1 | go.mod |
github.com/evanphx/json-patch |
v5.9.0+incompatible | go.mod |
github.com/json-iterator/go |
v1.1.12 | go.mod |
The library has zero CGO, no platform-specific code, and runs anywhere Go runs (Linux, macOS, Windows, ARM64, AMD64).
2.2 Add to a Go module
go get github.com/cisco-open/k8s-objectmatcher/patch
go mod tidy
go: added github.com/cisco-open/k8s-objectmatcher v0.x.y
go: added emperror.dev/errors v0.8.1
go: added github.com/evanphx/json-patch v5.9.0+incompatible
go: added github.com/json-iterator/go v1.1.12
Important
The importable subpackage is
patch, not the module root. Always importgithub.com/cisco-open/k8s-objectmatcher/patch— the bare module path exposes nothing useful.
2.3 Vendoring (optional)
If your project vendors dependencies (controller-runtime projects often
do), run a vendor refresh after go get:
go mod vendor
(no output on success)
2.4 Verify the install
A one-line smoke test is enough:
package main
import (
"fmt"
"github.com/cisco-open/k8s-objectmatcher/patch"
)
func main() {
a := patch.NewAnnotator("example.com/last-applied")
fmt.Println(a != nil) // true
}
go run ./...
true
Note
The default annotation key is
banzaicloud.com/last-applied(exposed aspatch.LastAppliedConfig). It is unchanged from the Banzai Cloud days for backward compatibility — existing operators that wrote that key still read correctly when their image is rebuilt againstcisco-open.
2.5 Local development (for contributors)
The repo ships a Makefile, golangci-lint config (.golangci.yml), and a
license-checker config (.licensei.toml). To run the test suite locally:
git clone https://github.com/cisco-open/k8s-objectmatcher
cd k8s-objectmatcher
go test ./patch/...
ok github.com/cisco-open/k8s-objectmatcher/patch 0.123s
The tests/ subdirectory is a separate module (own go.mod) that runs
integration tests against k8s.io/client-go v0.21.3. Those tests need a
working KUBECONFIG and a real or simulated API server — go test ./patch/... alone covers the unit tests.
3. Configuration
There is no config file, no environment variables, and no global state to
tune. "Configuration" of k8s-objectmatcher is a matter of:
- Picking the right annotation key.
- Choosing which
CalculateOptions to pass toCalculate. - Deciding whether to use
DefaultPatchMaker/DefaultAnnotatoror build your own withNewPatchMaker(...).
3.1 The annotation key
The Annotator stamps a JSON snapshot of the desired object onto the
live object as an annotation, then reads it back later for the three-way
merge. The key defaults to banzaicloud.com/last-applied:
const LastAppliedConfig = "banzaicloud.com/last-applied"
var DefaultAnnotator = NewAnnotator(LastAppliedConfig)
Choose your own key with NewAnnotator:
ann := patch.NewAnnotator("mycompany.io/last-applied")
maker := patch.NewPatchMaker(ann, &patch.K8sStrategicMergePatcher{}, &patch.BaseJSONMergePatcher{})
Tip
Pick a stable, unique key once per operator and never change it. Switching keys across releases makes existing live objects look like they have no last-applied snapshot, which forces a fresh three-way baseline (and may trigger one large reconcile).
3.2 Annotation storage format
The annotation value is gzipped + base64-encoded, with a fallback read path for plain (non-zipped) values from older operator versions. This matters for two reasons:
- Annotations have a 256 KiB limit per object in Kubernetes. The zip step roughly halves the JSON before encoding, so most CRDs fit comfortably; very large objects (StatefulSets with hundreds of mounts or ConfigMaps with embedded JSON) can still bump the ceiling.
- You will not see human-readable JSON if you
kubectl get -o yamlthe object — that's by design.
Note
The library transparently decodes both formats: pre-zip annotations (raw JSON) and modern zip+base64 annotations both round-trip through
GetOriginalConfiguration. Upgrading does not require a flag day.
3.3 The CalculateOption set
Every option is a func([]byte, []byte) ([]byte, []byte, error) — it
takes the current and modified JSON byte streams, mutates both
symmetrically, and returns them. The shipped options are:
| Option | Purpose |
|---|---|
IgnoreStatusFields() |
Strips the top-level status from both sides before diffing. |
IgnoreField(field) |
Strips an arbitrary top-level field from both sides. |
IgnoreVolumeClaimTemplateTypeMetaAndStatus() |
For StatefulSets — zeroes kind, apiVersion, and status inside every entry of spec.volumeClaimTemplates. |
You can supply zero, one, or many options to a single Calculate call —
they're applied left-to-right.
Important
Apply
IgnoreStatusFields()to anything with astatussubresource. Otherwise the API server'sstatusupdates (which your controller probably doesn't own) will show up as a non-empty patch on every reconcile and you'll spin in a tight update loop.
3.4 Using a custom PatchMaker
DefaultPatchMaker is built once at package load with the default
annotator and the standard Kubernetes strategic-merge / JSON-merge
implementations. To swap one out (e.g. inject a fake patcher in tests
or attach PreconditionFuncs to the strategic-merge path):
maker := patch.NewPatchMaker(
patch.NewAnnotator("mycompany.io/last-applied"),
&patch.K8sStrategicMergePatcher{
PreconditionFuncs: []mergepatch.PreconditionFunc{
mergepatch.RequireKeyUnchanged("apiVersion"),
},
},
&patch.BaseJSONMergePatcher{},
)
See the Admin Guide for a full breakdown of the strategic-merge / JSON- merge dispatch table and how to plug in a custom implementation.
4. Core Usage
The two-line mental model:
- Calculate a
PatchResultfrom(currentObject, desiredObject, opts...). - Branch on
PatchResult.IsEmpty().
Everything else — annotation management, per-kind cleaners, the
strategic-merge versus JSON-merge dispatch — happens inside Calculate.
4.1 The PatchResult shape
Calculate returns a *PatchResult:
type PatchResult struct {
Patch []byte // the diff, "{}" if no real change
Current []byte // current object as JSON, post-cleanup
Modified []byte // desired object as JSON, post-cleanup
Original []byte // last-applied snapshot from the annotation, may be nil
}
func (p *PatchResult) IsEmpty() bool // true when string(Patch) == "{}"
func (p *PatchResult) String() string // formatted dump for debugging
Treat IsEmpty() == true as the only signal that no update is needed.
Inspect Patch only when you need to log what changed; never make
control-flow decisions on the byte length, which can be 2 ({}) or
larger even when there's no semantic change.
4.2 Calling Calculate
result, err := patch.DefaultPatchMaker.Calculate(currentObj, desiredObj,
patch.IgnoreStatusFields(),
patch.IgnoreField("metadata"),
)
if err != nil {
return err
}
if !result.IsEmpty() {
// real change — update or recreate
}
Internally, Calculate does the following in order:
- JSON-marshals both objects.
- Applies every option, left-to-right.
- Calls
DeleteNullInJsonon both to drop nil fields and zero-valuedIntOrStringtypes (a long-standing source of false diffs). - Reads the last-applied annotation from
currentObject. - Branches on the object's runtime type:
*unstructured.Unstructured→ JSON-merge path (CreateThreeWayJSONMergePatch).- Everything else → strategic-merge path
(
CreateThreeWayMergePatch, then re-applies and re-diffs to collapse$setElementOrderdirectives).
- Returns
(Patch, Current, Modified, Original)for inspection.
Note
The strategic-merge path runs the patch twice — first to compute it, then to re-apply it locally and compute a two-way merge against the current state. This second pass eliminates spurious differences caused by
$setElementOrdermarkers that strategic merge generates for arrays even when the contents are identical.
4.3 Annotation lifecycle
Three calls cover the full lifecycle for a single object:
| When | Call | Purpose |
|---|---|---|
| Just before creating an object | DefaultAnnotator.SetLastAppliedAnnotation(desired) |
Stamp the desired state on the new object so the next reconcile has an Original to diff against. |
| Just before updating an object | DefaultAnnotator.SetLastAppliedAnnotation(desired) |
Refresh the snapshot so subsequent reconciles compare against the just-applied state, not the stale baseline. |
Inside Calculate |
(automatic) GetOriginalConfiguration |
Pulls the snapshot off currentObject for the three-way merge. |
You almost never call GetOriginalConfiguration or
GetModifiedConfiguration directly — Calculate does that. The two
calls you wire by hand are SetLastAppliedAnnotation (before create
and before update).
4.4 The full public API
| Symbol | Kind | Notes |
|---|---|---|
LastAppliedConfig |
const | "banzaicloud.com/last-applied" — the default annotation key. |
DefaultAnnotator |
var | NewAnnotator(LastAppliedConfig) — use directly for the common case. |
DefaultPatchMaker |
var | NewPatchMaker(DefaultAnnotator, &K8sStrategicMergePatcher{}, &BaseJSONMergePatcher{}). |
Annotator |
type | Holds a meta.MetadataAccessor and the annotation key. |
NewAnnotator(key string) *Annotator |
func | Build an annotator with a custom key. |
(*Annotator).GetOriginalConfiguration(obj) ([]byte, error) |
method | Read + base64-decode + unzip the annotation. Handles legacy plain values. |
(*Annotator).SetOriginalConfiguration(obj, original []byte) error |
method | Zip + base64 encode and write the annotation. |
(*Annotator).GetModifiedConfiguration(obj, annotate bool) ([]byte, error) |
method | Marshal the object without the annotation. With annotate=true, embeds the snapshot inside the same payload (rare). |
(*Annotator).SetLastAppliedAnnotation(obj) error |
method | Convenience: get modified, strip nulls, then set. The call you make from your controller. |
Maker |
interface | Single method Calculate(current, modified, opts...) (*PatchResult, error). |
PatchMaker |
type | Concrete Maker. Holds an *Annotator, a StrategicMergePatcher, and a JSONMergePatcher. |
NewPatchMaker(annotator, smp, jmp) Maker |
func | Build a custom maker. Returns the interface, not the struct. |
(*PatchMaker).Calculate(current, modified, opts...) (*PatchResult, error) |
method | The diff engine. |
PatchResult |
type | Fields: Patch, Current, Modified, Original. |
(*PatchResult).IsEmpty() bool |
method | string(Patch) == "{}". |
(*PatchResult).String() string |
method | Human-readable dump for logs. |
CalculateOption |
type | func([]byte, []byte) ([]byte, []byte, error). |
IgnoreStatusFields() CalculateOption |
func | Strip status from both sides. |
IgnoreField(field string) CalculateOption |
func | Strip an arbitrary top-level field. |
IgnoreVolumeClaimTemplateTypeMetaAndStatus() CalculateOption |
func | Zero kind / apiVersion / status inside every spec.volumeClaimTemplates entry (StatefulSets). |
DeleteNullInJson(jsonBytes []byte) ([]byte, map[string]interface{}, error) |
func | Walk a JSON object and drop nils / zero-valued IntOrStrings. Used internally; also useful in tests. |
StrategicMergePatcher |
interface | StrategicMergePatch / CreateTwoWayMergePatch / CreateThreeWayMergePatch. |
JSONMergePatcher |
interface | MergePatch / CreateMergePatch / CreateThreeWayJSONMergePatch. |
K8sStrategicMergePatcher |
type | Default StrategicMergePatcher. Has a PreconditionFuncs []mergepatch.PreconditionFunc field for invariant guards. |
BaseJSONMergePatcher |
type | Default JSONMergePatcher. No fields. |
Tip
Need to mock the diff engine in unit tests? Implement the
Makerinterface (Calculate(current, modified, opts...) (*PatchResult, error)) in your test code and inject your fake. The library never hardcodes*PatchMakeranywhere it consumesMaker.
5. Common Tasks
The seven recipes below are the patterns that come up in nearly every controller. Each one stays inside the public API surface — no internal types, no reflection.
5.1 Diff a Deployment without spinning on status updates
The single most common pattern: reconcile a Deployment and stop
fighting the deployment controller's status.observedGeneration /
status.replicas writes.
import (
"github.com/cisco-open/k8s-objectmatcher/patch"
appsv1 "k8s.io/api/apps/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
)
func reconcileDeployment(ctx context.Context, c client.Client, desired *appsv1.Deployment) error {
current := &appsv1.Deployment{}
err := c.Get(ctx, client.ObjectKeyFromObject(desired), current)
if errors.IsNotFound(err) {
if err := patch.DefaultAnnotator.SetLastAppliedAnnotation(desired); err != nil {
return err
}
return c.Create(ctx, desired)
}
if err != nil {
return err
}
result, err := patch.DefaultPatchMaker.Calculate(current, desired, patch.IgnoreStatusFields())
if err != nil {
return err
}
if result.IsEmpty() {
return nil
}
if err := patch.DefaultAnnotator.SetLastAppliedAnnotation(desired); err != nil {
return err
}
return c.Update(ctx, desired)
}
Note
SetLastAppliedAnnotationruns once on create and again on every non-empty update. Skipping it leaves the live object's last-applied annotation stale, which downgrades subsequent diffs from three-way to two-way and re-introduces the spurious-update problem.
5.2 Diff a Service correctly (clusterIP, nodePort, targetPort)
Services pick up server-side defaults — the API server picks a
clusterIP from the service CIDR, picks nodePorts on type
NodePort/LoadBalancer, and resolves targetPort to a concrete
value. None of those belong in your desired object, but they round-trip
into your current object.
result, err := patch.DefaultPatchMaker.Calculate(currentSvc, desiredSvc, patch.IgnoreStatusFields())
IgnoreStatusFields() covers status.loadBalancer.ingress and friends.
The server-set spec.clusterIP, spec.clusterIPs, and spec.ports[*].nodePort
fields are already preserved by the strategic-merge logic the
PatchMaker runs — leaving them off the desired object is enough.
Tip
If you must hardcode a
clusterIPon a desired Service (for example, to pin a headless DNS service across rebuilds), set it explicitly on the desired object. The patch will not flip it back to empty because the desired side now matches the current side.
5.3 StatefulSet volumeClaimTemplates — the special case
spec.volumeClaimTemplates is one of the few places where the API
server permanently rewrites your desired object — it stamps kind,
apiVersion, and an initial status.phase=Pending on every PVC
template. Without a filter, every reconcile sees a non-empty patch
forever.
result, err := patch.DefaultPatchMaker.Calculate(currentSS, desiredSS,
patch.IgnoreStatusFields(),
patch.IgnoreVolumeClaimTemplateTypeMetaAndStatus(),
)
IgnoreVolumeClaimTemplateTypeMetaAndStatus walks
spec.volumeClaimTemplates[*] on both sides and sets kind="",
apiVersion="", and status={"phase":"Pending"}, normalising the two
representations rather than deleting them.
Warning
IgnoreVolumeClaimTemplateTypeMetaAndStatusdoes not handle the nestedselector.matchLabels/resources.requests.storagefields being mutated post-creation — Kubernetes treats those as immutable and will reject the update. If your reconciler is the one trying to change PVC capacity, you need a separate recreate-or-resize path; see issue #51 for an open report on edge cases.
5.4 Use IgnoreField to opt-out a top-level key
IgnoreField(field) deletes a top-level key from both byte streams
before diffing. Useful for fields a third-party controller writes that
you want to leave alone:
result, err := patch.DefaultPatchMaker.Calculate(curObj, desObj,
patch.IgnoreStatusFields(),
patch.IgnoreField("metadata"), // ignore everything in metadata (rare; usually too broad)
)
The argument is an exact, top-level JSON key. To ignore a nested field
(say, spec.replicas), IgnoreField is not sufficient — that
capability is open as
#57. Use
the workaround in 5.7 below.
5.5 Wire it into a controller-runtime Reconcile
Every healthy controller-runtime reconciler that uses this library has the same shape: get → calculate → branch → update + reannotate.
func (r *AppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
desired := r.buildDesired(req) // your construction logic
current := &appsv1.Deployment{}
err := r.Get(ctx, req.NamespacedName, current)
switch {
case errors.IsNotFound(err):
if err := patch.DefaultAnnotator.SetLastAppliedAnnotation(desired); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, r.Create(ctx, desired)
case err != nil:
return ctrl.Result{}, err
}
result, err := patch.DefaultPatchMaker.Calculate(current, desired,
patch.IgnoreStatusFields(),
)
if err != nil {
return ctrl.Result{}, err
}
if result.IsEmpty() {
return ctrl.Result{}, nil
}
r.Log.V(1).Info("applying patch", "patch", string(result.Patch))
if err := patch.DefaultAnnotator.SetLastAppliedAnnotation(desired); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, r.Update(ctx, desired)
}
5.6 Debug spurious updates
If IsEmpty() keeps returning false and you can't see why:
- Log
result.String()— it dumpsPatch,Current,Modified, andOriginalin one block. - Look at
Patch. The keys present are exactly the keys disagreeing. - Compare against
Original. IfOriginalis empty, the live object has no last-applied snapshot — callSetLastAppliedAnnotationon create / on first reconcile. - If
Patchcontains$setElementOrder/...even for unchanged slices, you've found the rare residual the second pass didn't collapse. File an issue with the reproducer; the strategic-merge path already handles the common cases. - If
Patchreferences a field your controller never sets but the API server fills in (spec.clusterIP,spec.podCIDR, defaulted enum values), useIgnoreField(...).
Tip
The fastest reproducer is
result.String()printed from inside a failing reconcile. Paste that into the issue. A plainreflect.DeepEqual(current, desired)is not informative — you need the strategic-merge perspective the library uses.
5.7 Handle CRDs / unstructured objects
When currentObject is a *unstructured.Unstructured, Calculate
automatically routes to the JSON-merge path. There is nothing to
configure — the type assertion in the dispatch decides:
import "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
cur := &unstructured.Unstructured{}
cur.SetGroupVersionKind(myCRDGVK)
_ = c.Get(ctx, key, cur)
desired := &unstructured.Unstructured{Object: myDesiredMap}
result, err := patch.DefaultPatchMaker.Calculate(cur, desired)
The JSON-merge path also runs the same "apply locally, re-diff" trick to suppress no-op patches. CRDs without a registered Go type — the common case for dynamic operators — are a first-class citizen.
Note
The strategic-merge path needs a typed Go struct so it can look up patch metadata (
patchStrategy/patchMergeKeystruct tags). For unstructured objects there's no struct, soCreateThreeWayJSONMergePatchfromk8s.io/apimachinery/pkg/util/jsonmergepatchis used instead. The trade-off: list-of-objects with merge-keys won't merge as intelligently. Test your CRD schema against real updates.
5.8 Opt out of an entire subtree (workaround for nested-field ignore)
Until nested-field ignoring lands (#57),
the workaround is a custom CalculateOption — the type is just a
function, so you can write your own:
import (
json "github.com/json-iterator/go"
"github.com/cisco-open/k8s-objectmatcher/patch"
)
// IgnoreSpecReplicas zeroes spec.replicas on both sides — useful when
// HPA owns replicas.
func IgnoreSpecReplicas() patch.CalculateOption {
return func(current, modified []byte) ([]byte, []byte, error) {
for i, b := range [][]byte{current, modified} {
var m map[string]interface{}
if err := json.Unmarshal(b, &m); err != nil {
return nil, nil, err
}
if spec, ok := m["spec"].(map[string]interface{}); ok {
delete(spec, "replicas")
}
out, err := json.ConfigCompatibleWithStandardLibrary.Marshal(m)
if err != nil {
return nil, nil, err
}
if i == 0 {
current = out
} else {
modified = out
}
}
return current, modified, nil
}
}
You can stack any number of these alongside the shipped options.
6. Troubleshooting
The diagnostic decision tree:
Reconcile loops forever?
├── Calculate returns no error, IsEmpty() == false on every pass
│ → 6.1 spurious diff from server-set fields
│ → 6.2 stale or missing last-applied annotation
│ → 6.3 $setElementOrder noise
├── Calculate returns an error
│ → 6.4 "Failed to lookup patch meta" (unsupported type)
│ → 6.5 "could not unmarshal" (object isn't valid JSON)
│ → 6.6 "Failed to convert current object to byte sequence"
└── Annotation never appears on the live object
→ 6.7 forgot to call SetLastAppliedAnnotation before create/update
6.1 Error: every reconcile reports a non-empty patch on status
Cause: You're not passing IgnoreStatusFields(). The API server
writes status for almost every workload type, and your reconciler
sees that as a difference between desired (status: {}) and current
(status: {observedGeneration: 7, ...}).
Solution:
result, err := patch.DefaultPatchMaker.Calculate(cur, des, patch.IgnoreStatusFields())
Prevention: Add IgnoreStatusFields() to every Calculate call by
default. There's no downside on objects that don't have a status
field — the option just deletes a key that's already absent.
6.2 Error: result.Original is nil and patches are huge
Cause: The live object has no last-applied annotation, so the
three-way merge degrades into a two-way merge between Current and
Modified. Every server-defaulted field shows up.
Solution:
- Call
patch.DefaultAnnotator.SetLastAppliedAnnotation(desired)immediately beforec.Create(ctx, desired). - Call it again immediately before every
c.Update(ctx, desired). - For existing objects in the cluster predating your operator,
one-time backfill: read the live object, call
SetLastAppliedAnnotation(live)against it as the "desired", andUpdate. The next reconcile starts clean.
Prevention: Move the SetLastAppliedAnnotation call into a small
helper that wraps Create/Update so you can never forget it.
6.3 Error: patch contains $setElementOrder/containers for an unchanged Pod spec
Cause: Strategic merge generates $setElementOrder directives for
mergeable list fields. The library's second pass (apply locally, re-
diff with two-way merge) catches the common cases, but a few residuals
slip through — see issue
#9.
Solution: Confirm that the name keys on every list element match
exactly between current and desired. Strategic merge uses those as
merge keys; even a single rename re-orders the list and produces a
real $setElementOrder directive — not a bug.
Prevention: Build desired Pod specs from a deterministic source (template + Helm-like rendering, not random map iteration) so the list order is stable.
6.4 Error: Failed to lookup patch meta
Cause: K8sStrategicMergePatcher.CreateThreeWayMergePatch calls
strategicpatch.NewPatchMetaFromStruct(dataStruct). If dataStruct is
a type with no strategic-merge tags (a CRD struct that doesn't
register patchStrategy="merge" annotations), this errors.
Solution: Pass the object as *unstructured.Unstructured instead
— Calculate will route it through the JSON-merge path, which doesn't
need patch metadata.
u, err := runtime.DefaultUnstructuredConverter.ToUnstructured(myCRDObj)
// ...wrap and use as *unstructured.Unstructured
Prevention: For CRDs without proper struct tags, standardize on unstructured access in the reconciler.
6.5 Error: could not unmarshal byte sequence from a CalculateOption
Cause: Your custom option (or one of the shipped ones) was handed bytes that aren't valid JSON. Almost always: a previous option in the chain returned malformed bytes.
Solution: Inspect the option order. Each option must return
valid JSON for both current and modified. If you're writing
your own option, never return raw []byte("") on the success path —
always round-trip through marshaling.
Prevention: Unit-test custom options with the unstructured test
helpers from patch/patch_test.go (mustFromUnstructured /
mustToUnstructured) as a template.
6.6 Error: Failed to convert current object to byte sequence
Cause: The runtime.Object you passed wraps a value that json-iterator refuses to marshal — typically a pointer cycle or a Go interface with no encoder. With Kubernetes types, this almost never happens; with custom embedded structs in your own CRD types, it can.
Solution: Round-trip the object through
runtime.DefaultUnstructuredConverter.ToUnstructured and back. If
that fails too, the bug is in your CRD type definition (an
unexported field on the JSON path, a custom MarshalJSON that
panics, etc.).
Prevention: Run json.Marshal(yourObj) once at startup as a smoke
check.
6.7 Error: live object has no annotation after Create
Cause: SetLastAppliedAnnotation was called on the desired object
after Create. The mutation lives in memory but never reaches the
API server.
Solution: Move the SetLastAppliedAnnotation call before
c.Create(ctx, desired). The mutation is on the in-memory struct and
will be persisted as part of the create payload.
Prevention: Treat the annotator as a "stamp before send" operation, not a "stamp after send" one.
Warning
Calling
SetLastAppliedAnnotationon the current (server-side) object inside a reconcile, then callingUpdate, will work — but only if you also reapply your other desired-state mutations to the live object first. Most controllers find it cleaner to mutate the desired object andUpdatethat, never the live object.
7. FAQ
Q: Is this a CLI tool?
No — it's a Go library. There is no binary, no kubectl plugin, no
container image. You import github.com/cisco-open/k8s-objectmatcher/patch
into a Go program (typically a Kubernetes operator) and call its API.
Q: Does it work with controller-runtime / Operator SDK / Kubebuilder?
Yes — those all build on k8s.io/client-go and k8s.io/apimachinery,
and the only types this library cares about are runtime.Object
(which all of those use) and *unstructured.Unstructured. Drop it
into any reconciler.
Q: What's the difference between IsEmpty() and string(Patch) == "{}"?
IsEmpty() is exactly that comparison. Use the method — it's clearer
and survives a future internal change to representation.
Q: Why isn't there a kubectl apply --dry-run style mode?
Because that's the wrong abstraction for an operator. kubectl apply
operates on a single object at a time and prints diffs to a human; an
operator needs a programmatic answer ("should I update or not?") and a
machine-readable patch. Calculate returns both — the answer
(IsEmpty()) and the byte-level patch.
Q: Does it handle server-side apply?
Not directly. Server-side apply uses managed-fields tracking
(metadata.managedFields), which is a different model from the
client-side last-applied annotation this library implements. If your
controller uses server-side apply, the API server itself does the
"is this a real change" calculation — you don't need this library.
Many older operators use the client-side model and that's where this
library is the right fit.
Q: Can I use this without controller-runtime?
Yes. The library has zero dependency on controller-runtime — it only
needs k8s.io/apimachinery types. Plain client-go informers and
custom reconcile loops work fine.
Q: What about Pods?
Pods work fine, but most operators don't reconcile Pods directly —
they reconcile workload controllers (Deployment, StatefulSet, Job)
and let those manage their Pods. If you do diff a Pod, expect a lot
of server-defaulted fields (securityContext,
terminationGracePeriodSeconds, dnsPolicy, the auto-mounted
ServiceAccount token volume) so use IgnoreField("status") and
consider a custom option for the auto-injected volume.
Q: How do I ignore a label on Pods specifically?
This is open as
#65. For
now, write a custom CalculateOption that walks
spec.template.metadata.labels and deletes the offending key on both
sides. The recipe in 5.8 is the template.
Q: What's the deal with the banzaicloud.com/last-applied annotation key?
The library was originally built at Banzai Cloud (a Hungarian Kubernetes
shop) and Cisco kept the key when they took over the repo. Customers
depend on the exact value for backward compatibility — operators
upgrading from Banzai Cloud builds to cisco-open builds need their
existing live objects to round-trip correctly.
Q: Does it handle StatefulSet volumeClaimTemplates resize?
No — the library answers "is this a real change?", but StatefulSet PVC capacity is immutable in Kubernetes itself. Even with a real change, the API server will reject the update. Use a separate recreate-with-data-migration path. See issue #51 for related discussion.
Q: Where do I report a missing per-kind cleaner?
github.com/cisco-open/k8s-objectmatcher/issues. Issue #40 is the canonical "exclude specific fields from a StatefulSet" thread.
Q: Is the library safe to call from many goroutines?
Yes — DefaultPatchMaker and DefaultAnnotator are stateless beyond
their immutable fields, and Calculate is a pure function over its
inputs. The only shared state is the underlying
meta.MetadataAccessor, which is itself goroutine-safe.
Q: How big can the last-applied annotation get?
Kubernetes caps annotations at 256 KiB per object. The library
gzip-compresses the JSON before base64-encoding, so practical limits
are roughly 1–2 MiB of raw JSON. ConfigMaps with embedded large
payloads can hit the ceiling — for those, consider IgnoreField on
the data key and reconcile-by-hash instead.