This guide is the deep, source-based reference for
cisco-open/k8s-objectmatcher. The library is small — one Go package
(patch), four production source files, two test files, ~25 KB of source
— but it sits in the critical path of Kubernetes controllers. A bug in the
diff calculation can silently turn a healthy reconciler into a hot loop
that thrashes the API server, or worse, into a quiet reconciler that fails
to apply real changes. This document covers the architecture in detail, the
full configuration surface, the patch-calculation algorithm step by step,
the integration risk model, and the developer workflow for adding support
for new resource kinds or custom cleaners.
The audience is operator authors, controller-runtime users, library maintainers, and security reviewers tracing how patch payloads flow through the system.
1. Architecture Overview
k8s-objectmatcher is a pure-function diff engine with two side
effects on Kubernetes objects: stamping a last-applied annotation and
reading it back. Everything else — the strategic-merge / JSON-merge
dispatch, the per-kind cleaners, the null-stripping pass — operates on
in-memory []byte buffers without touching the cluster.
1.1 What the library does
- Marshals both the current (live) object and the desired (modified) object into JSON.
- Applies a configurable chain of
CalculateOptionfilters that delete or normalise fields on both byte streams symmetrically. - Strips nulls and zero-valued
IntOrStringtypes viaDeleteNullInJson. - Reads the last-applied snapshot from the live object's annotation
(
banzaicloud.com/last-appliedby default), which the controller itself stamped on the previous reconcile. - Computes a three-way merge patch — strategic for typed objects,
JSON-merge for
*unstructured.Unstructured. - Re-applies the patch locally and re-diffs to suppress
$setElementOrdernoise on the strategic-merge path (and the JSON- merge path runs the same trick). - Returns a
*PatchResultwith the patch, the cleaned current bytes, the cleaned modified bytes, and the original snapshot.
The controller compares PatchResult.IsEmpty() against "{}" and
either skips the update (in-sync) or calls client.Update (real
change).
1.2 ASCII architecture diagram
Controller Reconcile Loop
currentObject (from API)
modifiedObject (built locally)
patch.PatchMaker
1. json.Marshal(currentObject)
2. json.Marshal(modifiedObject)
3. apply CalculateOption chain
IgnoreStatusFields
IgnoreField
IgnoreVolumeClaimTemplateTypeMetaAndStatus
(custom)
4. DeleteNullInJson on both
5. Annotator.GetOriginalConfiguration(currentObject)
type switch on currentObject
unstructured.Unstructured everything else
BaseJSONMergePatcher K8sStrategicMergePatcher
CreateThreeWay CreateThreeWayMergePatch
JSONMergePatch then apply locally and
then apply locally, CreateTwoWayMergePatch
CreateMergePatch to collapse
setElementOrder noise
PatchResult
Patch
Current
Modified
Original
IsEmpty()
true false
skip update client.Update
SetLastAppliedAnnotation
1.3 Component inventory
| Component | Path | Role |
|---|---|---|
Annotator |
patch/annotation.go |
Reads / writes the last-applied annotation. Handles zip+base64 with a legacy plain-value fallback. |
PatchMaker |
patch/patch.go |
Orchestrates the diff. The only public entry point most users touch. |
Maker (interface) |
patch/patch.go |
Single method Calculate. Decouples consumers from the concrete *PatchMaker. |
PatchResult |
patch/patch.go |
The return value of Calculate. Plain struct with four byte slices. |
CalculateOption |
patch/deletenull.go |
func([]byte, []byte) ([]byte, []byte, error). The plug-in slot for filters. |
| Per-kind cleaners | patch/deletenull.go |
IgnoreStatusFields, IgnoreField, IgnoreVolumeClaimTemplateTypeMetaAndStatus, plus the unexported helpers deleteStatusField, deleteDataField, deleteVolumeClaimTemplateFields, DeleteNullInJson. |
K8sStrategicMergePatcher |
patch/patch_makers.go |
Default StrategicMergePatcher. Wraps k8s.io/apimachinery/pkg/util/strategicpatch. Has a PreconditionFuncs slice. |
BaseJSONMergePatcher |
patch/patch_makers.go |
Default JSONMergePatcher. Wraps evanphx/json-patch and k8s.io/apimachinery/pkg/util/jsonmergepatch. |
init() in deletenull.go |
patch/deletenull.go |
Registers a custom json-iterator encoder for intstr.IntOrString to write nil instead of 0 when the value is unset. |
Note
The
init()block inpatch/deletenull.gooverrides the global json-iterator encoder forintstr.IntOrString. Importing thepatchsubpackage anywhere in your binary changes the marshal behaviour of json-iterator everywhere — this is intentional (soCalculatesees stable bytes) but worth knowing if you also use json-iterator elsewhere.
1.4 Technology stack
| Layer | Component | Version |
|---|---|---|
| Language | Go | 1.22 |
| JSON | github.com/json-iterator/go |
v1.1.12 |
| Strategic merge | k8s.io/apimachinery/pkg/util/strategicpatch |
v0.19.16 |
| JSON merge | github.com/evanphx/json-patch |
v5.9.0+incompatible |
| JSON-merge orchestration | k8s.io/apimachinery/pkg/util/jsonmergepatch |
(vendored from apimachinery) |
| Error wrapping | emperror.dev/errors |
v0.8.1 |
| Annotation access | k8s.io/apimachinery/pkg/api/meta (MetadataAccessor) |
v0.19.16 |
| Compression | archive/zip (stdlib) |
n/a |
| Encoding | encoding/base64 (stdlib) |
n/a |
The integration test module (tests/) is a separate Go module with its
own go.mod and pulls in k8s.io/client-go v0.21.3, k8s.io/api
v0.21.3, and k8s.io/apiextensions-apiserver v0.21.3 — these are not
runtime dependencies of patch.
1.5 Data flow for the main workflow
Controller Annotator PatchMaker Patcher impls
Reconcile(req)
Get(current) <-- API --
desired = build()
SetLastApplied(desired) -> GetModified(desired,false)
<- modifiedNoAnn
-> SetOriginal(desired,modifiedNoAnn)
Calculate(current, desired, opts...)
json.Marshal(current)
json.Marshal(desired)
apply each opt
DeleteNullInJson * 2
GetOriginal(current) <-- read annotation, base64+unzip ---
if *unstructured.Unstructured
CreateThreeWayJSONMergePatch -> jsonMergePatcher
if patch != "{}":
MergePatch -> jsonMergePatcher
CreateMergePatch -> jsonMergePatcher
else
CreateThreeWayMergePatch -> strategicMergePatcher
if patch != "{}":
StrategicMergePatch -> strategicMergePatcher
CreateTwoWayMergePatch -> strategicMergePatcher
return PatchResult{Patch, Current, Modified, Original}
if IsEmpty(): return
Update(desired) -- API ->
2. System Requirements
Because the library is a Go module with no daemon and no CLI, the only runtime requirements are those of the controller binary that imports it.
2.1 Build requirements
| Item | Version | Notes |
|---|---|---|
| Go toolchain | 1.22+ | Set in go.mod (go 1.22). |
| Module proxy | Default proxy.golang.org |
No private replacements needed. |
Network for go get |
yes | One-time fetch; vendor afterwards if your build needs to be hermetic. |
2.2 Runtime requirements (from the calling controller)
| Item | Requirement | Notes |
|---|---|---|
| Memory | None measurable beyond JSON marshal buffers | Each Calculate allocates 4× the object size temporarily (current, modified, patch, original). |
| CPU | Negligible per call | Strategic merge is the heaviest step; even large StatefulSets finish in single-digit milliseconds. |
| File system | None | Library is pure in-process. |
| Kubernetes version | Any version where apimachinery's strategic-merge logic still applies — effectively all supported versions (1.21+). |
The library tracks apimachinery v0.19.16 in go.mod; later API server versions are forward-compatible because the patch metadata lookup is reflected from the Go struct, not from the API server. |
| API permissions | Whatever the controller already has | The library makes no API calls of its own. |
Note
The library's pinned
apimachinery v0.19.16ingo.modis the baseline for strategic-merge metadata. When a controller importsapimachinery v0.30+, Go's MVS picks the newer version and that's what's actually compiled. The pin only matters for builds that downgrade — which is rare.
2.3 Object-size constraints
The annotation system imposes Kubernetes' standard 256 KiB
per-annotation cap. The library compresses with archive/zip before
base64 encoding, so the effective ceiling is roughly 1–2 MiB of raw
JSON (compression ratio depends on object content; ConfigMaps with
text-heavy data compress well, dense binary fields don't).
3. Configuration Reference
The library has no config file and no environment variables.
Configuration happens at the call site — option arguments to
Calculate and constructor arguments to NewAnnotator /
NewPatchMaker. This section documents every knob.
3.1 CalculateOption reference
| Option | Signature | Effect on byte stream | Implemented by |
|---|---|---|---|
IgnoreStatusFields() |
() CalculateOption |
delete(obj, "status") on both sides |
deleteStatusField |
IgnoreField(field string) |
(string) CalculateOption |
delete(obj, field) on both sides — top-level only |
deleteDataField |
IgnoreVolumeClaimTemplateTypeMetaAndStatus() |
() CalculateOption |
Walks spec.volumeClaimTemplates[*] and sets kind="", apiVersion="", status={"phase":"Pending"} on each |
deleteVolumeClaimTemplateFields |
Application order matters because each option sees the byte stream
mutated by the previous one. Order is the slice index in the
opts ...CalculateOption variadic argument to Calculate.
Important
IgnoreFieldonly affects top-level keys. To suppress a nested field (e.g.spec.replicas,metadata.labels[someKey]), you must implement a customCalculateOption. This is a known gap — see #57.
3.2 Annotator configuration
NewAnnotator(key string) is the only constructor. The single config
input is the annotation key. Defaults:
| Symbol | Value | Where set |
|---|---|---|
LastAppliedConfig |
"banzaicloud.com/last-applied" |
patch/annotation.go:32 |
DefaultAnnotator |
NewAnnotator(LastAppliedConfig) |
patch/annotation.go:34 |
Important
Changing the annotation key in a running operator orphans the existing snapshots on every live object. Plan a backfill: read every managed object, call
SetLastAppliedAnnotationwith the new key, andUpdate. Until the backfill completes, those objects' diffs degrade to two-way merges and may produce large, server-defaulted patches.
3.3 PatchMaker configuration
NewPatchMaker(annotator *Annotator, smp StrategicMergePatcher, jmp JSONMergePatcher) Maker
takes three injected dependencies:
| Argument | Default | Purpose |
|---|---|---|
annotator *Annotator |
DefaultAnnotator |
Reads the last-applied snapshot during three-way merge. |
smp StrategicMergePatcher |
&K8sStrategicMergePatcher{} |
Three operations: StrategicMergePatch, CreateTwoWayMergePatch, CreateThreeWayMergePatch. The default delegates to k8s.io/apimachinery/pkg/util/strategicpatch. |
jmp JSONMergePatcher |
&BaseJSONMergePatcher{} |
Three operations: MergePatch, CreateMergePatch, CreateThreeWayJSONMergePatch. Default wraps evanphx/json-patch and apimachinery/pkg/util/jsonmergepatch. |
The interfaces are deliberately small so you can substitute fakes in tests or wrap the defaults with metrics / tracing.
3.4 K8sStrategicMergePatcher.PreconditionFuncs
The default strategic-merge implementation has a single configurable field:
type K8sStrategicMergePatcher struct {
PreconditionFuncs []mergepatch.PreconditionFunc
}
Precondition functions run on the patch and can short-circuit the
merge with an error. The two stock functions in
k8s.io/apimachinery/pkg/util/mergepatch are:
RequireKeyUnchanged(key)— error if the patch attempts to change the named key.RequireMetadataKeyUnchanged(key)— same, but scoped tometadata.
maker := patch.NewPatchMaker(
patch.DefaultAnnotator,
&patch.K8sStrategicMergePatcher{
PreconditionFuncs: []mergepatch.PreconditionFunc{
mergepatch.RequireKeyUnchanged("apiVersion"),
mergepatch.RequireMetadataKeyUnchanged("uid"),
},
},
&patch.BaseJSONMergePatcher{},
)
These run only on the CreateThreeWayMergePatch and
CreateTwoWayMergePatch paths. Use them to harden against logic bugs
that would otherwise produce a patch that mutates an immutable
field.
3.5 The full configuration surface, in one table
| Where | Symbol | Type | Purpose |
|---|---|---|---|
| Package | LastAppliedConfig |
const string |
Default annotation key |
| Package | DefaultAnnotator |
*Annotator |
Pre-built annotator with the default key |
| Package | DefaultPatchMaker |
Maker |
Pre-built PatchMaker with default deps |
| Per-call | opts ...CalculateOption |
variadic | Applied left-to-right inside Calculate |
| Constructor | NewAnnotator(key) |
constructor arg | Custom annotation key |
| Constructor | NewPatchMaker(annotator, smp, jmp) |
constructor args | Custom annotator and patcher impls |
| Field | K8sStrategicMergePatcher.PreconditionFuncs |
[]mergepatch.PreconditionFunc |
Patch validation guards |
There are no other knobs. No timeouts (the library doesn't make network calls), no logger injection (errors are returned, not logged), no rate limits.
4. Deployment & Scaling
Deployment of k8s-objectmatcher is deployment of the controller
binary that imports it. There is nothing to deploy separately.
4.1 Build deployment checklist
- [x]
go get github.com/cisco-open/k8s-objectmatcher/patch - [x] Pin the version in
go.mod(don't tracklatestin production controllers) - [x] Run
go mod vendorif your build is hermetic - [x] Cross-compile to your target arch (Linux/AMD64 and Linux/ARM64 both work without changes)
- [x] Build the controller container image as usual
- [x] Deploy via your normal operator-distribution channel (Helm, OLM, raw manifests)
4.2 Scaling considerations
The library is stateless and pure — there is nothing to scale horizontally or vertically. Performance characteristics:
| Metric | Cost |
|---|---|
Memory per Calculate |
~4× the JSON size of the object (current, modified, patch, original transient buffers) |
CPU per Calculate |
dominated by strategicpatch.CreateThreeWayMergePatch — ~milliseconds for typical objects |
| GC pressure | high — every call allocates fresh maps for unmarshal + marshal cycles |
| Lock contention | none — the library has no shared mutable state |
For controllers reconciling thousands of objects per second, the GC
allocations are usually the limiting factor. There is no built-in
pooling of []byte or map[string]interface{} buffers; consider
profiling with -memprofile and a sync.Pool wrapper if you hit
hot-path GC pressure.
Tip
If you reconcile the same object frequently and the snapshot rarely changes, cache the
PatchResult.Patch == "{}"decision at the controller layer keyed by(uid, resourceVersion). The library itself does no caching — by design, since determining whetherresourceVersionchanges equate to semantic changes is the very question the library exists to answer.
4.3 Versioning policy
The repo follows Go module versioning. Major version bumps would
require a path change to /v2 per the Go module rules. As of this
writing, the library lives at the v0/v1 module path
(github.com/cisco-open/k8s-objectmatcher).
5. Operations
Operating a controller that uses this library is operating any other controller — there's no dedicated runbook for the library itself. That said, three operational concerns deserve their own subsections.
5.1 Observability
The library does not log. Every error is returned wrapped via
emperror.dev/errors and propagated up to the caller. The controller
is responsible for logging:
result, err := maker.Calculate(cur, des, patch.IgnoreStatusFields())
if err != nil {
logger.Error(err, "patch calculation failed",
"kind", cur.GetObjectKind().GroupVersionKind().Kind,
"name", cur.GetName())
return err
}
if !result.IsEmpty() {
logger.V(1).Info("applying patch",
"patch", string(result.Patch),
"size", len(result.Patch))
}
Note
Logging
result.PatchatV(1)is invaluable for debugging — but see Section 7.3 below for the security implication: patches can contain secret material if your controller manages Secrets or Secret-bearing objects. Log them atV(2)and through a redacting log sink in production.
5.2 Annotation hygiene
The last-applied annotation grows with the object. For a Deployment or StatefulSet, expect 2–10 KiB of base64-encoded zip per object. Per-namespace etcd consumption can become noticeable at thousands of objects, but the per-object annotation cap (256 KiB) is the more important limit.
| Symptom | Likely cause | Mitigation |
|---|---|---|
annotation: too long from API server |
Object > ~1.5 MiB raw JSON | Use IgnoreField to drop a large data block before annotating, or split the object |
| Annotation absent on live objects | First reconcile didn't call SetLastAppliedAnnotation |
Backfill loop on startup |
| Annotation never updates | Controller updates via Patch instead of Update, and the patch doesn't include the annotation |
Switch to Update, or add the annotation to the Patch payload |
5.3 Upgrading the library in a running operator
Drop-in safe for the supported version line. The annotation format
has been stable since the zip+base64 migration, and the read
path keeps a fallback for plain JSON values, so upgrading from any
older Banzai Cloud or cisco-open build works without flag days.
The strategic-merge / JSON-merge dispatch table is also stable. The
public surface — PatchMaker, Annotator, CalculateOption,
PatchResult — is treated as part of the contract.
Tip
When upgrading, run a
go test ./patch/...against a unit-test harness with representative objects from your production cluster. The library has 100% public-API coverage in its own tests; layered tests in your controller catch any subtle behaviour changes that matter to your CRDs.
6. Performance
The hot path is Calculate. Cost analysis:
6.1 Per-call cost breakdown
| Step | Cost | Notes |
|---|---|---|
json.Marshal(currentObject) |
O(N) where N is JSON size | json-iterator is ~2× faster than stdlib |
json.Marshal(modifiedObject) |
O(N) | same |
Each CalculateOption |
O(N) — Unmarshal + delete + Marshal | The shipped options round-trip the entire object; for large objects this is the heaviest step |
DeleteNullInJson × 2 |
O(N) — recursive walk | Visits every key once |
GetOriginalConfiguration |
O(annot_size) — base64 + unzip | ~half the size of original JSON |
Strategic merge CreateThreeWayMergePatch |
O(N) plus reflection over the Go type | Dominant for typed objects |
| Strategic merge re-apply + two-way | O(N) again | Doubles the strategic-merge cost; required to suppress $setElementOrder noise |
JSON merge CreateThreeWayJSONMergePatch |
O(N) | Cheaper than strategic — no reflection |
A typical Deployment runs Calculate in 1–3 ms on modern hardware.
StatefulSets with many volumeClaimTemplates are slower because
IgnoreVolumeClaimTemplateTypeMetaAndStatus walks the templates list
in addition to the rest of the spec.
6.2 Allocation profile
The dominant allocations are:
- The
map[string]interface{}returned byjson.Unmarshalinside each option and insideDeleteNullInJson. - The
[]byteslices returned by everyMarshal.
A single Calculate against a Deployment allocates ~30–50 KiB of
heap. The GC handles this fine in normal operation; controllers
running at >1000 reconciles/sec/CPU may want to look at hot-path
profiling.
Note
The library does not pool buffers internally. If you need pooling, wrap the
Makerinterface in your own implementation that pools the[]byteoutputs ofCalculatefor downstream logging.
6.3 Benchmarking
There are no shipped benchmarks. To measure your own:
func BenchmarkCalculate(b *testing.B) {
cur := buildDeployment("cur")
des := buildDeployment("des")
maker := patch.DefaultPatchMaker
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = maker.Calculate(cur, des, patch.IgnoreStatusFields())
}
}
Run with go test -bench=. -benchmem ./yourpkg/....
7. Security
k8s-objectmatcher has no network surface — it never opens a
socket, never makes an HTTP call, never reads a file. The security
posture is therefore framed in terms of integration risk (what
happens when the calling controller misuses the library) and
data exposure (what's in the patch payloads that leave the
library's address space).
7.1 Integration risk: the buggy-patcher problem
A bug in patch calculation propagates to a destructive reconcile. Two failure modes are worth calling out:
| Failure mode | Mechanism | Blast radius |
|---|---|---|
| Spurious non-empty patch | A CalculateOption mutates one side but not the other; the diff sees the asymmetry as a real change |
Hot reconcile loop, API-server pressure, audit-log noise |
| Spurious empty patch | An option deletes a field on both sides that should have triggered an update | Real changes go undetected; the cluster silently drifts from desired state |
The first failure is loud and self-correcting (you'll notice in
metrics and back off). The second is silent and dangerous. Custom
CalculateOptions must be symmetric — mutate current and
modified the same way — and unit-tested with deliberately mutated
inputs.
Caution
A custom
CalculateOptionthat deletes a field only on themodifiedside will silently mask real updates. Always assert in tests that the option produces identical mutations on both byte slices for inputs that are equal.
7.2 Operator security posture
The library inherits the operator's RBAC and the operator's threat model. Specifically:
- The operator typically holds verbs
get,list,watch,create,update,patch,deleteon the resources it manages. - A bug in the library (or in the controller's use of it) cannot
exceed those verbs —
Calculateproduces bytes, but only the controller'sclient.Updatecall commits them. - The annotation it stamps is a benign metadata field. It does not carry capability or trust beyond what's already in the object.
Warning
Do not treat the last-applied annotation as a source of truth for security decisions (e.g. "this object was previously approved because it has our annotation"). Annotations can be written by any principal with
updateon the object — including humans with kubectl access. The annotation is a controller-internal cache, not an authentication signal.
7.3 Patch payloads in audit logs and observability pipelines
The patch bytes that flow through Calculate are the diff of two
Kubernetes objects, which means they can carry sensitive material
for any object type that holds secrets:
| Object kind | Sensitive content in the patch |
|---|---|
Secret |
The full data map (base64-encoded plaintext) when changed |
ConfigMap |
Embedded credentials, connection strings |
| Pod template specs | env values, mounted secret references, SA token path |
| CRDs storing credentials inline | Whatever the CRD schema permits |
If your controller logs result.Patch (a useful debugging practice)
and your log pipeline forwards everything to a centralized store
(Loki, Splunk, Datadog), you have just exfiltrated secret material
to that pipeline. Same for the audit log if you kubectl describe
the patch payload through some intermediary.
Recommendations:
- Log
result.Patchonly at high verbosity (e.g.V(2)in logr/klog). - Configure a redacting log sink that strips
Secret.data,ConfigMap.datafor known-sensitive keys, andenvvalues matching*_PASSWORD,*_TOKEN,*_KEYpatterns. - For controllers that manage
Secretobjects, consider replacing the patch payload with a hash for log purposes:
import "crypto/sha256"
if !result.IsEmpty() {
hash := sha256.Sum256(result.Patch)
logger.V(1).Info("patch needed", "patch_sha256", fmt.Sprintf("%x", hash[:8]))
}
Caution
Audit logs and tracing spans are also exposure surfaces.
result.String()formats the entirePatch,Current,Modified, andOriginal— that's four copies of the (possibly secret- bearing) object, one of them base64-decoded from the annotation. Never putresult.String()in a span attribute.
7.4 Supply chain
The library has a small, well-known dependency graph:
| Dep | Trust |
|---|---|
k8s.io/apimachinery |
Kubernetes core, signed releases |
github.com/json-iterator/go |
Mature, widely used |
github.com/evanphx/json-patch |
Reference JSON-patch implementation, used by Kubernetes itself |
emperror.dev/errors |
Small wrapper, low risk |
The license inventory is enforced by .licensei.toml (Apache-2.0,
MIT, BSD, MPL-2.0). License audits are routine — see the file for
the exact ignore list.
8. Developer Internals
This section documents the internals so contributors can extend the library without breaking the contract.
8.1 Repository structure
| Path | Role |
|---|---|
patch/annotation.go |
Annotator type, NewAnnotator, the four annotation methods, the zip+base64 codec |
patch/annotation_test.go |
Unit test for the empty-annotation edge case |
patch/deletenull.go |
CalculateOption type, the three shipped options, DeleteNullInJson, the unexported delete*Field helpers, and the json-iterator intstr.IntOrString encoder override |
patch/patch.go |
Maker interface, PatchMaker struct, Calculate, PatchResult, unstructuredJsonMergePatch (private) |
patch/patch_makers.go |
StrategicMergePatcher interface, JSONMergePatcher interface, the two default impls |
patch/patch_test.go |
Unit tests for the unstructured JSON-merge code path |
tests/ |
Integration tests against a real or simulated API server (separate Go module) |
docs/legacy.md |
Retained historical documentation |
Makefile |
go test, lint, license-check targets |
.golangci.yml |
linter config (gocyclo min-complexity 15, misspell, gofmt, goimports enabled) |
.licensei.toml |
License-audit allow/ignore lists |
8.2 The strategic-merge flow, step by step
The flow inside (*PatchMaker).Calculate for a typed object (the
"everything else" branch of the type switch):
- Marshal current and modified with json-iterator (which uses
the custom
IntOrStringencoder registered ininit()— soIntOrString{IntVal: 0, StrVal: ""}becomesnull, not0). - Apply each
CalculateOptionin order. Each option round- trips through Unmarshal + delete + Marshal, which means the per-option cost is O(N) regardless of how small the deletion is. DeleteNullInJsonwalks the entire JSON tree and removes any key whose value isnilor zero-valued (perisZero— which treats float, int64, bool as never zero so they aren't accidentally elided when the user intentionally set 0/false).Annotator.GetOriginalConfiguration(currentObject)reads the annotation. The implementation:- Reads
annots[a.key]. - Tries base64 decode. If the result has content type
application/zip, unzips and returns the inner bytes. - If base64 decode fails, falls back to returning the raw annotation bytes (legacy plain-JSON path).
- Reads
- Type switch on
currentObject:*unstructured.Unstructured: route to(*PatchMaker).unstructuredJsonMergePatch.- default: continue with strategic merge.
strategicMergePatcher.CreateThreeWayMergePatch(original, modified, current, currentObject)produces the candidate patch.- If
string(patch) != "{}":- Apply the patch to
currentlocally:patchCurrent, _ := strategicMergePatcher.StrategicMergePatch(current, patch, currentObject). - Re-diff:
patch, _ := strategicMergePatcher.CreateTwoWayMergePatch(current, patchCurrent, currentObject). This collapses$setElementOrdermarkers that strategic merge generates even for unchanged lists.
- Apply the patch to
- Build and return the
PatchResult.
The unstructured path mirrors the same trick:
CreateThreeWayJSONMergePatch → if non-empty, MergePatch to apply
locally, then CreateMergePatch to re-derive a clean diff.
8.3 The per-kind cleaner pattern
A "cleaner" is a CalculateOption that knows about a specific
Kubernetes kind and normalises a field that the API server mutates.
The shipped cleaner is IgnoreVolumeClaimTemplateTypeMetaAndStatus,
which targets StatefulSet spec.volumeClaimTemplates. The pattern:
func IgnoreXYZ() CalculateOption {
return func(current, modified []byte) ([]byte, []byte, error) {
current, err := mutateXYZ(current)
if err != nil {
return nil, nil, errors.Wrap(err, "...")
}
modified, err = mutateXYZ(modified)
if err != nil {
return nil, nil, errors.Wrap(err, "...")
}
return current, modified, nil
}
}
func mutateXYZ(obj []byte) ([]byte, error) {
var resource map[string]interface{}
if err := json.Unmarshal(obj, &resource); err != nil {
return nil, errors.Wrap(err, "could not unmarshal byte sequence")
}
// ...navigate into the structure, normalise the field...
out, err := json.ConfigCompatibleWithStandardLibrary.Marshal(resource)
if err != nil {
return nil, errors.Wrap(err, "could not marshal byte sequence")
}
return out, nil
}
mutateXYZ is responsible for being defensive — every layer of
type assertion (if spec, ok := resource["spec"].(map[string]interface{}); ok)
must guard against the field not existing. Returning an unmodified
byte stream when the field isn't present is the correct behaviour.
8.4 Adding support for a new resource kind
The high-level recipe to add an IgnoreFooBarBaz() option for a new
kind:
- Identify the server-mutated fields by
kubectl get -o jsonthe live object after creation and diffing it against the desired YAML you submitted. Note every key that appears in the live but not the desired. - Decide the mutation strategy — delete the field on both
sides (use
delete()), or normalise it to a fixed value (set it on both sides, likeIgnoreVolumeClaimTemplateTypeMetaAndStatusdoes). - Implement following the cleaner pattern above.
- Add a unit test in
patch/deletenull_test.go(create the file if it doesn't exist) that:- Creates two byte streams with the field set.
- Runs the option.
- Asserts both streams come out identical and the field is gone (or normalised).
- Asserts that for inputs without the field, the option is a no-op.
- Test against
Calculateend-to-end with a representative*unstructured.Unstructuredand a typed object. - Document in this guide and the User Guide's option table.
Note
The
tests/directory at the repo root is a separate Go module withk8s.io/client-goandapiextensions-apiserveras dependencies. End-to-end tests for new options usually live there rather than in thepatch/unit tests, because they require a real (orenvtest-simulated) API server.
8.5 Why the second pass exists
Strategic merge generates $setElementOrder/<list-name> directives
whenever a mergeable list is involved, even when the contents and
order are identical. That's because strategic merge's three-way
algorithm computes the order from the merge of three versions and
emits a directive when the logical order could differ — even if
in practice it doesn't.
The library's response is to apply the patch back onto current
and re-diff with two-way merge. If applying the patch yields the
same current, the two-way diff is empty, and the patch collapses
to {}. This is the key innovation that makes the library practical
for real controllers; without it, every reconcile of any object with
a list (Pods always have at least containers, volumes, etc.)
would see a non-empty patch.
The unstructured path uses the same trick but with MergePatch
(apply) and CreateMergePatch (re-derive). JSON merge doesn't have
$setElementOrder semantics — but it does have its own quirks
around explicit-null values, which the second-pass round-trip
also smooths over.
8.6 The intstr.IntOrString json-iterator override
The init() block in patch/deletenull.go is the single largest
behavioural override the library imposes on its callers' process. It
registers a custom encoder for intstr.IntOrString:
- If
IntVal == 0andStrVal != "" && StrVal != "0"→ write the string. - If
IntVal == 0andStrVal == ""orStrVal == "0"→ writenull. - Otherwise → write the int.
The companion IsEmpty predicate returns true only for the second
case — so json-iterator omits the field entirely when serialising a
struct with omitempty.
The motivation: Kubernetes uses intstr.IntOrString for
targetPort, maxSurge, maxUnavailable, port, etc. Default
json-iterator (and stdlib encoding/json) marshal an unset
IntOrString as 0, which produces a false diff against a server
representation that often serialises it as null or omits it.
Forcing both sides to write null for the unset case eliminates
that whole category of spurious diff.
Important
Importing
github.com/cisco-open/k8s-objectmatcher/patchregisters this encoder process-wide. Any other code in the binary that uses json-iterator to marshal anIntOrStringwill see the same behaviour. This is almost always what you want for Kubernetes code, but be aware of it.
8.7 Testing strategy
| Layer | Files | Focus |
|---|---|---|
| Unit (annotation) | patch/annotation_test.go |
Empty-annotation edge case (verifies metadata.annotations is removed when its only entry was LastAppliedConfig) |
| Unit (patch) | patch/patch_test.go |
The unstructured JSON-merge code path against four canonical scenarios: non-existent field not deleted, existent field deleted, existent field updated, new field added |
| Integration | tests/integration_test.go, tests/main_test.go |
Real API server interactions (tests/ is a separate module) |
Run the production unit tests:
go test ./patch/...
ok github.com/cisco-open/k8s-objectmatcher/patch 0.123s
Run the integration suite (requires KUBECONFIG):
cd tests && go test ./...
8.8 Code conventions
From .golangci.yml:
- gocyclo
min-complexity: 15 - linters enabled:
misspell,gofmt,goimports - linters explicitly disabled:
golint,errcheck,gas,megacheck,varcheck,gochecknoglobals,govet(the last due to a known false positive about non-nil ≠ nil)
Errors are wrapped with emperror.dev/errors (not stdlib fmt.Errorf
with %w) to attach contextual key/value pairs via
errors.WrapWithDetails.
Every Go file carries the Apache-2.0 license header, with
Copyright © :YEAR: Banzai Cloud. The header template is enforced
by .licensei.toml.
8.9 Potential improvements
| Area | Today | Improvement |
|---|---|---|
| Nested-field ignore | Only top-level | Add IgnorePath("spec.replicas") (#57) |
| Pod label ignore | Manual via custom option | First-class IgnorePodLabel("key") (#65) |
| StatefulSet field exclusion | Cleaner pattern only | IgnoreStatefulSetFields(...) curated set (#40) |
$setElementOrder residuals |
Second-pass mostly catches them | Investigate the Volumes/VolumeMounts edge case (#9) |
IgnoreVolumeClaimTemplateTypeMetaAndStatus edge cases |
Reported broken in some scenarios (#51) | Add test cases for the failing inputs and harden the cleaner |
| Buffer pooling | None | Optional sync.Pool for the []byte and map[string]interface{} buffers in the hot path |
8.10 Contributing
Standard fork-and-PR flow against
github.com/cisco-open/k8s-objectmatcher. The repo enforces:
- Apache-2.0 license header on every Go file (template in
.licensei.toml). gofmt+goimportsclean.go test ./patch/...passes.- The integration suite in
tests/passes if your change touches anything that an API server interaction would observe.
Open issues are catalogued at github.com/cisco-open/k8s-objectmatcher/issues.
9. Integration Points
The library's only integration with the outside world is the calling controller. This section enumerates the integration surfaces and how each maps to a public API symbol.
9.1 Inbound integration: what callers depend on
| Caller need | API symbol |
|---|---|
| "Is there a real diff?" | (*PatchMaker).Calculate → (*PatchResult).IsEmpty |
| "What changed?" | (*PatchResult).Patch |
| "Stamp the desired state on a new object" | (*Annotator).SetLastAppliedAnnotation |
| "Use a custom annotation key" | NewAnnotator(key) |
| "Inject a fake patcher in tests" | NewPatchMaker(annotator, smp, jmp) and the Maker interface |
| "Add a precondition guard" | K8sStrategicMergePatcher.PreconditionFuncs |
| "Strip a top-level field" | IgnoreField(field) |
| "Strip status" | IgnoreStatusFields() |
| "Handle StatefulSet PVC templates" | IgnoreVolumeClaimTemplateTypeMetaAndStatus() |
| "Strip nulls / zero IntOrStrings outside Calculate" | DeleteNullInJson(jsonBytes) |
9.2 Outbound integration: what the library depends on
| Dependency | Use |
|---|---|
k8s.io/apimachinery/pkg/api/meta.MetadataAccessor |
Read/write annotations on any runtime.Object without type-asserting |
k8s.io/apimachinery/pkg/runtime.Object |
The single argument type that lets Calculate accept any Kubernetes object |
k8s.io/apimachinery/pkg/util/strategicpatch |
All three strategic-merge operations |
k8s.io/apimachinery/pkg/util/jsonmergepatch |
CreateThreeWayJSONMergePatch |
k8s.io/apimachinery/pkg/util/mergepatch |
The PreconditionFunc type used by K8sStrategicMergePatcher |
k8s.io/apimachinery/pkg/apis/meta/v1/unstructured |
Type assertion for the JSON-merge dispatch |
k8s.io/apimachinery/pkg/util/intstr |
IntOrString encoder override |
github.com/json-iterator/go |
All JSON marshal/unmarshal |
github.com/evanphx/json-patch |
MergePatch, CreateMergePatch for the unstructured path |
emperror.dev/errors |
Error wrapping with context |
9.3 Common integration patterns
| Pattern | Files / examples |
|---|---|
controller-runtime Reconcile |
See User Guide §5.5 |
| client-go shared informer | Same pattern — substitute informer.GetStore().Get for Get |
| Operator SDK | controller-runtime under the hood; works the same |
| Cross-cluster reconciler | Each cluster's Calculate runs locally on the watcher — the library doesn't care about cluster topology |
| Helm post-renderer style | Render desired YAML, parse to typed objects, call Calculate against the live cluster |
9.4 Compatibility matrix
| Component | Compatible versions |
|---|---|
| Go | 1.22+ (any newer Go works) |
k8s.io/apimachinery |
v0.19.16 (baseline in go.mod); MVS upgrades to whatever the calling controller needs (typically v0.28+ in current operators) |
controller-runtime |
Any version — the library doesn't import it |
| Kubernetes API server | 1.21+ (matches the test module's client-go v0.21.3 baseline; the strategic-merge metadata works across all currently supported versions) |
*unstructured.Unstructured |
Always supported — JSON-merge path |
Typed objects with patchStrategy struct tags |
Always supported — strategic-merge path |
| Typed CRD objects without strategic-merge tags | Use unstructured access; see §8.2 and User Guide §5.7 |
9.5 What the library does not integrate with
To set expectations and prevent looking-for-features-that-aren't-there:
| Not integrated | Why |
|---|---|
Server-side apply (metadata.managedFields) |
Different model — API server handles diff calculation server-side |
kubectl.kubernetes.io/last-applied-configuration |
Different annotation key; not interoperable with kubectl apply clients out of the box |
| Webhooks (admission, validation) | Outside the diff layer |
| Metrics (Prometheus / OpenTelemetry) | Caller's responsibility |
| Logging | Caller's responsibility — library returns errors, never logs |
| Tracing | Caller's responsibility |
| Feature flags | None defined |
Note
If you need interoperability with
kubectl apply'slast-applied-configurationannotation, you can swap the key withNewAnnotator("kubectl.kubernetes.io/last-applied-configuration"). The formatkubectlwrites is plain JSON (not zip+base64), and theAnnotatorread path falls back to plain JSON when base64 decode fails — so reading interoperates. Writing through this library will switch the annotation to zip+base64 on the next reconcile, whichkubectl apply --prunedoes not understand. Mixing the two is generally inadvisable.