diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml
index 385c5b67da..6264ba3aa2 100644
--- a/.github/workflows/integration-tests.yml
+++ b/.github/workflows/integration-tests.yml
@@ -27,7 +27,7 @@ jobs:
name: Integration tests (${{ inputs.java-version }}, ${{ inputs.kube-version }}, ${{ inputs.http-client }})
runs-on: ubuntu-latest
continue-on-error: ${{ inputs.experimental }}
- timeout-minutes: 40
+ timeout-minutes: 120
steps:
- uses: actions/checkout@v7
with:
diff --git a/README.md b/README.md
index be70d3c7d7..987750324a 100644
--- a/README.md
+++ b/README.md
@@ -57,7 +57,6 @@ For all features and their usage see the [related sections on the website](https
allows to create operators by simply applying a custom resource, thus, is a language independent way.
* Kubernetes Webhooks Framework: https://github.com/java-operator-sdk/kubernetes-webooks-framework
Framework to implement Admission Controllers and Conversion Hooks.
-* Operator SDK plugin: https://github.com/operator-framework/java-operator-plugins
## Projects using JOSDK
diff --git a/bootstrapper-maven-plugin/pom.xml b/bootstrapper-maven-plugin/pom.xml
index 938a92495e..9959c62396 100644
--- a/bootstrapper-maven-plugin/pom.xml
+++ b/bootstrapper-maven-plugin/pom.xml
@@ -22,7 +22,7 @@
io.javaoperatorsdkjava-operator-sdk
- 5.4.0-SNAPSHOT
+ 5.5.0-SNAPSHOTbootstrapper
@@ -32,7 +32,7 @@
3.15.2
- 3.9.15
+ 3.9.163.1.03.15.2
diff --git a/caffeine-bounded-cache-support/pom.xml b/caffeine-bounded-cache-support/pom.xml
index 0ead2b52fa..8fe7f736df 100644
--- a/caffeine-bounded-cache-support/pom.xml
+++ b/caffeine-bounded-cache-support/pom.xml
@@ -21,7 +21,7 @@
io.javaoperatorsdkjava-operator-sdk
- 5.4.0-SNAPSHOT
+ 5.5.0-SNAPSHOTcaffeine-bounded-cache-support
diff --git a/docs/content/en/blog/news/read-after-write-consistency.md b/docs/content/en/blog/news/read-after-write-consistency.md
index 29e3c5f626..5fc7042a09 100644
--- a/docs/content/en/blog/news/read-after-write-consistency.md
+++ b/docs/content/en/blog/news/read-after-write-consistency.md
@@ -30,8 +30,8 @@ public UpdateControl reconcile(WebPage webPage, Context contex
}
```
-In addition to that, the framework will automatically filter events for your own updates,
-so they don't trigger the reconciliation again.
+In addition to that, the framework will provide facilities to filter out
+events for own updates so they don't trigger the reconciliation again.
{{% alert color=success %}}
**This should significantly simplify controller development, and will make reconciliation
@@ -180,12 +180,6 @@ From this point the idea of the algorithm is very simple:
the one in the TRC. If yes, evict the resource from the TRC.
3. When the controller reads a resource from cache, it checks the TRC first, then falls back to the Informer's cache.
-The actual filtering of events for our own writes is more nuanced than a simple
-"evict on RV ≥ TRC version" rule — it is driven by a per-resource state machine
-that tracks in-flight writes and the events received around them. See
-[Filtering events for our own updates](#filtering-events-for-our-own-updates) below.
-
-
```mermaid
sequenceDiagram
box rgba(50,108,229,0.1)
@@ -226,10 +220,31 @@ sequenceDiagram
When we update a resource, the informer will eventually propagate an event that would trigger a reconciliation.
In most cases, however, this is not desirable. Since we already have the up-to-date resource at that point,
we want to be notified only when the change originates outside our reconciler.
-Therefore, in addition to caching the resource, we filter out events caused by our own updates.
+Therefore, in addition to caching the resource, provide utilities to so you can optimize the update/patch
+operations to filter out those events.
+
+See [ResourceOperations](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java#L49)
+for details.
+
+```java
+public UpdateControl reconcile(WebPage webPage, Context context) {
+
+ ConfigMap managedConfigMap = prepareConfigMap(webPage);
+
+ // resource operation in this case will resource only if
+ // it does not match the actual, and will filter our the related event
+ context.resourceOperations().serverSideApply(managedConfigMap);
+
+ // UpdateControl.patchStatus would only cache the resource to
+ // filter out events too you have to use resourceOperations.
+ context.resourceOperations().serverSideApplyPrimaryStatus(alterStatusObject(webPage));
+
+ return UpdateControl.noUpdate();
+}
+```
-Note that the implementation of this is relatively complex: while performing the update, we record all the
-events received in the meantime and decide whether to propagate them further once the update request completes.
+Note that the implementation of this is relatively complex and has some caveats:
+while performing the update, we record all the events received in the meantime and decide whether to propagate them further once the update request completes.
This way, we significantly reduce the number of reconciliations, making the whole process much more efficient.
diff --git a/docs/content/en/blog/releases/v5-4-release.md b/docs/content/en/blog/releases/v5-4-release.md
index 235da5160e..bacbe112ac 100644
--- a/docs/content/en/blog/releases/v5-4-release.md
+++ b/docs/content/en/blog/releases/v5-4-release.md
@@ -37,6 +37,10 @@ ControllerConfigurationOverrider.override(config)
`josdk.controller..shard-selector` configuration key. This feature relies on Fabric8 client
support and requires the 7.8.0 baseline shipped with this release.
+> **Note**: Shard selectors are an **alpha feature in Kubernetes 1.36** and are not available on a stock
+> cluster. They must be explicitly enabled on the API server (via the `ShardedListAndWatch` feature gate)
+> before they can be used.
+
### Opting Out of Default Event Filters
By default, JOSDK applies a set of internal update filters to a controller's own event source:
@@ -173,7 +177,7 @@ Mappers.fromOwnerReferences(MyPrimary.class, clusterScoped);
## All Changes
-See the [comparison view](https://github.com/operator-framework/java-operator-sdk/compare/v5.3.0...v5.4.0)
+See the [comparison view](https://github.com/operator-framework/java-operator-sdk/compare/v5.3.5...v5.4.0)
for the full list of changes.
## Feedback
diff --git a/docs/content/en/blog/releases/v5-5-release.md b/docs/content/en/blog/releases/v5-5-release.md
new file mode 100644
index 0000000000..0dcda16cb4
--- /dev/null
+++ b/docs/content/en/blog/releases/v5-5-release.md
@@ -0,0 +1,135 @@
+---
+title: Version 5.5 Released!
+date: 2026-07-17
+author: >-
+ [Attila Mészáros](https://github.com/csviri)
+---
+
+We're pleased to announce the release of Java Operator SDK v5.5.0! This minor version reworks how
+the framework filters the events caused by a controller's own writes, making it more correct and
+more efficient, and exposes a richer, matcher-aware `ResourceOperations` API. There are **no
+breaking API changes**, but there is one behavioral change around `UpdateControl` — see the
+migration notes.
+
+If you are running on version `5.3.x` or `5.4.x` upgrade is strongly recommend!
+
+## Key Features
+
+### Matcher-based updates in `ResourceOperations`
+
+`ResourceOperations` (available from the reconciliation `Context` via `context.resourceOperations()`)
+now offers a complete, consistent family of update/patch/create methods for every write strategy —
+**server-side apply, update (PUT), JSON Patch (RFC 6902) and JSON Merge Patch (RFC 7386)** — each
+available for the whole resource and the `status` subresource, and with dedicated `primary`
+variants.
+
+Every method comes in two flavors: a default one, and one taking an `Options` argument that controls
+how the resulting own event is handled:
+
+```java
+public UpdateControl reconcile(WebPage webPage, Context context) {
+ makeStatusChanges(webPage);
+ // updates the status, filters the own event, and skips the write entirely if nothing changed
+ context.resourceOperations().serverSideApplyPrimaryStatus(webPage);
+ return UpdateControl.noUpdate();
+}
+```
+
+By default these operations **match the desired state against the actual (cached) state before
+writing**: if they already match, the write is skipped; otherwise the write is performed and its own
+event is filtered. This is the most efficient option — it covers full event filtering *and* avoids a
+request to the Kubernetes API server when nothing changed. Default matchers are provided for every
+operation type; when one does not fit, supply your own via `Options.matchAndFilter(matcher)`.
+
+`Options` exposes the available strategies:
+
+- `matchAndFilter(...)` / `matchAndFilterWithDefaultMatcher(...)` — match, then write-and-filter only
+ if needed. This is the default for the server-side apply and patch methods.
+- `filterWithOptimisticLocking()` — filter the own event; the write must use optimistic locking,
+ otherwise an `IllegalArgumentException` is thrown. The matcher / `updateType` overloads also skip
+ the write when the desired state already matches; this match + optimistic locking combination is
+ the default for the PUT `update` methods.
+- `cacheOnly()` — only cache the response (read-cache-after-write consistency), no filtering.
+- `forceFilterEvents()` — always filter (mostly internal usage). Assumes resource was matched before
+ or update is done using optimistic locking.
+
+> **Correctness note**: filtering an own event is only safe if the framework can tell an own write
+> apart from a concurrent third-party write. This requires **either** a matcher **or** optimistic
+> locking; otherwise a concurrent external update inside the filter window may be missed until the
+> next resync.
+
+### More correct own-event filtering (no-op update edge case)
+
+The read-cache-after-write own-event filter has been reworked to fix an edge case where a legitimate
+external change could be filtered out together with a controller's own **no-op** update — for
+example, when the spec was changed externally while the controller patched its status and that status
+patch turned out to be a no-op. The new matcher-based operations avoid issuing such no-op writes in
+the first place, and the filtering itself is now correct in these concurrent scenarios.
+
+## Additional Improvements
+
+- **`GenericKubernetesResourceMatcher.matchStatus(...)`**: a status-only counterpart to `match(...)`
+ that compares just the `/status` subtree.
+- **New `Matcher` SPI**: `io.javaoperatorsdk.operator.api.reconciler.matcher.Matcher` lets you plug a
+ custom matching strategy into `Options.matchAndFilter(...)`.
+- **Quieter logs for reconciler-handled errors**: when `updateErrorStatus(...)` returns any
+ `ErrorStatusUpdateControl` other than `defaultErrorProcessing()`, the error is treated as handled
+ by the reconciler and logged at `DEBUG` instead of the `Uncaught error during event processing`
+ `WARN`. Native retry (including `@GradualRetry` exponential backoff) is unchanged, so a reconciler
+ can keep retrying a recoverable condition without spamming warnings. Return
+ `defaultErrorProcessing()` to keep the previous behavior.
+- **Startup-latency metric**: a new `Metrics.eventProcessingStarted(Controller)` callback fires when
+ a controller's event processor begins accepting events (including after a deferred start such as
+ winning leader election). The Micrometer implementations record a per-controller gauge with the JVM
+ uptime at that moment, making operator startup latency measurable.
+- Extensive integration tests covering every `ResourceOperations` update/patch operation (primary,
+ status, and secondary resources) against a real cluster.
+
+## Migration Notes
+
+There are **no breaking API changes**; existing code compiles and runs unchanged. However:
+
+### `UpdateControl` no longer filters own events by default
+
+Returning an `UpdateControl` (or `ErrorStatusUpdateControl`) still updates the resource and keeps the
+cache read-after-write consistent, but it **no longer filters the resulting own event by default** —
+so the write may cause an additional reconciliation (which should be idempotent). This change fixes correctness edge
+cases where an event that should have propagated was previously swallowed.
+
+If you relied on the previous filtering, perform the update through `ResourceOperations` and return
+`UpdateControl.noUpdate()`:
+
+```java
+// before (v5.4)
+resource.setStatus(new MyStatus().setReady(true));
+return UpdateControl.patchStatus(resource);
+
+// after (v5.5) — filter the own event explicitly
+resource.setStatus(new MyStatus().setReady(true));
+context.resourceOperations().serverSideApplyPrimaryStatus(resource);
+return UpdateControl.noUpdate();
+```
+
+See the [migration guide](/docs/migration/v5-5-migration) for details.
+
+## Getting Started
+
+```xml
+
+ io.javaoperatorsdk
+ operator-framework
+ 5.5.0
+
+```
+
+## All Changes
+
+See the [comparison view](https://github.com/operator-framework/java-operator-sdk/compare/v5.4.0...v5.5.0)
+for the full list of changes.
+
+## Feedback
+
+Please report issues or suggest improvements on our
+[GitHub repository](https://github.com/operator-framework/java-operator-sdk/issues).
+
+Happy operator building! 🚀
diff --git a/docs/content/en/docs/documentation/_index.md b/docs/content/en/docs/documentation/_index.md
index c0805dfa2f..d639b8b9f9 100644
--- a/docs/content/en/docs/documentation/_index.md
+++ b/docs/content/en/docs/documentation/_index.md
@@ -23,6 +23,7 @@ This section contains detailed documentation for all Java Operator SDK features
## Advanced Features
- **[Eventing](eventing/)** - Understanding the event-driven model
+- **[Event filters](event-filters/)** - Filtering events and the default filter behavior
- **[Accessing Resources in Caches](working-with-es-caches/)** - How to access resources in caches
- **[Operations](operations/)** - Helm chart, metrics, logging, configurations, leader election
- **[Other Features](features/)** - Additional capabilities and integrations
diff --git a/docs/content/en/docs/documentation/error-handling-retries.md b/docs/content/en/docs/documentation/error-handling-retries.md
index 7bd4ad2e22..428c35b9ae 100644
--- a/docs/content/en/docs/documentation/error-handling-retries.md
+++ b/docs/content/en/docs/documentation/error-handling-retries.md
@@ -108,6 +108,22 @@ Retry can be skipped in cases of unrecoverable errors:
ErrorStatusUpdateControl.patchStatus(customResource).withNoRetry();
```
+When `updateErrorStatus` returns any `ErrorStatusUpdateControl` other than
+`ErrorStatusUpdateControl.defaultErrorProcessing()`, the framework considers the error handled by
+the reconciler and, while retry attempts remain, no longer logs the "Uncaught error during event
+processing" warning; the error is logged on `DEBUG` level instead. This lets a reconciler keep
+retrying an expected, recoverable condition without producing a continuous stream of `WARN`
+messages, while it remains free to log the error at whatever level it deems appropriate inside
+`updateErrorStatus`.
+
+This log downgrade applies only when retry is actually taking place, i.e. a retry is configured and
+the returned control does not disable it. Controls that explicitly disable retry — `withNoRetry()`
+and `rescheduleAfter()` — cancel the native retry and its `@GradualRetry` backoff, so nothing is
+retried in those cases. Likewise, on the last retry attempt (or when no retry is configured) the
+failure is final, so the framework keeps its higher-severity logging to avoid hiding a
+non-recoverable error. Returning `ErrorStatusUpdateControl.defaultErrorProcessing()` preserves the
+default behavior, including the warning.
+
### Correctness and Automatic Retries
While it is possible to deactivate automatic retries, this is not desirable unless there is a particular reason.
diff --git a/docs/content/en/docs/documentation/event-filters.md b/docs/content/en/docs/documentation/event-filters.md
new file mode 100644
index 0000000000..661f3931f3
--- /dev/null
+++ b/docs/content/en/docs/documentation/event-filters.md
@@ -0,0 +1,170 @@
+---
+title: Event filters
+weight: 50
+---
+
+Every [event source](eventing.md) that watches Kubernetes resources is backed by an informer that
+receives *add*, *update* and *delete* events from the Kubernetes API. Not every one of those events
+is worth a reconciliation: a resource might be touched by another controller, its `.metadata` might
+change without any meaningful change to its `.spec`, or your own controller might have caused the
+change in the first place. **Event filters** let you decide, per event, whether it should be
+propagated and (potentially) trigger a reconciliation. Filtering events as early as possible keeps
+your operator efficient by avoiding needless reconciliations.
+
+Filters apply both to the **primary resource** (the resource your `Reconciler` manages) and to any
+**secondary resources** watched through an [`InformerEventSource`](eventing.md#informereventsource).
+
+## Filter types
+
+JOSDK defines four functional interfaces in the
+[`io.javaoperatorsdk.operator.processing.event.source.filter`](https://github.com/operator-framework/java-operator-sdk/tree/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/filter)
+package. Each returns `true` when the event should be **accepted** (propagated) and `false` when it
+should be **dropped**:
+
+| Filter | Applies to | Signature |
+| --- | --- | --- |
+| [`OnAddFilter`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/filter/OnAddFilter.java) | resource add events | `boolean accept(R resource)` |
+| [`OnUpdateFilter`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/filter/OnUpdateFilter.java) | resource update events | `boolean accept(R newResource, R oldResource)` |
+| [`OnDeleteFilter`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/filter/OnDeleteFilter.java) | resource delete events | `boolean accept(R resource, Boolean deletedFinalStateUnknown)` |
+| [`GenericFilter`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/filter/GenericFilter.java) | add, update **and** delete events | `boolean accept(R resource)` |
+
+`GenericFilter` is applied to every kind of event, so it is a convenient way to express a condition
+that is independent of the event type. When both a specific filter (e.g. `OnUpdateFilter`) and the
+`GenericFilter` are configured, **both** must accept the event for it to be propagated.
+
+All four interfaces are `@FunctionalInterface`s and provide `and(...)`, `or(...)` and `not()`
+default methods, so you can compose several conditions:
+
+```java
+OnUpdateFilter filter =
+ onUpdateFilterA.and(onUpdateFilterB).not();
+```
+
+## Configuring filters on the primary resource
+
+Filters for the primary resource are configured through the `@Informer` annotation nested in
+`@ControllerConfiguration`. You reference the filter *class* (which must have an accessible no-arg
+constructor):
+
+```java
+@ControllerConfiguration(informer = @Informer(onUpdateFilter = UpdateFilter.class))
+public class MyReconciler implements Reconciler {
+ // ...
+}
+```
+
+```java
+public class UpdateFilter implements OnUpdateFilter {
+ @Override
+ public boolean accept(MyCustomResource newResource, MyCustomResource oldResource) {
+ // reconcile only if the value actually changed
+ return !newResource.getSpec().getValue().equals(SKIP_VALUE);
+ }
+}
+```
+
+The `@Informer` annotation exposes `onAddFilter`, `onUpdateFilter`, `onDeleteFilter` and
+`genericFilter` attributes for the primary resource informer.
+
+## Configuring filters on secondary resources
+
+For secondary resources watched via an `InformerEventSource`, filters are set through the
+`InformerEventSourceConfiguration` builder, where you can pass filter instances directly (typically
+as lambdas):
+
+```java
+@Override
+public List> prepareEventSources(
+ EventSourceContext context) {
+
+ var informerConfiguration =
+ InformerEventSourceConfiguration.from(ConfigMap.class, MyCustomResource.class)
+ .withOnUpdateFilter(
+ (newCM, oldCM) -> !newCM.getData().get(VALUE_KEY).equals(SKIP_VALUE))
+ .withOnAddFilter(cm -> true)
+ .build();
+
+ return List.of(new InformerEventSource<>(informerConfiguration, context));
+}
+```
+
+The builder provides `withOnAddFilter`, `withOnUpdateFilter`, `withOnDeleteFilter` and
+`withGenericFilter`.
+
+{{% alert title="Selectors vs. filters" color="primary" %}}
+Filters run **client-side**, after the event has already been received from the API server. If you
+can express your condition as a label or field selector, prefer the `labelSelector` /
+`fieldSelector` attributes of `@Informer` (or the equivalent builder methods) instead: those are
+evaluated **server-side**, so filtered-out resources are never sent to the informer at all and are
+not even held in the cache. Use event filters for conditions that cannot be expressed as selectors,
+such as comparing the new and old versions of a resource.
+{{% /alert %}}
+
+## Default filters
+
+For the primary resource, JOSDK always applies a set of **internal update filters** on top of your
+own `onUpdateFilter`. These are what make sure your `Reconciler` is not triggered for updates that
+don't require action. An update event is accepted by the default filters if **any** of the following
+is true (see
+[`InternalEventFilters`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/InternalEventFilters.java)):
+
+- **Generation aware:** the resource's `.metadata.generation` increased. Kubernetes bumps the
+ generation whenever the `.spec` changes, so this drops events caused purely by `.metadata` or
+ `.status` changes. This part is only active when
+ [generation-aware event processing](eventing.md#generation-awareness-and-event-filtering) is
+ enabled (the default for resources that support it). For resources without a generation (e.g.
+ `Pod`), every update is accepted.
+- **Finalizer needed:** the finalizer JOSDK manages was just added or removed. This ensures the
+ reconciliation that adds the finalizer is not filtered out.
+- **Marked for deletion:** the resource just transitioned to being marked for deletion (a deletion
+ timestamp was set), so cleanup logic can run.
+
+### How default filters combine with your filter
+
+The default (internal) update filter is combined with your `onUpdateFilter` using a logical **AND**:
+an update event must be accepted by **both** your filter and the internal filter to be propagated.
+In other words, your filter can only make the operator *more* selective; it cannot force a
+reconciliation for an event that the default filters would otherwise drop.
+
+Note that default filters only concern **update** events on the **primary** resource. Add events are
+always processed regardless of the internal filters, delete events are only subject to your
+`genericFilter` (if any), and secondary resources have no internal default filters at all.
+
+### Disabling the default filters
+
+If the AND-composition described above is too restrictive — for example, you need full control over
+which updates trigger a reconciliation — you can turn the internal update filters off with the
+`defaultFilters` attribute:
+
+```java
+@ControllerConfiguration(
+ defaultFilters = false,
+ informer = @Informer(onUpdateFilter = MyUpdateFilter.class))
+public class MyReconciler implements Reconciler {
+ // ...
+}
+```
+
+With `defaultFilters = false`:
+
+- Your `onUpdateFilter` becomes the **sole** update filter and has full control over which update
+ events are propagated.
+- If you don't provide an `onUpdateFilter`, **all** update events are accepted.
+
+{{% alert title="Use with care" color="warning" %}}
+Disabling default filters removes the generation-aware, finalizer-needed and marked-for-deletion
+guarantees. If you still want part of that behavior, compose it explicitly using the static factory
+methods on
+[`InternalEventFilters`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/InternalEventFilters.java)
+inside your own filter.
+{{% /alert %}}
+
+## Non-informer based event sources
+
+The filters described above apply only to informer-backed event sources, since they operate on the
+*add*, *update* and *delete* events an informer receives from the Kubernetes API. Event sources that
+are not backed by an informer — such as [`PollingEventSource`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PollingEventSource.java)
+and [`PerResourcePollingEventSource`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PerResourcePollingEventSource.java)
+— have no filters. For those, it is up to your implementation to emit only the events that are
+actually relevant, so that reconciliations are not triggered needlessly.
+
diff --git a/docs/content/en/docs/documentation/reconciler.md b/docs/content/en/docs/documentation/reconciler.md
index f4e474da30..1fa183633f 100644
--- a/docs/content/en/docs/documentation/reconciler.md
+++ b/docs/content/en/docs/documentation/reconciler.md
@@ -192,6 +192,42 @@ supports stronger guarantees, both for primary and secondary resources. If this
they would otherwise look like own echoes, since the relist may have
hidden events.
+#### Requesting event filtering and its correctness requirements
+
+Own-event filtering is only safe if the framework can tell an own write apart from a concurrent
+third-party write. This requires **either**:
+
+- a *matcher* to be provided (so a filtered event can be confirmed to reflect the desired state we
+ just wrote), **or**
+- the update to be done using *optimistic locking* (a resource version set on the written resource),
+ so a conflicting concurrent change is rejected by the API server rather than silently swallowed.
+
+`ResourceOperations` methods accept an `Options` argument to select the behavior; each maps to a
+`Mode`:
+
+- `Options.matchAndFilter(matcher)` / `Options.matchAndFilterWithDefaultMatcher(updateType)` — compare
+ the desired state to the actual (cached) state; if they already match, skip the write entirely,
+ otherwise write and filter the own event. This is the **default** for the server-side apply and
+ patch (JSON Patch / JSON Merge Patch) methods, and generally the most efficient option: it filters
+ the own event *and* avoids a request to the API server when nothing changed. Default matchers are
+ provided for every operation type, but they are heuristics — a workflow relying on them should be
+ tested against the concrete resources it manages, or a custom matcher supplied.
+- `Options.filterWithOptimisticLocking()` — filter the own event; the write must use optimistic
+ locking (a resource version set on the written resource), otherwise an `IllegalArgumentException`
+ is thrown. Requiring optimistic locking guarantees a concurrent third-party change is rejected by
+ the API server rather than being silently filtered out. The overloads taking a matcher /
+ `updateType` additionally skip the write when the desired state already matches. This match +
+ optimistic locking combination is the **default** for the PUT `update` / `updatePrimary` /
+ `updatePrimaryStatus` methods (a full PUT should not clobber a concurrent change).
+- `Options.cacheOnly()` — only cache the response (read-cache-after-write consistency), no own-event
+ filtering.
+- `Options.forceFilterEvents()` — always filter, regardless of optimistic locking. Only safe when
+ correctness is otherwise guaranteed (mostly for internal usage); a concurrent external update in
+ the filter window may otherwise be missed until the next resync.
+
+See the [`ResourceOperations`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java)
+and `ResourceOperations.Options` documentation for details.
+
In order to benefit from these stronger guarantees, use [`ResourceOperations`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java)
from the context of the reconciliation:
@@ -208,20 +244,26 @@ public UpdateControl reconcile(WebPage webPage, Context contex
var upToDateResource = context.getSecondaryResource(ConfigMap.class);
makeStatusChanges(webPage);
-
- // built in update methods by default use this feature
+ // patches the status and caches the response (does not filter the own event by default, see below)
return UpdateControl.patchStatus(webPage);
}
```
-`UpdateControl` and `ErrorStatusUpdateControl` by default use this functionality, but you can also update your primary resource at any time during the reconciliation using `ResourceOperations`:
+{{% alert title="UpdateControl and event filtering" %}}
+Since v5.5, returning an `UpdateControl` (or `ErrorStatusUpdateControl`) updates the resource and
+keeps the cache read-after-write consistent, but by default it **no longer filters the own event** —
+the resulting update may an additional reconciliation (which should be idempotent). To also filter the own
+event, perform the update through `ResourceOperations` instead and return `UpdateControl.noUpdate()`.
+{{% /alert %}}
+
+You can update your primary resource at any time during the reconciliation using `ResourceOperations`:
```java
public UpdateControl reconcile(WebPage webPage, Context context) {
makeStatusChanges(webPage);
- // this is equivalent to UpdateControl.patchStatus(webpage)
+ // updates the status, filters the own event and skips the write if nothing changed
context.resourceOperations().serverSideApplyPrimaryStatus(webPage);
return UpdateControl.noUpdate();
}
@@ -231,14 +273,6 @@ If your reconciler is built around the assumption that new reconciliations would
`reschedule` method is provided on `UpdateControl` to immediately reschedule a new reconciliation, to mimic the previous
behavior.
-### Caveats
-
-- This feature is implemented on top of the Fabric8 client informers, using additional caches in `InformerEventSource`,
- so it is safe to use `context.getSecondaryResources(..)` or `InformerEventSource.get(ResourceID)`methods. Listing
- resources directly via `InformerEventSource.list(..)`, however, won't work since this method directly reads from the
- underlying informer cache, thus bypassing the additional caches that make the feature possible.
-
-
### Notes
- This [talk](https://www.youtube.com/watch?v=HrwHh5Yh6AM&t=1387s) mentions this feature.
- [Umbrella issue](https://github.com/operator-framework/java-operator-sdk/issues/2944) on GitHub.
diff --git a/docs/content/en/docs/migration/v5-5-migration.md b/docs/content/en/docs/migration/v5-5-migration.md
new file mode 100644
index 0000000000..0d9194e7e3
--- /dev/null
+++ b/docs/content/en/docs/migration/v5-5-migration.md
@@ -0,0 +1,69 @@
+---
+title: Migrating from v5.4 to v5.5
+description: Migrating from v5.4 to v5.5
+---
+
+## No breaking API changes
+
+v5.5 does **not** contain breaking API changes: existing code compiles and continues to work without
+modification. There is, however, one **behavioral** change around own-event filtering that you should
+be aware of (see below).
+
+## `UpdateControl` no longer filters own events by default
+
+In previous versions, updating the primary resource (or its status) by returning an `UpdateControl`
+from the reconciler would filter out the event caused by that own update, so the write did not
+trigger an additional reconciliation. Unfortunately, in some edge cases this could lead an
+incorrect behavior, thus filtering out events which should be propagated.
+
+More precisely in case where for example the spec part of the primary resource was updated, while
+controller patched the status, but that patch status was a no-op operation. Note that
+these no-op operations are causing issue, which should not be done in first place.
+From 5.5 we provide methods in `ResourceOperations` which allow only operations which are
+correct.
+
+From v5.5, `UpdateControl` **no longer filters these own events by default**. Returning an
+`UpdateControl` still updates the resource and keeps the cache read-after-write consistent, but the
+resulting update event is now delivered like any other event, which may cause an additional
+reconciliation.
+
+This is safe (reconciliations are expected to be idempotent), but if you relied on the previous
+filtering — for example to avoid an extra reconciliation after a status update — use
+[`ResourceOperations`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java)
+directly, which does filter own events.
+
+### Using `ResourceOperations` instead
+
+`ResourceOperations` is available from the reconciliation `Context` via
+`context.resourceOperations()`. Instead of returning an `UpdateControl`, perform the update through
+it and return `UpdateControl.noUpdate()`:
+
+```java
+// before (v5.4): the own status update event was filtered
+@Override
+public UpdateControl reconcile(MyResource resource, Context context) {
+ resource.setStatus(new MyStatus().setReady(true));
+ return UpdateControl.patchStatus(resource);
+}
+```
+
+```java
+// after (v5.5): filter the own event explicitly via ResourceOperations
+@Override
+public UpdateControl reconcile(MyResource resource, Context context) {
+ resource.setStatus(new MyStatus().setReady(true));
+ context.resourceOperations().serverSideApplyPrimaryStatus(resource);
+ return UpdateControl.noUpdate();
+}
+```
+
+`ResourceOperations` covers every update/patch strategy (server-side apply, update, JSON Patch, JSON
+Merge Patch) for both the whole resource and the status subresource, as well as their primary
+variants. By default these operations match the desired state against the actual (cached) state
+before writing and filter the own event, so they only issue a request to the Kubernetes API server
+when something actually changed.
+
+> **Note**: Safe own-event filtering requires either a matcher (used by default) or the update to be
+> done with optimistic locking. See the `ResourceOperations` and `ResourceOperations.Options`
+> documentation for the available modes, the correctness requirements, and the caveats of the
+> default matchers.
diff --git a/invite.ics b/invite.ics
index 0106e45e5a..5f790920af 100644
--- a/invite.ics
+++ b/invite.ics
@@ -1,48 +1,174 @@
BEGIN:VCALENDAR
-PRODID:-//Google Inc//Google Calendar 70.9054//EN
-VERSION:2.0
-CALSCALE:GREGORIAN
METHOD:REQUEST
+PRODID:-//Linux Foundation//Meeting Management
+VERSION:2.0
BEGIN:VTIMEZONE
-TZID:America/New_York
-X-LIC-LOCATION:America/New_York
+TZID:Europe/Prague
+LAST-MODIFIED:20221029T021029Z
+TZURL:http://tzurl.org/zoneinfo/Europe/Prague
+X-LIC-LOCATION:Europe/Prague
+X-PROLEPTIC-TZNAME:LMT
+BEGIN:STANDARD
+TZNAME:PMT
+TZOFFSETFROM:+005744
+TZOFFSETTO:+005744
+DTSTART:18500101T000000
+END:STANDARD
+BEGIN:STANDARD
+TZNAME:CET
+TZOFFSETFROM:+005744
+TZOFFSETTO:+0100
+DTSTART:18911001T000000
+END:STANDARD
+BEGIN:DAYLIGHT
+TZNAME:CEST
+TZOFFSETFROM:+0100
+TZOFFSETTO:+0200
+DTSTART:19160430T230000
+RDATE:19400401T020000
+RDATE:19430329T020000
+RDATE:19460506T020000
+RDATE:19490409T020000
+END:DAYLIGHT
+BEGIN:STANDARD
+TZNAME:CET
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0100
+DTSTART:19161001T010000
+RDATE:19421102T030000
+RDATE:19431004T030000
+RDATE:19441002T030000
+RDATE:19451001T030000
+END:STANDARD
+BEGIN:DAYLIGHT
+TZNAME:CEST
+TZOFFSETFROM:+0100
+TZOFFSETTO:+0200
+DTSTART:19170416T020000
+RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=3MO;UNTIL=19180415T010000Z
+END:DAYLIGHT
+BEGIN:STANDARD
+TZNAME:CET
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0100
+DTSTART:19170917T030000
+RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=3MO;UNTIL=19180916T010000Z
+END:STANDARD
+BEGIN:DAYLIGHT
+TZNAME:CEST
+TZOFFSETFROM:+0100
+TZOFFSETTO:+0200
+DTSTART:19440403T020000
+RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1MO;UNTIL=19450402T010000Z
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+TZNAME:CEST
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0200
+DTSTART:19450509T000000
+END:DAYLIGHT
+BEGIN:STANDARD
+TZNAME:CET
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0100
+DTSTART:19461006T030000
+RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19491002T010000Z
+END:STANDARD
+BEGIN:STANDARD
+TZNAME:GMT
+TZOFFSETFROM:+0100
+TZOFFSETTO:+0000
+DTSTART:19461201T030000
+END:STANDARD
+BEGIN:STANDARD
+TZNAME:CET
+TZOFFSETFROM:+0000
+TZOFFSETTO:+0100
+DTSTART:19470223T020000
+END:STANDARD
+BEGIN:DAYLIGHT
+TZNAME:CEST
+TZOFFSETFROM:+0100
+TZOFFSETTO:+0200
+DTSTART:19470420T020000
+RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=3SU;UNTIL=19480418T010000Z
+END:DAYLIGHT
+BEGIN:STANDARD
+TZNAME:CET
+TZOFFSETFROM:+0100
+TZOFFSETTO:+0100
+DTSTART:19790101T000000
+END:STANDARD
+BEGIN:DAYLIGHT
+TZNAME:CEST
+TZOFFSETFROM:+0100
+TZOFFSETTO:+0200
+DTSTART:19790401T020000
+RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU;UNTIL=19800406T010000Z
+END:DAYLIGHT
+BEGIN:STANDARD
+TZNAME:CET
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0100
+DTSTART:19790930T030000
+RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1SU;UNTIL=19950924T010000Z
+END:STANDARD
BEGIN:DAYLIGHT
-TZOFFSETFROM:-0500
-TZOFFSETTO:-0400
-TZNAME:EDT
-DTSTART:19700308T020000
-RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
+TZNAME:CEST
+TZOFFSETFROM:+0100
+TZOFFSETTO:+0200
+DTSTART:19810329T020000
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
BEGIN:STANDARD
-TZOFFSETFROM:-0400
-TZOFFSETTO:-0500
-TZNAME:EST
-DTSTART:19701101T020000
-RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
+TZNAME:CET
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0100
+DTSTART:19961027T030000
+RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
-DTSTART;TZID=America/New_York:20260202T080000
-DTEND;TZID=America/New_York:20260202T090000
-RRULE:FREQ=WEEKLY;WKST=SU;INTERVAL=2;BYDAY=MO
-DTSTAMP:20260205T122901Z
-ORGANIZER:mailto:csviri@gmail.com
-UID:0slja1g4au8gb1lfkfe0ladnll_R20241126T140000@google.com
-X-MICROSOFT-CDO-OWNERAPPTID:1947686914
-CREATED:20231002T122647Z
-DESCRIPTION:CNCF Operator SDK is inviting you to a scheduled community Zoom meeting. \n
- Zoom: https://zoom.us/j/8415370125 \n
- Password: 77777
-LAST-MODIFIED:20260205T122857Z
-LOCATION:
-SEQUENCE:5
-STATUS:CONFIRMED
+ATTENDEE;VALUE=TEXT:csviri@gmail.com
+CREATED:20260714T061738Z
+DESCRIPTION:\nYou have been invited to a recurring meeting for Operato
+ r Framework\n\n\n\nWays to join meeting:\n\n1. Join from PC\, Mac\, iP
+ ad\, or Android\n\nhttps://zoom-lfx.platform.linuxfoundation.org/meeti
+ ng/97644661130?password=f0aa104f-cab5-4bee-a375-947fe4539b2d\n\n2. Joi
+ n via audio\n\nOne tap mobile:\nUS (iOS): +12532158782\,\,97644661130#
+ \,\,\,\,*684093# or +13462487799\,\,97644661130#\,\,\,\,*684093#\nUS (
+ Android): +12532158782\;97644661130#\;684093# or +13462487799\;9764466
+ 1130#\;684093#\n\nOr dial:\nUS: +1 253 215 8782 or +1 346 248 7799 or
+ +1 669 900 6833 or +1 301 715 8592 or +1 312 626 6799 or +1 646 374 86
+ 56 or 877 369 0926 (Toll Free) or 855 880 1246 (Toll Free)\nCanada: +1
+ 647 374 4685 or +1 647 558 0588 or +1 778 907 2071 or +1 204 272 7920
+ or +1 438 809 7799 or +1 587 328 1099 or 855 703 8985 (Toll Free)\n\n
+ Meeting ID: 97644661130\n\nMeeting Passcode: 684093\n\n\nInternational
+ numbers: https://zoom.us/u/alwnPIaVT\n\nManage this meeting: https://
+ projectadmin.lfx.linuxfoundation.org/project/a092M00001IkYIdQAN/collab
+ oration/meetings?date=2026-07-20&manage=true&meetingId=97644661130\n
+DTEND;TZID=Europe/Prague:20260720T160000
+DTSTAMP:20260714T061738Z
+DTSTART;TZID=Europe/Prague:20260720T150000
+LAST-MODIFIED:20260714T061738Z
+LOCATION:https://zoom-lfx.platform.linuxfoundation.org/meeting/9764466
+ 1130?password=f0aa104f-cab5-4bee-a375-947fe4539b2d
+ORGANIZER;CN=Operator Framework:MAILTO:meetings@lfx.dev
+RRULE:FREQ=DAILY;INTERVAL=14;WKST=SU
+SEQUENCE:1
SUMMARY:Java Operator SDK Community Meeting
-TRANSP:OPAQUE
+TZID:Europe/Prague
+TZNAME:Europe/Prague
+UID:97644661130
+URL;VALUE=TEXT:https://zoom-lfx.platform.linuxfoundation.org/meeting/9
+ 7644661130?password=f0aa104f-cab5-4bee-a375-947fe4539b2d
+X-MEETING-ID:97644661130
+X-OCCURRENCE-ID:
+X-REGISTRANT-ID:0a3cb7f8-701b-4ef1-9f5f-f3cdfca73594
BEGIN:VALARM
ACTION:DISPLAY
-DESCRIPTION:This is an event reminder
-TRIGGER:-P0DT0H10M0S
+DESCRIPTION:Meeting reminder
+TRIGGER:-PT10M
END:VALARM
END:VEVENT
END:VCALENDAR
diff --git a/micrometer-support/pom.xml b/micrometer-support/pom.xml
index 05de6e8052..9de7303b24 100644
--- a/micrometer-support/pom.xml
+++ b/micrometer-support/pom.xml
@@ -21,7 +21,7 @@
io.javaoperatorsdkjava-operator-sdk
- 5.4.0-SNAPSHOT
+ 5.5.0-SNAPSHOTmicrometer-support
diff --git a/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java b/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java
index 60f58b411f..0c102e52c4 100644
--- a/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java
+++ b/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java
@@ -15,6 +15,7 @@
*/
package io.javaoperatorsdk.operator.monitoring.micrometer;
+import java.lang.management.ManagementFactory;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
@@ -54,6 +55,7 @@ public class MicrometerMetrics implements Metrics {
private static final String RECONCILIATIONS_STARTED = RECONCILIATIONS + "started";
private static final String RECONCILIATIONS_EXECUTIONS = PREFIX + RECONCILIATIONS + "executions.";
private static final String RECONCILIATIONS_QUEUE_SIZE = PREFIX + RECONCILIATIONS + "queue.size.";
+ private static final String PROCESSING_STARTED_LATENCY = PREFIX + "processing.started.latency.";
private static final String NAME = "name";
private static final String NAMESPACE = "namespace";
private static final String GROUP = "group";
@@ -147,6 +149,16 @@ public void controllerRegistered(Controller extends HasMetadata> controller) {
gauges.put(controllerQueueName, controllerQueueSize);
}
+ @Override
+ public void eventProcessingStarted(Controller extends HasMetadata> controller) {
+ final var configuration = controller.getConfiguration();
+ final var name = configuration.getName();
+ final var tags = new ArrayList(3);
+ addGVKTags(GroupVersionKind.gvkFor(configuration.getResourceClass()), tags, false);
+ registry.gauge(
+ PROCESSING_STARTED_LATENCY + name, tags, ManagementFactory.getRuntimeMXBean().getUptime());
+ }
+
@Override
public T timeControllerExecution(ControllerExecution execution) {
final var name = execution.controllerName();
diff --git a/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetricsV2.java b/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetricsV2.java
index deb6c98c9e..b342061720 100644
--- a/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetricsV2.java
+++ b/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetricsV2.java
@@ -15,6 +15,7 @@
*/
package io.javaoperatorsdk.operator.monitoring.micrometer;
+import java.lang.management.ManagementFactory;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
@@ -59,6 +60,7 @@ public class MicrometerMetricsV2 implements Metrics {
public static final String RECONCILIATIONS_EXECUTIONS_GAUGE = RECONCILIATIONS + "active";
public static final String RECONCILIATIONS_QUEUE_SIZE_GAUGE = RECONCILIATIONS + "queue";
public static final String NUMBER_OF_RESOURCE_GAUGE = "custom_resources";
+ public static final String PROCESSING_STARTED_LATENCY_GAUGE = "processing.started.latency";
public static final String RECONCILIATION_EXECUTION_DURATION =
RECONCILIATIONS + "execution.duration";
@@ -145,6 +147,15 @@ public void controllerRegistered(Controller extends HasMetadata> controller) {
executionTimers.put(name, timer);
}
+ @Override
+ public void eventProcessingStarted(Controller extends HasMetadata> controller) {
+ final var name = controller.getConfiguration().getName();
+ final var tags = new ArrayList();
+ addControllerNameTag(name, tags);
+ registry.gauge(
+ PROCESSING_STARTED_LATENCY_GAUGE, tags, ManagementFactory.getRuntimeMXBean().getUptime());
+ }
+
private String numberOfResourcesRefName(String name) {
return NUMBER_OF_RESOURCE_GAUGE + name;
}
diff --git a/migration/pom.xml b/migration/pom.xml
index 053ca36214..76a1c7902c 100644
--- a/migration/pom.xml
+++ b/migration/pom.xml
@@ -21,7 +21,7 @@
io.javaoperatorsdkjava-operator-sdk
- 5.4.0-SNAPSHOT
+ 5.5.0-SNAPSHOTmigration
@@ -29,7 +29,7 @@
OpenRewrite migration recipes for Java Operator SDK
- 8.86.0
+ 8.87.2
diff --git a/operator-framework-bom/pom.xml b/operator-framework-bom/pom.xml
index 604f664f6c..6e21c9c756 100644
--- a/operator-framework-bom/pom.xml
+++ b/operator-framework-bom/pom.xml
@@ -21,7 +21,7 @@
io.javaoperatorsdkoperator-framework-bom
- 5.4.0-SNAPSHOT
+ 5.5.0-SNAPSHOTpomOperator SDK - Bill of MaterialsJava SDK for implementing Kubernetes operators
diff --git a/operator-framework-core/pom.xml b/operator-framework-core/pom.xml
index c9be25c69d..9104c6893c 100644
--- a/operator-framework-core/pom.xml
+++ b/operator-framework-core/pom.xml
@@ -21,7 +21,7 @@
io.javaoperatorsdkjava-operator-sdk
- 5.4.0-SNAPSHOT
+ 5.5.0-SNAPSHOT../pom.xml
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetrics.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetrics.java
index 6ae3ebe65a..374f4a8a45 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetrics.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetrics.java
@@ -70,6 +70,11 @@ public void controllerRegistered(Controller extends HasMetadata> controller) {
metricsList.forEach(metrics -> metrics.controllerRegistered(controller));
}
+ @Override
+ public void eventProcessingStarted(Controller extends HasMetadata> controller) {
+ metricsList.forEach(metrics -> metrics.eventProcessingStarted(controller));
+ }
+
@Override
public void eventReceived(Event event, Map metadata) {
metricsList.forEach(metrics -> metrics.eventReceived(event, metadata));
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/Metrics.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/Metrics.java
index fbbb20d92d..67fda2eb12 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/Metrics.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/Metrics.java
@@ -41,6 +41,17 @@ public interface Metrics {
*/
default void controllerRegistered(Controller extends HasMetadata> controller) {}
+ /**
+ * Called when the event processor of the specified controller has started and begins processing
+ * events. Until this point, events received from the event sources are deferred rather than
+ * reconciled (see the {@code EventProcessor}); in leader-election setups this is only called once
+ * leadership has been acquired. This callback can be used to measure how long the operator takes
+ * to start accepting events.
+ *
+ * @param controller the controller whose event processor has started
+ */
+ default void eventProcessingStarted(Controller extends HasMetadata> controller) {}
+
/**
* Called when an event has been accepted by the SDK from an event source, which would potentially
* trigger the Reconciler.
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java
index b9ef475509..c4532aa284 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java
@@ -16,6 +16,7 @@
package io.javaoperatorsdk.operator.api.reconciler;
import java.lang.reflect.InvocationTargetException;
+import java.util.Optional;
import java.util.function.Predicate;
import java.util.function.UnaryOperator;
@@ -27,20 +28,87 @@
import io.fabric8.kubernetes.client.dsl.base.PatchContext;
import io.fabric8.kubernetes.client.dsl.base.PatchType;
import io.javaoperatorsdk.operator.OperatorException;
+import io.javaoperatorsdk.operator.api.reconciler.matcher.Matcher;
import io.javaoperatorsdk.operator.processing.event.ResourceID;
import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource;
import io.javaoperatorsdk.operator.processing.event.source.informer.ManagedInformerEventSource;
+import io.javaoperatorsdk.operator.processing.matcher.UpdateType;
+import static io.javaoperatorsdk.operator.api.reconciler.Experimental.API_MIGHT_CHANGE;
import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getUID;
import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getVersion;
/**
- * Provides useful operations to manipulate resources (server-side apply, patch, etc.) in an
- * idiomatic way, in particular to make sure that the latest version of the resource is present in
- * the caches for the next reconciliation. In other words, it provides read-cache-after-write
- * consistency.
+ * Provides various, useful operations to manipulate resources (server-side apply, patch, etc.) in
+ * an idiomatic ways. Provides improved update/patch/create operations to make sure that the latest
+ * version of the resource is present in the caches for the next reconciliation, and filter own
+ * update events. You can still use kubernetes client directly, these methods are however useful to
+ * achieve better efficiency.
*
- * @param
the resource type on which this object operates
+ *
Every update/patch/create method comes in two flavors: a default one, and one that takes an
+ * {@link Options} argument to control how the resulting own event is handled. The behavior is
+ * selected through {@link Mode}:
+ *
+ *
+ *
{@link Options#filterWithOptimisticLocking()} ({@link Mode#FILTER_WITH_OPTIMISTIC_LOCKING})
+ * - the own event is filtered; the write must use optimistic locking (i.e. a resource version
+ * is set on the resource being written), otherwise an {@link IllegalArgumentException} is
+ * thrown. Requiring optimistic locking guarantees that a concurrent third-party change is
+ * rejected by the API server rather than silently filtered out.
+ *
{@link Options#matchAndFilter(Matcher)} / {@link
+ * Options#matchAndFilterWithDefaultMatcher(io.javaoperatorsdk.operator.processing.matcher.UpdateType)}
+ * ({@link Mode#FILTER_IF_NOT_MATCHING}) - before writing, the desired state is compared to
+ * the actual (cached) state using the provided {@link Matcher}; if they already match the
+ * write is skipped, otherwise it is performed and the own event is filtered.
+ *
{@link Options#cacheOnly()} ({@link Mode#CACHE_ONLY}) - the response is only put into the
+ * cache (read-cache-after-write consistency) and no own-event filtering is done.
+ *
{@link Options#forceFilterEvents()} ({@link Mode#FORCE_FILTER}) - the own event is always
+ * filtered, regardless of optimistic locking. Use only when correctness is otherwise
+ * guaranteed (see the note below). This is mostly for internal usage, like in {@link
+ * io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource}
+ * where we match the resource explicitly before update.
+ *
+ *
+ *
Correctness of event filtering. Filtering an own update event is only safe if
+ * the framework can tell an own write apart from a concurrent third-party write. This requires
+ * either:
+ *
+ *
+ *
a {@link Matcher} to be provided (so a filtered event can be confirmed to match the desired
+ * state we just wrote), or
+ *
the update to be done using optimistic locking (a resource version set on the
+ * written resource), so a conflicting concurrent change is rejected by the API server rather
+ * than silently swallowed.
+ *
+ *
+ *
If neither holds - for example {@link Options#forceFilterEvents()} on a write without
+ * optimistic locking and without a matcher - a concurrent external update happening within the
+ * filtering window may be filtered out and thus missed until the next resync. Prefer providing a
+ * matcher or using optimistic locking whenever own-event filtering is desired.
+ *
+ *
Default matchers. For the matching modes a default {@link Matcher} is
+ * provided for every {@link io.javaoperatorsdk.operator.processing.matcher.UpdateType} (see {@link
+ * Options#matchAndFilterWithDefaultMatcher(io.javaoperatorsdk.operator.processing.matcher.UpdateType)}),
+ * so matching works out of the box. Note however that these default matchers are heuristics and may
+ * have issues in some edge cases, so a workflow relying on them should be tested against the
+ * concrete resources it manages. When a default matcher does not fit, provide your own via {@link
+ * Options#matchAndFilter(Matcher)}.
+ *
+ *
Despite that caveat, matching is generally the most efficient way to handle updates: it covers
+ * full event filtering and only performs a write when the actual state actually differs
+ * from the desired one, thus also reducing the number of requests made against the Kubernetes API
+ * server.
+ *
+ *
Matching against cached resources. When matching the actual resource (in most
+ * cases read from the cache) against the desired one for secondary resources, the matching must be
+ * done on a cloned version of the actual resource. Secondary resources are not cloned by
+ * default when read from the cache (see {@link
+ * io.javaoperatorsdk.operator.api.config.ConfigurationService#cloneSecondaryResourcesWhenGettingFromCache()}),
+ * so mutating the resource obtained from the cache while matching would also mutate the copy stored
+ * in the cache. Either clone the resource explicitly before matching, or enable {@link
+ * io.javaoperatorsdk.operator.api.config.ConfigurationService#cloneSecondaryResourcesWhenGettingFromCache()}.
+ *
+ * @param
the primary resource type on which this object operates
*/
public class ResourceOperations
{
@@ -55,19 +123,34 @@ public ResourceOperations(Context
context) {
}
/**
- * Updates the resource and caches the response if needed, thus making sure that next
- * reconciliation will see to updated resource - or more recent one if additional update happened
- * after this update; In addition to that it filters out the event from the update, so
- * reconciliation is not triggered by own update.
+ * Server-Side Applies the resource, then caches the response so the next reconciliation observes
+ * it (read-cache-after-write consistency).
*
- *
You are free to control the optimistic locking by setting the resource version in resource
- * metadata. In case of SSA we advise not to do updates with optimistic locking.
+ *
Uses the {@link UpdateType#SSA} default {@link Matcher}: the apply is skipped when the
+ * actual (cached) state already matches the desired one, otherwise it is performed and the
+ * resulting own event is filtered so it does not trigger a reconciliation. Use {@link
+ * #serverSideApply( HasMetadata, Options)} to change this behavior. SSA does not require
+ * optimistic locking.
*
- * @param resource fresh resource for server side apply
- * @return updated resource
- * @param resource type
+ * @param resource the desired resource to server-side apply
+ * @param the resource type
+ * @return the applied resource as returned by the API server
*/
public R serverSideApply(R resource) {
+ return serverSideApply(resource, Options.matchAndFilterWithDefaultMatcher(UpdateType.SSA));
+ }
+
+ /**
+ * Server-Side Applies the resource, controlling caching and own-event handling through the given
+ * {@link Options}.
+ *
+ * @param resource the desired resource to server-side apply
+ * @param options controls caching and own-event filtering; see {@link Options} and the class
+ * documentation
+ * @param the resource type
+ * @return the applied resource as returned by the API server
+ */
+ public R serverSideApply(R resource, Options options) {
return resourcePatch(
resource,
r ->
@@ -79,25 +162,39 @@ public R serverSideApply(R resource) {
.withForce(true)
.withFieldManager(context.getControllerConfiguration().fieldManager())
.withPatchType(PatchType.SERVER_SIDE_APPLY)
- .build()));
+ .build()),
+ options);
}
/**
- * Updates the resource and caches the response if needed, thus making sure that next
- * reconciliation will see to updated resource - or more recent one if additional update happened
- * after this update; In addition to that it filters out the event from the update, so
- * reconciliation is not triggered by own update.
- *
- *
You are free to control the optimistic locking by setting the resource version in resource
- * metadata. In case of SSA we advise not to do updates with optimistic locking.
+ * Server-Side Applies the resource, caching and filtering the own event through the given {@link
+ * InformerEventSource} (instead of the controller's own event source). Uses the {@link
+ * UpdateType#SSA} default {@link Matcher}.
*
- * @param resource fresh resource for server side apply
- * @return updated resource
- * @param informerEventSource InformerEventSource to use for resource caching and filtering
- * @param resource type
+ * @param resource the desired resource to server-side apply
+ * @param informerEventSource the event source used for caching and own-event filtering
+ * @param the resource type
+ * @return the applied resource as returned by the API server
*/
public R serverSideApply(
R resource, InformerEventSource informerEventSource) {
+ return serverSideApply(
+ resource, informerEventSource, Options.matchAndFilterWithDefaultMatcher(UpdateType.SSA));
+ }
+
+ /**
+ * Server-Side Applies the resource, caching and filtering the own event through the given {@link
+ * InformerEventSource} and using the given {@link Options}. When {@code informerEventSource} is
+ * {@code null} this falls back to {@link #serverSideApply(HasMetadata)}.
+ *
+ * @param resource the desired resource to server-side apply
+ * @param informerEventSource the event source used for caching and own-event filtering
+ * @param options controls caching and own-event filtering; see {@link Options}
+ * @param the resource type
+ * @return the applied resource as returned by the API server
+ */
+ public R serverSideApply(
+ R resource, InformerEventSource informerEventSource, Options options) {
if (informerEventSource == null) {
return serverSideApply(resource);
}
@@ -113,25 +210,20 @@ public R serverSideApply(
.withFieldManager(context.getControllerConfiguration().fieldManager())
.withPatchType(PatchType.SERVER_SIDE_APPLY)
.build()),
- informerEventSource);
+ informerEventSource,
+ options);
}
/**
- * Server-Side Apply the resource status subresource.
- *
- *
Updates the resource and caches the response if needed, thus making sure that next
- * reconciliation will see to updated resource - or more recent one if additional update happened
- * after this update; In addition to that it filters out the event from this update, so
- * reconciliation is not triggered by own update.
- *
- *
You are free to control the optimistic locking by setting the resource version in resource
- * metadata. In case of SSA we advise not to do updates with optimistic locking.
+ * Server-Side Applies the {@code status} subresource, controlling caching and own-event handling
+ * through the given {@link Options}.
*
- * @param resource fresh resource for server side apply
- * @return updated resource
- * @param resource type
+ * @param resource the desired resource (with the status to apply)
+ * @param options controls caching and own-event filtering; see {@link Options}
+ * @param the resource type
+ * @return the applied resource as returned by the API server
*/
- public R serverSideApplyStatus(R resource) {
+ public R serverSideApplyStatus(R resource, Options options) {
return resourcePatch(
resource,
r ->
@@ -144,24 +236,44 @@ public R serverSideApplyStatus(R resource) {
.withForce(true)
.withFieldManager(context.getControllerConfiguration().fieldManager())
.withPatchType(PatchType.SERVER_SIDE_APPLY)
- .build()));
+ .build()),
+ options);
}
/**
- * Server-Side Apply the primary resource.
+ * Server-Side Applies the {@code status} subresource, caching the response and filtering the own
+ * event. Uses the {@link UpdateType#SSA_STATUS} default {@link Matcher}.
*
- *
Updates the resource and caches the response if needed, thus making sure that next
- * reconciliation will see to updated resource - or more recent one if additional update happened
- * after this update; In addition to that it filters out the event from this update, so
- * reconciliation is not triggered by own update.
- *
- *
You are free to control the optimistic locking by setting the resource version in resource
- * metadata. In case of SSA we advise not to do updates with optimistic locking.
+ * @param resource the desired resource (with the status to apply)
+ * @param the resource type
+ * @return the applied resource as returned by the API server
+ */
+ public R serverSideApplyStatus(R resource) {
+ return serverSideApplyStatus(
+ resource, Options.matchAndFilterWithDefaultMatcher(UpdateType.SSA_STATUS));
+ }
+
+ /**
+ * Server-Side Applies the primary resource, caching and filtering the own event through the
+ * controller's own event source. Uses the {@link UpdateType#SSA} default {@link Matcher}.
*
- * @param resource primary resource for server side apply
- * @return updated resource
+ * @param resource the desired primary resource to server-side apply
+ * @return the applied resource as returned by the API server
*/
public P serverSideApplyPrimary(P resource) {
+ return serverSideApplyPrimary(
+ resource, Options.matchAndFilterWithDefaultMatcher(UpdateType.SSA));
+ }
+
+ /**
+ * Server-Side Applies the primary resource, caching and filtering the own event through the
+ * controller's own event source and using the given {@link Options}.
+ *
+ * @param resource the desired primary resource to server-side apply
+ * @param options controls caching and own-event filtering; see {@link Options}
+ * @return the applied resource as returned by the API server
+ */
+ public P serverSideApplyPrimary(P resource, Options options) {
return resourcePatch(
resource,
r ->
@@ -174,26 +286,34 @@ public P serverSideApplyPrimary(P resource) {
.withFieldManager(context.getControllerConfiguration().fieldManager())
.withPatchType(PatchType.SERVER_SIDE_APPLY)
.build()),
- context.eventSourceRetriever().getControllerEventSource());
+ context.eventSourceRetriever().getControllerEventSource(),
+ options);
}
/**
- * Server-Side Apply the primary resource status subresource.
+ * Server-Side Applies the primary resource {@code status} subresource, caching and filtering the
+ * own event through the controller's own event source. Uses the {@link UpdateType#SSA_STATUS}
+ * default {@link Matcher}.
*
- *
Updates the resource and caches the response if needed, thus making sure that next
- * reconciliation will see to updated resource - or more recent one if additional update happened
- * after this update; In addition to that it filters out the event from this update, so
- * reconciliation is not triggered by own update.
- *
- *
You are free to control the optimistic locking by setting the resource version in resource
- * metadata. In case of SSA we advise not to do updates with optimistic locking.
+ * @param desired the desired primary resource (with the status to apply)
+ * @return the applied resource as returned by the API server
+ */
+ public P serverSideApplyPrimaryStatus(P desired) {
+ return serverSideApplyPrimaryStatus(
+ desired, Options.matchAndFilterWithDefaultMatcher(UpdateType.SSA_STATUS));
+ }
+
+ /**
+ * Server-Side Applies the primary resource {@code status} subresource, caching and filtering the
+ * own event through the controller's own event source and using the given {@link Options}.
*
- * @param resource primary resource for server side apply
- * @return updated resource
+ * @param desired the desired primary resource (with the status to apply)
+ * @param options controls caching and own-event filtering; see {@link Options}
+ * @return the applied resource as returned by the API server
*/
- public P serverSideApplyPrimaryStatus(P resource) {
+ public P serverSideApplyPrimaryStatus(P desired, Options options) {
return resourcePatch(
- resource,
+ desired,
r ->
context
.getClient()
@@ -205,346 +325,598 @@ public P serverSideApplyPrimaryStatus(P resource) {
.withFieldManager(context.getControllerConfiguration().fieldManager())
.withPatchType(PatchType.SERVER_SIDE_APPLY)
.build()),
- context.eventSourceRetriever().getControllerEventSource());
+ context.eventSourceRetriever().getControllerEventSource(),
+ options);
}
/**
- * Updates the resource and caches the response if needed, thus making sure that next
- * reconciliation will see to updated resource - or more recent one if additional update happened
- * after this update; In addition to that it filters out the event from this update, so
- * reconciliation is not triggered by own update.
+ * Updates (HTTP PUT) the resource, then caches the response so the next reconciliation observes
+ * it (read-cache-after-write consistency).
*
- *
You are free to control the optimistic locking by setting the resource version in resource
- * metadata.
+ *
Uses the {@link UpdateType#UPDATE} default {@link Matcher}: the update is skipped when the
+ * actual (cached) state already matches the desired one, otherwise it is performed using
+ * optimistic locking (the resource version must be set, otherwise an {@link
+ * IllegalArgumentException} is thrown) and the resulting own event is filtered. Use {@link
+ * #update(HasMetadata, Options)} to change this.
*
- * @param resource resource to update
- * @return updated resource
- * @param resource type
+ * @param resource the desired resource to update
+ * @param the resource type
+ * @return the updated resource as returned by the API server
*/
public R update(R resource) {
- return resourcePatch(resource, r -> context.getClient().resource(r).update());
+ return update(resource, Options.filterWithOptimisticLocking(UpdateType.UPDATE));
}
/**
- * Updates the resource and caches the response if needed, thus making sure that next
- * reconciliation will see to updated resource - or more recent one if additional update happened
- * after this update; In addition to that it filters out the event from this update, so
- * reconciliation is not triggered by own update.
+ * Updates (HTTP PUT) the resource, controlling caching and own-event handling through the given
+ * {@link Options}.
*
- *
You are free to control the optimistic locking by setting the resource version in resource
- * metadata.
+ * @param desired the desired resource to update
+ * @param options controls caching and own-event filtering; see {@link Options}
+ * @param the resource type
+ * @return the updated resource as returned by the API server
+ */
+ public R update(R desired, Options options) {
+ return resourcePatch(desired, r -> context.getClient().resource(r).update(), options);
+ }
+
+ /**
+ * Updates (HTTP PUT) the resource, caching and filtering the own event through the given {@link
+ * InformerEventSource}. Uses the {@link UpdateType#UPDATE} default {@link Matcher} and optimistic
+ * locking: the update is skipped when the actual state already matches, otherwise it is performed
+ * with optimistic locking (the resource version must be set) and the own event is filtered.
*
- * @param resource resource to update
- * @return updated resource
- * @param informerEventSource InformerEventSource to use for resource caching and filtering
- * @param resource type
+ * @param resource the desired resource to update
+ * @param informerEventSource the event source used for caching and own-event filtering
+ * @param the resource type
+ * @return the updated resource as returned by the API server
*/
public R update(
R resource, InformerEventSource informerEventSource) {
+ return update(
+ resource, informerEventSource, Options.filterWithOptimisticLocking(UpdateType.UPDATE));
+ }
+
+ /**
+ * Updates (HTTP PUT) the resource, caching and filtering the own event through the given {@link
+ * InformerEventSource} and using the given {@link Options}. When {@code informerEventSource} is
+ * {@code null} this falls back to {@link #update(HasMetadata)}.
+ *
+ * @param resource the desired resource to update
+ * @param informerEventSource the event source used for caching and own-event filtering
+ * @param options controls caching and own-event filtering; see {@link Options}
+ * @param the resource type
+ * @return the updated resource as returned by the API server
+ */
+ public R update(
+ R resource, InformerEventSource informerEventSource, Options options) {
if (informerEventSource == null) {
return update(resource);
}
return resourcePatch(
- resource, r -> context.getClient().resource(r).update(), informerEventSource);
+ resource, r -> context.getClient().resource(r).update(), informerEventSource, options);
}
/**
- * Creates the resource and caches the response if needed, thus making sure that next
- * reconciliation will see to updated resource - or more recent one if additional update happened
- * after this update; In addition to that it filters out the event from this update, so
- * reconciliation is not triggered by own update.
+ * Creates the resource, then caches the response so the next reconciliation observes it
+ * (read-cache-after-write consistency). The resulting own event is filtered.
*
- *
You are free to control the optimistic locking by setting the resource version in resource
- * metadata.
- *
- * @param resource resource to update
- * @return updated resource
- * @param resource type
+ * @param resource the resource to create
+ * @param the resource type
+ * @return the created resource as returned by the API server
*/
public R create(R resource) {
- return resourcePatch(resource, r -> context.getClient().resource(r).create());
+ // it is safe to do event filtering for create since check if the resource already exists.
+ return create(resource, Options.forceFilterEvents());
}
/**
- * Creates the resource and caches the response if needed, thus making sure that next
- * reconciliation will see to updated resource - or more recent one if additional update happened
- * after this update; In addition to that it filters out the event from this update, so
- * reconciliation is not triggered by own update.
+ * Creates the resource, controlling caching and own-event handling through the given {@link
+ * Options}. A matcher is not supported for create (there is nothing to match against) and passing
+ * one throws {@link IllegalArgumentException}.
*
- *
You are free to control the optimistic locking by setting the resource version in resource
- * metadata.
+ * @param resource the resource to create
+ * @param options controls caching and own-event filtering; see {@link Options}
+ * @param the resource type
+ * @return the created resource as returned by the API server
+ * @throws IllegalArgumentException if the options carry a {@link Matcher}
+ */
+ public R create(R resource, Options options) {
+ if (options.getMatcher().isPresent()) {
+ throw new IllegalArgumentException(
+ "Create operation does not support matcher. There is nothing to match.");
+ }
+ return resourcePatch(resource, r -> context.getClient().resource(r).create(), options);
+ }
+
+ /**
+ * Creates the resource, caching and filtering the own event through the given {@link
+ * InformerEventSource} and using the given {@link Options}. When {@code informerEventSource} is
+ * {@code null} this falls back to {@link #create(HasMetadata)}.
*
- * @param resource resource to update
- * @return updated resource
- * @param informerEventSource InformerEventSource to use for resource caching and filtering
- * @param resource type
+ * @param resource the resource to create
+ * @param informerEventSource the event source used for caching and own-event filtering
+ * @param options controls caching and own-event filtering; see {@link Options}
+ * @param the resource type
+ * @return the created resource as returned by the API server
*/
public R create(
- R resource, InformerEventSource informerEventSource) {
+ R resource, InformerEventSource informerEventSource, Options options) {
if (informerEventSource == null) {
return create(resource);
}
+ // it is safe to do event filtering for create since check if the resource already exists.
return resourcePatch(
- resource, r -> context.getClient().resource(r).create(), informerEventSource);
+ resource, r -> context.getClient().resource(r).create(), informerEventSource, options);
}
/**
- * Updates the resource status subresource.
+ * Updates (HTTP PUT) the resource {@code status} subresource, caching the response and filtering
+ * the own event. Uses the {@link UpdateType#UPDATE_STATUS} default {@link Matcher} and optimistic
+ * locking: the update is skipped when the actual status already matches, otherwise it is
+ * performed with optimistic locking (the resource version must be set, otherwise an {@link
+ * IllegalArgumentException} is thrown) and the own event is filtered.
*
- *
Updates the resource and caches the response if needed, thus making sure that next
- * reconciliation will see to updated resource - or more recent one if additional update happened
- * after this update; In addition to that it filters out the event from this update, so
- * reconciliation is not triggered by own update.
- *
- *
You are free to control the optimistic locking by setting the resource version in resource
- * metadata.
- *
- * @param resource resource to update
- * @return updated resource
- * @param resource type
+ * @param resource the desired resource (with the status to update)
+ * @param the resource type
+ * @return the updated resource as returned by the API server
*/
public R updateStatus(R resource) {
- return resourcePatch(resource, r -> context.getClient().resource(r).updateStatus());
+ return updateStatus(resource, Options.filterWithOptimisticLocking(UpdateType.UPDATE_STATUS));
}
/**
- * Updates the primary resource.
+ * Updates (HTTP PUT) the resource {@code status} subresource, controlling caching and own-event
+ * handling through the given {@link Options}.
*
- *
Updates the resource and caches the response if needed, thus making sure that next
- * reconciliation will see to updated resource - or more recent one if additional update happened
- * after this update; In addition to that it filters out the event from this update, so
- * reconciliation is not triggered by own update.
+ * @param resource the desired resource (with the status to update)
+ * @param options controls caching and own-event filtering; see {@link Options}
+ * @param the resource type
+ * @return the updated resource as returned by the API server
+ */
+ public R updateStatus(R resource, Options options) {
+ return resourcePatch(resource, r -> context.getClient().resource(r).updateStatus(), options);
+ }
+
+ /**
+ * Updates (HTTP PUT) the primary resource, caching and filtering the own event through the
+ * controller's own event source. Uses the {@link UpdateType#UPDATE} default {@link Matcher} and
+ * optimistic locking: the update is skipped when the actual state already matches, otherwise it
+ * is performed with optimistic locking (the resource version must be set) and the own event is
+ * filtered.
*
- *
You are free to control the optimistic locking by setting the resource version in resource
- * metadata.
+ * @param desired the desired primary resource to update
+ * @return the updated resource as returned by the API server
+ */
+ public P updatePrimary(P desired) {
+ return updatePrimary(desired, Options.filterWithOptimisticLocking(UpdateType.UPDATE));
+ }
+
+ /**
+ * Updates (HTTP PUT) the primary resource, caching and filtering the own event through the
+ * controller's own event source and using the given {@link Options}.
*
- * @param resource primary resource to update
- * @return updated resource
+ * @param desired the desired primary resource to update
+ * @param options controls caching and own-event filtering; see {@link Options}
+ * @return the updated resource as returned by the API server
*/
- public P updatePrimary(P resource) {
+ public P updatePrimary(P desired, Options options) {
return resourcePatch(
- resource,
+ desired,
r -> context.getClient().resource(r).update(),
- context.eventSourceRetriever().getControllerEventSource());
+ context.eventSourceRetriever().getControllerEventSource(),
+ options);
}
/**
- * Updates the primary resource status subresource.
- *
- *
Updates the resource and caches the response if needed, thus making sure that next
- * reconciliation will see to updated resource - or more recent one if additional update happened
- * after this update; In addition to that it filters out the event from this update, so
- * reconciliation is not triggered by own update.
+ * Updates (HTTP PUT) the primary resource {@code status} subresource, caching and filtering the
+ * own event through the controller's own event source. Uses the {@link UpdateType#UPDATE_STATUS}
+ * default {@link Matcher} and optimistic locking: the update is skipped when the actual status
+ * already matches, otherwise it is performed with optimistic locking (the resource version must
+ * be set) and the own event is filtered.
*
- *
You are free to control the optimistic locking by setting the resource version in resource
- * metadata.
- *
- * @param resource primary resource to update
- * @return updated resource
+ * @param desired the desired primary resource (with the status to update)
+ * @return the updated resource as returned by the API server
*/
- public P updatePrimaryStatus(P resource) {
+ public P updatePrimaryStatus(P desired) {
return resourcePatch(
- resource,
+ desired,
r -> context.getClient().resource(r).updateStatus(),
- context.eventSourceRetriever().getControllerEventSource());
+ context.eventSourceRetriever().getControllerEventSource(),
+ Options.filterWithOptimisticLocking(UpdateType.UPDATE_STATUS));
}
/**
- * Applies a JSON Patch to the resource. The unaryOperator function is used to modify the
- * resource, and the differences are sent as a JSON Patch to the Kubernetes API server.
- *
- *
Updates the resource and caches the response if needed, thus making sure that next
- * reconciliation will see to updated resource - or more recent one if additional update happened
- * after this update; In addition to that it filters out the event from this update, so
- * reconciliation is not triggered by own update.
+ * Applies a JSON Patch (RFC 6902) to the resource: the {@code unaryOperator} mutates the resource
+ * and the diff is sent as a patch. Caches the response and filters the own event using the {@link
+ * UpdateType#JSON_PATCH} default {@link Matcher}. Use {@link #jsonPatch(HasMetadata,
+ * UnaryOperator, Options)} to change this behavior.
*
- *
You are free to control the optimistic locking by setting the resource version in resource
- * metadata.
- *
- * @param resource resource to patch
- * @param unaryOperator function to modify the resource
- * @return updated resource
- * @param resource type
+ * @param actualResource the current resource used as the base of the patch
+ * @param unaryOperator function that mutates the resource into its desired state
+ * @param the resource type
+ * @return the patched resource as returned by the API server
*/
- public R jsonPatch(R resource, UnaryOperator unaryOperator) {
- return resourcePatch(resource, r -> context.getClient().resource(r).edit(unaryOperator));
+ public R jsonPatch(R actualResource, UnaryOperator unaryOperator) {
+ return jsonPatch(
+ actualResource,
+ unaryOperator,
+ Options.matchAndFilterWithDefaultMatcher(UpdateType.JSON_PATCH));
}
/**
- * Applies a JSON Patch to the resource status subresource. The unaryOperator function is used to
- * modify the resource status, and the differences are sent as a JSON Patch.
- *
- *
Updates the resource and caches the response if needed, thus making sure that next
- * reconciliation will see to updated resource - or more recent one if additional update happened
- * after this update; In addition to that it filters out the event from this update, so
- * reconciliation is not triggered by own update.
+ * Applies a JSON Patch (RFC 6902) to the resource, controlling caching and own-event handling
+ * through the given {@link Options}.
*
- *
You are free to control the optimistic locking by setting the resource version in resource
- * metadata.
- *
- * @param resource resource to patch
- * @param unaryOperator function to modify the resource
- * @return updated resource
- * @param resource type
+ * @param actualResource the current resource used as the base of the patch
+ * @param unaryOperator function that mutates the resource into its desired state
+ * @param options controls caching and own-event filtering; see {@link Options}
+ * @param the resource type
+ * @return the patched resource as returned by the API server
*/
- public R jsonPatchStatus(R resource, UnaryOperator unaryOperator) {
- return resourcePatch(resource, r -> context.getClient().resource(r).editStatus(unaryOperator));
+ public R jsonPatch(
+ R actualResource, UnaryOperator unaryOperator, Options options) {
+ R desired = desiredForJsonPatch(actualResource, unaryOperator, options);
+ return resourcePatch(
+ desired,
+ actualResource,
+ r -> context.getClient().resource(actualResource).edit(rr -> desired),
+ options);
}
/**
- * Applies a JSON Patch to the primary resource.
+ * Applies a JSON Patch (RFC 6902) to the resource, caching and filtering the own event through
+ * the given {@link InformerEventSource} and using the given {@link Options}.
*
- *
Updates the resource and caches the response if needed, thus making sure that next
- * reconciliation will see to updated resource - or more recent one if additional update happened
- * after this update; In addition to that it filters out the event from this update, so
- * reconciliation is not triggered by own update.
+ * @param actualResource the current resource used as the base of the patch
+ * @param unaryOperator function that mutates the resource into its desired state
+ * @param informerEventSource the event source used for caching and own-event filtering
+ * @param options controls caching and own-event filtering; see {@link Options}
+ * @param the resource type
+ * @return the patched resource as returned by the API server
+ */
+ public R jsonPatch(
+ R actualResource,
+ UnaryOperator unaryOperator,
+ InformerEventSource informerEventSource,
+ Options options) {
+ R desired = desiredForJsonPatch(actualResource, unaryOperator, options);
+ return resourcePatch(
+ desired,
+ actualResource,
+ r -> context.getClient().resource(actualResource).edit(rr -> desired),
+ informerEventSource,
+ options);
+ }
+
+ /**
+ * Applies a JSON Patch (RFC 6902) to the resource {@code status} subresource: the {@code
+ * unaryOperator} mutates the resource status and the diff is sent as a patch. Caches the response
+ * and filters the own event using the {@link UpdateType#JSON_PATCH_STATUS} default {@link
+ * Matcher}.
*
- *
You are free to control the optimistic locking by setting the resource version in resource
- * metadata.
+ * @param actualResource the current resource used as the base of the patch
+ * @param unaryOperator function that mutates the resource status into its desired state
+ * @param the resource type
+ * @return the patched resource as returned by the API server
+ */
+ public R jsonPatchStatus(
+ R actualResource, UnaryOperator unaryOperator) {
+ return jsonPatchStatus(
+ actualResource,
+ unaryOperator,
+ Options.matchAndFilterWithDefaultMatcher(UpdateType.JSON_PATCH_STATUS));
+ }
+
+ /**
+ * Applies a JSON Patch (RFC 6902) to the resource {@code status} subresource, controlling caching
+ * and own-event handling through the given {@link Options}.
*
- * @param resource primary resource to patch
- * @param unaryOperator function to modify the resource
- * @return updated resource
+ * @param actualResource the current resource used as the base of the patch
+ * @param unaryOperator function that mutates the resource status into its desired state
+ * @param options controls caching and own-event filtering; see {@link Options}
+ * @param the resource type
+ * @return the patched resource as returned by the API server
*/
- public P jsonPatchPrimary(P resource, UnaryOperator
unaryOperator) {
+ public R jsonPatchStatus(
+ R actualResource, UnaryOperator unaryOperator, Options options) {
+ R desired = desiredForJsonPatch(actualResource, unaryOperator, options);
return resourcePatch(
- resource,
- r -> context.getClient().resource(r).edit(unaryOperator),
- context.eventSourceRetriever().getControllerEventSource());
+ desired,
+ actualResource,
+ r -> context.getClient().resource(actualResource).editStatus(rr -> desired),
+ options);
}
/**
- * Applies a JSON Patch to the primary resource status subresource.
+ * Applies a JSON Patch (RFC 6902) to the resource {@code status} subresource, caching and
+ * filtering the own event through the given {@link InformerEventSource} and using the given
+ * {@link Options}.
*
- *
Updates the resource and caches the response if needed, thus making sure that next
- * reconciliation will see to updated resource - or more recent one if additional update happened
- * after this update; In addition to that it filters out the event from this update, so
- * reconciliation is not triggered by own update.
+ * @param actualResource the current resource used as the base of the patch
+ * @param unaryOperator function that mutates the resource status into its desired state
+ * @param informerEventSource the event source used for caching and own-event filtering
+ * @param options controls caching and own-event filtering; see {@link Options}
+ * @param the resource type
+ * @return the patched resource as returned by the API server
+ */
+ public R jsonPatchStatus(
+ R actualResource,
+ UnaryOperator unaryOperator,
+ InformerEventSource informerEventSource,
+ Options options) {
+ R desired = desiredForJsonPatch(actualResource, unaryOperator, options);
+ return resourcePatch(
+ desired,
+ actualResource,
+ r -> context.getClient().resource(actualResource).editStatus(rr -> desired),
+ informerEventSource,
+ options);
+ }
+
+ /**
+ * Applies a JSON Patch (RFC 6902) to the primary resource, caching and filtering the own event
+ * through the controller's own event source. Uses the {@link UpdateType#JSON_PATCH} default
+ * {@link Matcher}.
*
- *
You are free to control the optimistic locking by setting the resource version in resource
- * metadata.
+ * @param actualResource the current primary resource used as the base of the patch
+ * @param unaryOperator function that mutates the resource into its desired state
+ * @return the patched resource as returned by the API server
+ */
+ public P jsonPatchPrimary(P actualResource, UnaryOperator
unaryOperator) {
+ return jsonPatchPrimary(
+ actualResource,
+ unaryOperator,
+ Options.matchAndFilterWithDefaultMatcher(UpdateType.JSON_PATCH));
+ }
+
+ /**
+ * Applies a JSON Patch (RFC 6902) to the primary resource, caching and filtering the own event
+ * through the controller's own event source and using the given {@link Options}.
*
- * @param resource primary resource to patch
- * @param unaryOperator function to modify the resource
- * @return updated resource
+ * @param actualResource the current primary resource used as the base of the patch
+ * @param unaryOperator function that mutates the resource into its desired state
+ * @param options controls caching and own-event filtering; see {@link Options}
+ * @return the patched resource as returned by the API server
*/
- public P jsonPatchPrimaryStatus(P resource, UnaryOperator
unaryOperator) {
+ public P jsonPatchPrimary(P actualResource, UnaryOperator
unaryOperator, Options options) {
+ P desired = desiredForJsonPatch(actualResource, unaryOperator, options);
return resourcePatch(
- resource,
- r -> context.getClient().resource(r).editStatus(unaryOperator),
- context.eventSourceRetriever().getControllerEventSource());
+ desired,
+ actualResource,
+ r -> context.getClient().resource(actualResource).edit(rr -> desired),
+ context.eventSourceRetriever().getControllerEventSource(),
+ options);
}
/**
- * Applies a JSON Merge Patch to the resource. JSON Merge Patch (RFC 7386) is a simpler patching
- * strategy that merges the provided resource with the existing resource on the server.
+ * Applies a JSON Patch (RFC 6902) to the primary resource {@code status} subresource, caching and
+ * filtering the own event through the controller's own event source. Uses the {@link
+ * UpdateType#JSON_PATCH_STATUS} default {@link Matcher}.
*
- *
Updates the resource and caches the response if needed, thus making sure that next
- * reconciliation will see to updated resource - or more recent one if additional update happened
- * after this update; In addition to that it filters out the event from this update, so
- * reconciliation is not triggered by own update.
+ * @param actualResource the current primary resource used as the base of the patch
+ * @param unaryOperator function that mutates the resource status into its desired state
+ * @return the patched resource as returned by the API server
+ */
+ public P jsonPatchPrimaryStatus(P actualResource, UnaryOperator
unaryOperator) {
+ return jsonPatchPrimaryStatus(
+ actualResource,
+ unaryOperator,
+ Options.matchAndFilterWithDefaultMatcher(UpdateType.JSON_PATCH_STATUS));
+ }
+
+ /**
+ * Applies a JSON Patch (RFC 6902) to the primary resource {@code status} subresource, caching and
+ * filtering the own event through the controller's own event source and using the given {@link
+ * Options}.
*
- *
You are free to control the optimistic locking by setting the resource version in resource
- * metadata.
+ * @param actualResource the current primary resource used as the base of the patch
+ * @param unaryOperator function that mutates the resource status into its desired state
+ * @param options controls caching and own-event filtering; see {@link Options}
+ * @return the patched resource as returned by the API server
+ */
+ public P jsonPatchPrimaryStatus(
+ P actualResource, UnaryOperator
unaryOperator, Options options) {
+ P desired = desiredForJsonPatch(actualResource, unaryOperator, options);
+ return resourcePatch(
+ desired,
+ actualResource,
+ r -> context.getClient().resource(actualResource).status().edit(rr -> desired),
+ context.eventSourceRetriever().getControllerEventSource(),
+ options);
+ }
+
+ /**
+ * Applies a JSON Merge Patch (RFC 7386) to the resource, merging the provided resource with the
+ * one on the server. Caches the response and filters the own event using the {@link
+ * UpdateType#JSON_MERGE_PATCH} default {@link Matcher}. Use {@link #jsonMergePatch(HasMetadata,
+ * Options)} to change this behavior.
*
- * @param resource resource to patch
- * @return updated resource
- * @param resource type
+ * @param desired the desired resource to merge-patch
+ * @param the resource type
+ * @return the patched resource as returned by the API server
*/
- public R jsonMergePatch(R resource) {
- return resourcePatch(resource, r -> context.getClient().resource(r).patch());
+ public R jsonMergePatch(R desired) {
+ return jsonMergePatch(
+ desired, Options.matchAndFilterWithDefaultMatcher(UpdateType.JSON_MERGE_PATCH));
}
/**
- * Applies a JSON Merge Patch to the resource status subresource.
+ * Applies a JSON Merge Patch (RFC 7386) to the resource, controlling caching and own-event
+ * handling through the given {@link Options}.
*
- *
Updates the resource and caches the response if needed, thus making sure that next
- * reconciliation will see to updated resource - or more recent one if additional update happened
- * after this update; In addition to that it filters out the event from this update, so
- * reconciliation is not triggered by own update.
+ * @param desired the desired resource to merge-patch
+ * @param options controls caching and own-event filtering; see {@link Options}
+ * @param the resource type
+ * @return the patched resource as returned by the API server
+ */
+ public R jsonMergePatch(R desired, Options options) {
+ return resourcePatch(desired, r -> context.getClient().resource(r).patch(), options);
+ }
+
+ /**
+ * Applies a JSON Merge Patch (RFC 7386) to the resource, caching and filtering the own event
+ * through the given {@link InformerEventSource} and using the given {@link Options}.
*
- *
You are free to control the optimistic locking by setting the resource version in resource
- * metadata.
+ * @param desired the desired resource to merge-patch
+ * @param informerEventSource the event source used for caching and own-event filtering
+ * @param options controls caching and own-event filtering; see {@link Options}
+ * @param the resource type
+ * @return the patched resource as returned by the API server
+ */
+ public R jsonMergePatch(
+ R desired, InformerEventSource informerEventSource, Options options) {
+ return resourcePatch(
+ desired, r -> context.getClient().resource(r).patch(), informerEventSource, options);
+ }
+
+ /**
+ * Applies a JSON Merge Patch (RFC 7386) to the resource {@code status} subresource, caching the
+ * response and filtering the own event. Uses the {@link UpdateType#JSON_MERGE_PATCH_STATUS}
+ * default {@link Matcher}.
*
- * @param resource resource to patch
- * @return updated resource
- * @param resource type
+ * @param resource the desired resource (with the status to merge-patch)
+ * @param the resource type
+ * @return the patched resource as returned by the API server
*/
public R jsonMergePatchStatus(R resource) {
- return resourcePatch(resource, r -> context.getClient().resource(r).patchStatus());
+ return jsonMergePatchStatus(
+ resource, Options.matchAndFilterWithDefaultMatcher(UpdateType.JSON_MERGE_PATCH_STATUS));
}
/**
- * Applies a JSON Merge Patch to the primary resource. Caches the response using the controller's
- * event source.
+ * Applies a JSON Merge Patch (RFC 7386) to the resource {@code status} subresource, controlling
+ * caching and own-event handling through the given {@link Options}.
*
- *
Updates the resource and caches the response if needed, thus making sure that next
- * reconciliation will see to updated resource - or more recent one if additional update happened
- * after this update; In addition to that it filters out the event from this update, so
- * reconciliation is not triggered by own update.
+ * @param resource the desired resource (with the status to merge-patch)
+ * @param options controls caching and own-event filtering; see {@link Options}
+ * @param the resource type
+ * @return the patched resource as returned by the API server
+ */
+ public R jsonMergePatchStatus(R resource, Options options) {
+ return resourcePatch(resource, r -> context.getClient().resource(r).patchStatus(), options);
+ }
+
+ /**
+ * Applies a JSON Merge Patch (RFC 7386) to the resource {@code status} subresource, caching and
+ * filtering the own event through the given {@link InformerEventSource} and using the given
+ * {@link Options}.
*
- *
You are free to control the optimistic locking by setting the resource version in resource
- * metadata.
+ * @param resource the desired resource (with the status to merge-patch)
+ * @param informerEventSource the event source used for caching and own-event filtering
+ * @param options controls caching and own-event filtering; see {@link Options}
+ * @param the resource type
+ * @return the patched resource as returned by the API server
+ */
+ public R jsonMergePatchStatus(
+ R resource, InformerEventSource informerEventSource, Options options) {
+ return resourcePatch(
+ resource, r -> context.getClient().resource(r).patchStatus(), informerEventSource, options);
+ }
+
+ /**
+ * Applies a JSON Merge Patch (RFC 7386) to the primary resource, caching and filtering the own
+ * event through the controller's own event source. Uses the {@link UpdateType#JSON_MERGE_PATCH}
+ * default {@link Matcher}.
*
- * @param resource primary resource to patch reconciliation
- * @return updated resource
+ * @param resource the desired primary resource to merge-patch
+ * @return the patched resource as returned by the API server
*/
public P jsonMergePatchPrimary(P resource) {
+ return jsonMergePatchPrimary(
+ resource, Options.matchAndFilterWithDefaultMatcher(UpdateType.JSON_MERGE_PATCH));
+ }
+
+ /**
+ * Applies a JSON Merge Patch (RFC 7386) to the primary resource, caching and filtering the own
+ * event through the controller's own event source and using the given {@link Options}.
+ *
+ * @param resource the desired primary resource to merge-patch
+ * @param options controls caching and own-event filtering; see {@link Options}
+ * @return the patched resource as returned by the API server
+ */
+ public P jsonMergePatchPrimary(P resource, Options options) {
return resourcePatch(
resource,
r -> context.getClient().resource(r).patch(),
- context.eventSourceRetriever().getControllerEventSource());
+ context.eventSourceRetriever().getControllerEventSource(),
+ options);
}
/**
- * Applies a JSON Merge Patch to the primary resource.
- *
- *
Updates the resource and caches the response if needed, thus making sure that next
- * reconciliation will see to updated resource - or more recent one if additional update happened
- * after this update; In addition to that it filters out the event from this update, so
- * reconciliation is not triggered by own update.
- *
- *
You are free to control the optimistic locking by setting the resource version in resource
- * metadata.
+ * Applies a JSON Merge Patch (RFC 7386) to the primary resource {@code status} subresource,
+ * caching and filtering the own event through the controller's own event source. Uses the {@link
+ * UpdateType#JSON_MERGE_PATCH_STATUS} default {@link Matcher}.
*
- * @param resource primary resource to patch
- * @return updated resource
- * @see #jsonMergePatchPrimaryStatus(HasMetadata)
+ * @param resource the desired primary resource (with the status to merge-patch)
+ * @return the patched resource as returned by the API server
*/
public P jsonMergePatchPrimaryStatus(P resource) {
+ return jsonMergePatchPrimaryStatus(
+ resource, Options.matchAndFilterWithDefaultMatcher(UpdateType.JSON_MERGE_PATCH_STATUS));
+ }
+
+ /**
+ * Applies a JSON Merge Patch (RFC 7386) to the primary resource {@code status} subresource,
+ * caching and filtering the own event through the controller's own event source and using the
+ * given {@link Options}.
+ *
+ * @param resource the desired primary resource (with the status to merge-patch)
+ * @param options controls caching and own-event filtering; see {@link Options}
+ * @return the patched resource as returned by the API server
+ */
+ public P jsonMergePatchPrimaryStatus(P resource, Options options) {
return resourcePatch(
resource,
r -> context.getClient().resource(r).patchStatus(),
- context.eventSourceRetriever().getControllerEventSource());
+ context.eventSourceRetriever().getControllerEventSource(),
+ options);
}
/**
- * Utility method to patch a resource and cache the result. Automatically discovers the event
- * source for the resource type and delegates to {@link #resourcePatch(HasMetadata, UnaryOperator,
- * ManagedInformerEventSource)}.
+ * Low-level building block behind all the update/patch operations above: runs the given {@code
+ * updateOperation} against the API server, then caches the response and (depending on the {@link
+ * Options}) filters the resulting own event. The target event source is resolved automatically
+ * from the desired resource's type. Uses {@link Options#filterWithOptimisticLocking()}.
*
- * @param resource resource to patch
- * @param updateOperation operation to perform (update, patch, edit, etc.)
- * @return updated resource
- * @param resource type
- * @throws IllegalStateException if no event source or multiple event sources are found
+ * @param resource the desired resource being written
+ * @param updateOperation the actual write to perform (update, patch, edit, ...) returning the
+ * server response
+ * @param the resource type
+ * @return the resource as returned by {@code updateOperation}
+ * @throws IllegalStateException if no (or no matching) event source is found for the resource
+ * type
*/
- @SuppressWarnings({"rawtypes", "unchecked"})
public R resourcePatch(R resource, UnaryOperator updateOperation) {
+ return resourcePatch(resource, updateOperation, Options.filterWithOptimisticLocking());
+ }
- var esList = context.eventSourceRetriever().getEventSourcesFor(resource.getClass());
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ private R resourcePatch(
+ R desired, UnaryOperator updateOperation, Options options) {
+ return resourcePatch(desired, null, updateOperation, options);
+ }
+
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ private R resourcePatch(
+ R desired, R actual, UnaryOperator updateOperation, Options options) {
+
+ Class> targetClass = desired == null ? actual.getClass() : desired.getClass();
+
+ var esList = context.eventSourceRetriever().getEventSourcesFor(targetClass);
if (esList.isEmpty()) {
- throw new IllegalStateException("No event source found for type: " + resource.getClass());
+ throw new IllegalStateException("No event source found for type: " + targetClass);
}
var es = esList.get(0);
if (esList.size() > 1) {
log.warn(
"Multiple event sources found for type: {}, selecting first with name {}",
- resource.getClass(),
+ targetClass,
es.name());
}
if (es instanceof ManagedInformerEventSource mes) {
- return resourcePatch(resource, updateOperation, (ManagedInformerEventSource) mes);
+ return resourcePatch(
+ desired, actual, updateOperation, (ManagedInformerEventSource) mes, options);
} else {
throw new IllegalStateException(
"Target event source must be a subclass off "
@@ -553,19 +925,96 @@ public R resourcePatch(R resource, UnaryOperator upda
}
/**
- * Utility method to patch a resource and cache the result using the specified event source. This
- * method either filters out the resulting event or allows it to trigger reconciliation based on
- * the filterEvent parameter.
+ * This method is public to ensure backward compatibility, we will make it private in next major
+ * release.
*
- * @param resource resource to patch
- * @param updateOperation operation to perform (update, patch, edit, etc.)
- * @param ies the managed informer event source to use for caching
- * @return updated resource
- * @param resource type
+ * @deprecated use one of the higher-level update/patch methods, or {@link #resourcePatch(
+ * HasMetadata, HasMetadata, UnaryOperator, ManagedInformerEventSource, Options)}
*/
+ @Deprecated(forRemoval = true)
public R resourcePatch(
- R resource, UnaryOperator updateOperation, ManagedInformerEventSource ies) {
- return ies.eventFilteringUpdateAndCacheResource(resource, updateOperation);
+ R desired, UnaryOperator updateOperation, ManagedInformerEventSource ies) {
+ return resourcePatch(desired, updateOperation, ies, Options.filterWithOptimisticLocking());
+ }
+
+ private R resourcePatch(
+ R desired,
+ UnaryOperator updateOperation,
+ ManagedInformerEventSource ies,
+ Options options) {
+
+ return resourcePatch(desired, null, updateOperation, ies, options);
+ }
+
+ /**
+ * The core update/patch routine used by all operations. Optionally matches the desired against
+ * the actual (cached) state, performs the write through {@code updateOperation}, and caches the
+ * response while filtering the own event according to the {@link Options}/{@link Mode}.
+ *
+ *
When a {@link Matcher} is present but {@code actualResource} is {@code null}, the actual
+ * state is looked up from the given event source's cache. If the states already match, the write
+ * is skipped and the actual resource is returned unchanged.
+ *
+ * @param desiredResource the desired resource; may be {@code null} for JSON Patch operations,
+ * where {@code actualResource} is used as the base instead
+ * @param actualResource the actual (current) resource used for matching; may be {@code null}
+ * @param updateOperation the actual write to perform, returning the server response
+ * @param ies the event source used for caching and own-event filtering
+ * @param options controls matching, caching and own-event filtering; see {@link Options}
+ * @param the resource type
+ * @return the resource as returned by {@code updateOperation}, or {@code actualResource} when the
+ * desired state already matches
+ * @throws IllegalArgumentException if the mode requires a matcher but none is provided, or if the
+ * mode is {@link Mode#FILTER_WITH_OPTIMISTIC_LOCKING} but the resource being written has no
+ * resource version set
+ */
+ // visible for testing
+ R resourcePatch(
+ R desiredResource,
+ R actualResource,
+ UnaryOperator updateOperation,
+ ManagedInformerEventSource ies,
+ Options options) {
+
+ var matcher = options.getMatcher().orElse(null);
+ boolean matches = false;
+ if (matcher != null) {
+ if (actualResource == null) {
+ actualResource = ies.get(ResourceID.fromResource(desiredResource)).orElse(null);
+ }
+ if (actualResource != null) {
+ matches = matcher.matches(desiredResource, actualResource, context);
+ }
+ }
+ if (options.requiresMatcher() && matcher == null) {
+ throw new IllegalArgumentException("Mode : " + options.mode + " requires matcher");
+ }
+ // this is to cover special case for jsonPatch were we should use actual resource as base
+ if (matches) {
+ if (log.isDebugEnabled()) {
+ log.debug(
+ "Resource match resource id: {}, type: {}, version: {}",
+ ResourceID.fromResource(desiredResource),
+ desiredResource.getClass().getSimpleName(),
+ desiredResource.getMetadata().getResourceVersion());
+ }
+ return actualResource;
+ }
+
+ if (options.getMode() == Mode.FILTER_WITH_OPTIMISTIC_LOCKING
+ && desiredResource.getMetadata().getResourceVersion() == null) {
+ throw new IllegalArgumentException(
+ "Mode "
+ + Mode.FILTER_WITH_OPTIMISTIC_LOCKING
+ + " requires optimistic locking, but no resource version is set on the resource: "
+ + ResourceID.fromResource(desiredResource));
+ }
+
+ if (options.getMode() == Mode.CACHE_ONLY) {
+ return ies.updateAndCacheResource(desiredResource, updateOperation);
+ } else {
+ return ies.eventFilteringUpdateAndCacheResource(desiredResource, updateOperation);
+ }
}
/**
@@ -578,7 +1027,33 @@ public R resourcePatch(
* @see #addFinalizer(String)
*/
public P addFinalizer() {
- return addFinalizer(context.getControllerConfiguration().getFinalizerName());
+ return addFinalizer(context.getControllerConfiguration().getFinalizerName(), false);
+ }
+
+ /**
+ * Adds the default finalizer (from controller configuration) to the primary resource. This is a
+ * convenience method that calls {@link #addFinalizer(String, boolean)} with the configured
+ * finalizer name.
+ *
+ * @param cacheOnly if {@code true} the finalizer patch does not filter its own event (see {@link
+ * #addFinalizer(String, boolean)})
+ * @return updated resource from the server response
+ * @see #addFinalizer(String, boolean)
+ */
+ public P addFinalizer(boolean cacheOnly) {
+ return addFinalizer(context.getControllerConfiguration().getFinalizerName(), cacheOnly);
+ }
+
+ /**
+ * Adds the given finalizer to the primary resource, filtering the own event (equivalent to {@link
+ * #addFinalizer(String, boolean)} with {@code cacheOnly = false}).
+ *
+ * @param finalizerName name of the finalizer to add
+ * @return updated resource from the server response
+ * @see #addFinalizer(String, boolean)
+ */
+ public P addFinalizer(String finalizerName) {
+ return addFinalizer(finalizerName, false);
}
/**
@@ -587,9 +1062,14 @@ public P addFinalizer() {
* marked for deletion. Note that explicitly adding/removing finalizer is required only if
* "Trigger reconciliation on all event" mode is on.
*
+ * @param finalizerName name of the finalizer to add
+ * @param cacheOnly if {@code true} the finalizer patch only caches the response and does
+ * not filter the resulting own event, so a subsequent reconciliation is triggered by
+ * the finalizer addition; if {@code false} the default JSON Patch matcher is used and the own
+ * event is filtered
* @return updated resource from the server response
*/
- public P addFinalizer(String finalizerName) {
+ public P addFinalizer(String finalizerName, boolean cacheOnly) {
var resource = context.getPrimaryResource();
if (resource.isMarkedForDeletion() || resource.hasFinalizer(finalizerName)) {
return resource;
@@ -599,7 +1079,8 @@ public P addFinalizer(String finalizerName) {
r.addFinalizer(finalizerName);
return r;
},
- r -> !r.hasFinalizer(finalizerName));
+ r -> !r.hasFinalizer(finalizerName),
+ cacheOnly);
}
/**
@@ -621,6 +1102,7 @@ public P removeFinalizer() {
* explicitly adding/removing finalizer is required only if "Trigger reconciliation on all event"
* mode is on.
*
+ * @param finalizerName name of the finalizer to remove
* @return updated resource from the server response
*/
public P removeFinalizer(String finalizerName) {
@@ -643,18 +1125,40 @@ public P removeFinalizer(String finalizerName) {
}
/**
- * Patches the resource using JSON Patch. In case the server responds with conflict (HTTP 409) or
- * unprocessable content (HTTP 422) it retries the operation up to the maximum number defined in
- * {@link ResourceOperations#DEFAULT_MAX_RETRY}.
+ * Patches the primary resource using JSON Patch, retrying on conflict, filtering the own event
+ * via the default JSON Patch matcher (equivalent to {@link
+ * #conflictRetryingPatchPrimary(UnaryOperator, Predicate, boolean)} with {@code cacheOnly =
+ * false}).
*
* @param resourceChangesOperator changes to be done on the resource before update
* @param preCondition condition to check if the patch operation still needs to be performed or
- * not.
- * @return updated resource from the server or unchanged if the precondition does not hold.
+ * not
+ * @return updated resource from the server or unchanged if the precondition does not hold
*/
- @SuppressWarnings("unchecked")
public P conflictRetryingPatchPrimary(
UnaryOperator
resourceChangesOperator, Predicate
preCondition) {
+ return conflictRetryingPatchPrimary(resourceChangesOperator, preCondition, false);
+ }
+
+ /**
+ * Patches the primary resource using JSON Patch, retrying on conflict. The {@code
+ * resourceChangesOperator} is applied to the current resource before each attempt; if the server
+ * responds with conflict (HTTP 409) or unprocessable content (HTTP 422) the latest version is
+ * re-fetched and the operation is retried, up to {@link ResourceOperations#DEFAULT_MAX_RETRY}
+ * times.
+ *
+ * @param resourceChangesOperator changes to be done on the resource before update
+ * @param preCondition condition to check if the patch operation still needs to be performed or
+ * not; evaluated against the (possibly re-fetched) resource before each attempt
+ * @param cacheOnly if {@code true} the patch only caches the response and does not
+ * filter the resulting own event; if {@code false} the default JSON Patch matcher is used and
+ * the own event is filtered
+ * @return updated resource from the server or unchanged if the precondition does not hold
+ * @throws OperatorException if the maximum number of retry attempts is exceeded
+ */
+ @SuppressWarnings("unchecked")
+ public P conflictRetryingPatchPrimary(
+ UnaryOperator
resourceChangesOperator, Predicate
preCondition, boolean cacheOnly) {
var resource = context.getPrimaryResource();
var client = context.getClient();
if (log.isDebugEnabled()) {
@@ -666,7 +1170,12 @@ public P conflictRetryingPatchPrimary(
if (!preCondition.test(resource)) {
return resource;
}
- return jsonPatchPrimary(resource, resourceChangesOperator);
+ return jsonPatchPrimary(
+ resource,
+ resourceChangesOperator,
+ cacheOnly
+ ? Options.cacheOnly()
+ : Options.matchAndFilterWithDefaultMatcher(UpdateType.JSON_PATCH));
} catch (KubernetesClientException e) {
log.trace("Exception during patch for resource: {}", resource);
retryIndex++;
@@ -742,7 +1251,7 @@ public P addFinalizerWithSSA(String finalizerName) {
resource.initNameAndNamespaceFrom(originalResource);
resource.addFinalizer(finalizerName);
- return serverSideApplyPrimary(resource);
+ return serverSideApplyPrimary(resource, Options.cacheOnly());
} catch (InstantiationException
| IllegalAccessException
| InvocationTargetException
@@ -754,4 +1263,185 @@ public P addFinalizerWithSSA(String finalizerName) {
e);
}
}
+
+ /**
+ * Controls how an update/patch/create operation of {@link ResourceOperations} handles the own
+ * event resulting from the write. This is the entry point users interact with to tune caching and
+ * event filtering; instances are created through the static factory methods rather than a
+ * constructor, and each maps to a {@link Mode}.
+ *
+ *
See the {@link ResourceOperations} class documentation for a full description of the
+ * available strategies, their correctness requirements (a {@link Matcher} or optimistic locking
+ * is needed for safe own-event filtering), and the trade-offs of the default matchers.
+ *
+ *
+ *
{@link #filterWithOptimisticLocking()} - filter the own event; requires the write to use
+ * optimistic locking (a resource version set), throwing if none is set.
+ *
{@link #matchAndFilter(Matcher)} / {@link #matchAndFilterWithDefaultMatcher(UpdateType)}
+ * - skip the write when the desired state already matches the actual state, otherwise write
+ * and filter the own event.
+ *
{@link #cacheOnly()} / {@link #cacheOnly(Matcher)} - only cache the response, no
+ * own-event filtering.
+ *
{@link #forceFilterEvents()} - always filter the own event (mostly for internal usage).
+ *
+ */
+ @Experimental(API_MIGHT_CHANGE)
+ public static class Options {
+
+ private static final Options ALWAYS_FILTER = new Options(Mode.FORCE_FILTER, null);
+ private static final Options ONLY_CACHE = new Options(Mode.CACHE_ONLY, null);
+ private static final Options FILTER_WITH_OPTIMISTIC_LOCKING =
+ new Options(Mode.FILTER_WITH_OPTIMISTIC_LOCKING, null);
+
+ private final Mode mode;
+ private final Matcher matcher;
+
+ private Options(Mode mode, Matcher matcher) {
+ this.mode = mode;
+ this.matcher = matcher;
+ }
+
+ /**
+ * Always filters the own event resulting from the write, regardless of optimistic locking. Safe
+ * only when correctness is otherwise guaranteed (see {@link ResourceOperations}); mostly for
+ * internal usage.
+ *
+ * @return options that always filter the own event
+ */
+ public static Options forceFilterEvents() {
+ return ALWAYS_FILTER;
+ }
+
+ /**
+ * Only caches the response of the write for read-cache-after-write consistency, without doing
+ * any own-event filtering.
+ *
+ * @return options that only cache the response
+ */
+ public static Options cacheOnly() {
+ return ONLY_CACHE;
+ }
+
+ /**
+ * Like {@link #cacheOnly()} but additionally skips the write when the desired state already
+ * matches the actual (cached) state according to the given {@link Matcher}.
+ *
+ * @param matcher the matcher used to decide whether the actual state already matches the
+ * desired
+ * @return options that cache only, skipping the write when already matching
+ */
+ public static Options cacheOnly(Matcher matcher) {
+ return new Options(Mode.CACHE_ONLY, matcher);
+ }
+
+ /**
+ * Filters the own event resulting from the write, requiring the write to use optimistic locking
+ * (a resource version set on the written resource). If no resource version is set an {@link
+ * IllegalArgumentException} is thrown when the operation is performed. Requiring optimistic
+ * locking guarantees that a concurrent third-party change is rejected by the API server rather
+ * than being silently filtered out.
+ *
+ * @return options that filter the own event, requiring optimistic locking
+ */
+ public static Options filterWithOptimisticLocking() {
+ return FILTER_WITH_OPTIMISTIC_LOCKING;
+ }
+
+ /**
+ * Like {@link #filterWithOptimisticLocking()} but additionally skips the write when the desired
+ * state already matches the actual (cached) state according to the given {@link Matcher}. When
+ * a write is needed it still requires optimistic locking (see {@link
+ * #filterWithOptimisticLocking()}).
+ *
+ * @param matcher the matcher used to decide whether the actual state already matches the
+ * desired
+ * @return options that match using the given matcher and, when a write is needed, filter the
+ * own event requiring optimistic locking
+ */
+ public static Options filterWithOptimisticLocking(Matcher matcher) {
+ return new Options(Mode.FILTER_WITH_OPTIMISTIC_LOCKING, matcher);
+ }
+
+ /**
+ * Like {@link #filterWithOptimisticLocking()} but additionally skips the write when the desired
+ * state already matches the actual (cached) state according to the default {@link Matcher} of
+ * the given {@link UpdateType}. When a write is needed it still requires optimistic locking
+ * (see {@link #filterWithOptimisticLocking()}).
+ *
+ * @param updateType the update type whose default matcher should be used
+ * @return options that match using the update type's default matcher and, when a write is
+ * needed, filter the own event requiring optimistic locking
+ */
+ public static Options filterWithOptimisticLocking(UpdateType updateType) {
+ return new Options(Mode.FILTER_WITH_OPTIMISTIC_LOCKING, updateType.getMatcher());
+ }
+
+ /**
+ * Filters own updates and, before doing so, checks whether the actual resource already matches
+ * the desired state using the given {@link Matcher}.
+ *
+ * @param matcher the matcher used to decide whether actual already matches desired
+ * @return options that match using the given matcher and filter the own event
+ */
+ public static Options matchAndFilter(Matcher matcher) {
+ if (matcher == null) {
+ throw new IllegalArgumentException("Matcher cannot be null for matchAndFilterOperation");
+ }
+ return new Options(Mode.FILTER_IF_NOT_MATCHING, matcher);
+ }
+
+ /**
+ * Same as {@link #matchAndFilter(Matcher)} but uses the default {@link Matcher} registered for
+ * the given {@link UpdateType}. See the {@link ResourceOperations} class documentation for the
+ * caveats of the default matchers.
+ *
+ * @param updateType the update type whose default matcher should be used
+ * @return options that match using the default matcher and filter the own event
+ */
+ public static Options matchAndFilterWithDefaultMatcher(UpdateType updateType) {
+ return new Options(Mode.FILTER_IF_NOT_MATCHING, updateType.getMatcher());
+ }
+
+ public Mode getMode() {
+ return mode;
+ }
+
+ public Optional getMatcher() {
+ return Optional.ofNullable(matcher);
+ }
+
+ public boolean requiresMatcher() {
+ return mode == Mode.FILTER_IF_NOT_MATCHING;
+ }
+ }
+
+ /**
+ * The strategy used to decide how the own event resulting from a write is handled. This is a
+ * low-level enum; users should not reference it directly but select the desired behavior through
+ * the {@link Options} factory methods (e.g. {@link Options#filterWithOptimisticLocking()}, {@link
+ * Options#matchAndFilter(Matcher)}, {@link Options#cacheOnly()}, {@link
+ * Options#forceFilterEvents()}), which is why each constant links to its corresponding factory.
+ */
+ @Experimental(API_MIGHT_CHANGE)
+ public enum Mode {
+ /** See {@link Options#filterWithOptimisticLocking()}. */
+ FILTER_WITH_OPTIMISTIC_LOCKING,
+ /** See {@link Options#matchAndFilter(Matcher)}. */
+ FILTER_IF_NOT_MATCHING,
+ /** See {@link Options#cacheOnly()}. */
+ CACHE_ONLY,
+ /** See {@link Options#forceFilterEvents()}. */
+ FORCE_FILTER,
+ }
+
+ private T desiredForJsonPatch(
+ T actualResource, UnaryOperator unaryOperator, Options options) {
+ var cloned =
+ context
+ .getControllerConfiguration()
+ .getConfigurationService()
+ .getResourceCloner()
+ .clone(actualResource);
+ return unaryOperator.apply(cloned);
+ }
}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/Matcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/Matcher.java
new file mode 100644
index 0000000000..5da71ea8eb
--- /dev/null
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/Matcher.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright Java Operator SDK Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.javaoperatorsdk.operator.api.reconciler.matcher;
+
+import io.fabric8.kubernetes.api.model.HasMetadata;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+
+public interface Matcher {
+
+ /**
+ * Matcher if the desired state matches the actual state in respective to underlying update/patch
+ * method
+ */
+ boolean matches(HasMetadata desired, HasMetadata actual, Context> context);
+}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/Controller.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/Controller.java
index bc3a43a9a3..5df4a86f57 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/Controller.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/Controller.java
@@ -383,6 +383,7 @@ public synchronized void start(boolean startEventProcessor) throws OperatorExcep
eventSourceManager.start();
if (startEventProcessor) {
eventProcessor.start();
+ metrics.eventProcessingStarted(this);
}
log.info("'{}' controller started", controllerName);
} catch (MissingCRDException e) {
@@ -429,6 +430,7 @@ public synchronized void changeNamespaces(Set namespaces) {
public synchronized void startEventProcessing() {
eventProcessor.start();
+ metrics.eventProcessingStarted(this);
log.info("Started event processing for controller: {}", configuration.getName());
}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/AbstractExternalDependentResource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/AbstractExternalDependentResource.java
index 7b83a377c1..c391092422 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/AbstractExternalDependentResource.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/AbstractExternalDependentResource.java
@@ -21,10 +21,8 @@
import io.fabric8.kubernetes.api.model.HasMetadata;
import io.javaoperatorsdk.operator.api.reconciler.Context;
-import io.javaoperatorsdk.operator.api.reconciler.dependent.RecentOperationCacheFiller;
import io.javaoperatorsdk.operator.processing.ResourceIDMapper;
import io.javaoperatorsdk.operator.processing.event.EventSourceRetriever;
-import io.javaoperatorsdk.operator.processing.event.ResourceID;
import io.javaoperatorsdk.operator.processing.event.source.EventSource;
import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource;
@@ -93,14 +91,10 @@ private void handleExplicitStateDelete(P primary, R secondary, Context
context) {
var resource = dependentResourceWithExplicitState.stateResource(primary, created);
- var stateResource = context.getClient().resource(resource).create();
- if (externalStateEventSource != null) {
- ((RecentOperationCacheFiller) externalStateEventSource)
- .handleRecentResourceCreate(ResourceID.fromResource(primary), stateResource);
- }
+ context.resourceOperations().create(resource);
}
@Override
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesResourceMatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesResourceMatcher.java
index 5562c883e2..b5a0728e16 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesResourceMatcher.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesResourceMatcher.java
@@ -20,6 +20,7 @@
import java.util.List;
import io.fabric8.kubernetes.api.model.HasMetadata;
+import io.fabric8.zjsonpatch.DiffFlags;
import io.fabric8.zjsonpatch.JsonDiff;
import io.javaoperatorsdk.operator.api.reconciler.Context;
import io.javaoperatorsdk.operator.processing.dependent.Matcher;
@@ -29,6 +30,7 @@
public class GenericKubernetesResourceMatcher {
private static final String SPEC = "/spec";
+ private static final String STATUS = "/status";
private static final String METADATA = "/metadata";
private static final String ADD = "add";
private static final String OP = "op";
@@ -195,6 +197,58 @@ public static Matcher.Result m
return Matcher.Result.computed(matched, desired);
}
+ /**
+ * Determines whether the {@code status} subresource of the specified actual resource matches the
+ * {@code status} of the specified desired resource. Unlike {@link #match(HasMetadata,
+ * HasMetadata, Context)}, which ignores the status, this method considers only the
+ * {@code /status} subtree; all other differences (spec, metadata, ...) are ignored.
+ *
+ * @param desired the desired resource
+ * @param actualResource the actual resource
+ * @param context the {@link Context} instance within which this method is called
+ * @param resource
+ * @return results of matching
+ */
+ public static Matcher.Result matchStatus(
+ R desired, R actualResource, Context
context) {
+ return matchStatus(desired, actualResource, false, context);
+ }
+
+ /**
+ * Determines whether the {@code status} subresource of the specified actual resource matches the
+ * {@code status} of the specified desired resource. Unlike {@link #match(HasMetadata,
+ * HasMetadata, Context)}, which ignores the status, this method considers only the
+ * {@code /status} subtree; all other differences (spec, metadata, ...) are ignored.
+ *
+ * @param desired the desired resource
+ * @param actualResource the actual resource
+ * @param valuesEquality if {@code false}, the algorithm only checks that the values present in
+ * the desired status are the same in the actual status, allowing the actual status to contain
+ * additional values (for example defaults added by the server). If {@code true}, the statuses
+ * match only if they are fully equal.
+ * @param context the {@link Context} instance within which this method is called
+ * @param resource
+ * @return results of matching
+ */
+ public static Matcher.Result matchStatus(
+ R desired, R actualResource, boolean valuesEquality, Context
context) {
+ final var kubernetesSerialization = context.getClient().getKubernetesSerialization();
+ var desiredNode = kubernetesSerialization.convertValue(desired, JsonNode.class);
+ var actualNode = kubernetesSerialization.convertValue(actualResource, JsonNode.class);
+ var wholeDiffJsonPatch =
+ JsonDiff.asJson(desiredNode, actualNode, DiffFlags.dontNormalizeOpIntoMoveAndCopy());
+
+ boolean matched = true;
+ for (int i = 0; i < wholeDiffJsonPatch.size() && matched; i++) {
+ var node = wholeDiffJsonPatch.get(i);
+ if (nodeIsChildOf(node, List.of(STATUS))) {
+ matched = match(valuesEquality, node, Collections.emptyList());
+ }
+ }
+
+ return Matcher.Result.computed(matched, desired);
+ }
+
private static boolean match(boolean equality, JsonNode diff, final List ignoreList) {
if (equality) {
return false;
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResource.java
index f8d7c07b01..bb59d6eed6 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResource.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResource.java
@@ -30,6 +30,7 @@
import io.javaoperatorsdk.operator.api.reconciler.Context;
import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext;
import io.javaoperatorsdk.operator.api.reconciler.Ignore;
+import io.javaoperatorsdk.operator.api.reconciler.ResourceOperations;
import io.javaoperatorsdk.operator.api.reconciler.dependent.GarbageCollected;
import io.javaoperatorsdk.operator.api.reconciler.dependent.managed.ConfiguredDependentResource;
import io.javaoperatorsdk.operator.processing.GroupVersionKind;
@@ -90,10 +91,14 @@ public R create(R desired, P primary, Context
context) {
desired.getClass(),
ResourceID.fromResource(desired),
ssa);
-
return ssa
? context.resourceOperations().serverSideApply(desired, eventSource().orElse(null))
- : context.resourceOperations().create(desired, eventSource().orElse(null));
+ : context
+ .resourceOperations()
+ .create(
+ desired,
+ eventSource().orElse(null),
+ ResourceOperations.Options.forceFilterEvents());
}
public R update(R actual, R desired, P primary, Context
context) {
@@ -114,7 +119,12 @@ public R update(R actual, R desired, P primary, Context
postExecutionControl) {
if (!isRetryConfigured() && postExecutionControl.exceptionDuringExecution()) {
+ // No retry is configured, so this failure is final. Even if the reconciler handled the error
+ // in updateErrorStatus, we keep the higher-severity log so a non-recoverable failure is not
+ // hidden from operators.
log.error(
"Error during event processing {}",
executionScope,
@@ -369,14 +374,16 @@ TimerEventSource
retryEventSource() {
* events (received meanwhile retry is in place or already in buffer) instantly or always wait
* according to the retry timing if there was an exception.
*/
- private void handleRetryOnException(ExecutionScope
executionScope) {
if (!retry.isLastAttempt()
&& exception instanceof KubernetesClientException ex
&& ex.getCode() == HttpURLConnection.HTTP_CONFLICT) {
+ // The conflict branch is already low-noise (DEBUG + INFO) and provides actionable info, so it
+ // keeps precedence over the handled-error downgrade below.
log.debug("Full client conflict error during event processing {}", executionScope, exception);
log.info(
"Resource Kubernetes Resource Creator/Update Conflict during reconciliation. Message:"
+ " {} Resource name: {}",
ex.getMessage(),
ex.getFullResourceName());
+ } else if (errorHandledByReconciler && !retry.isLastAttempt()) {
+ // The reconciler already handled the error in updateErrorStatus (and had the chance to log it
+ // as needed), so while there are retries remaining the framework only logs it on debug level.
+ // On the last attempt we still fall through to the higher-severity logging below, so a final,
+ // non-recoverable failure is not hidden from operators.
+ log.debug(
+ "Error during event processing {}, but was handled by the reconciler",
+ executionScope,
+ exception);
} else if (eventPresent || !retry.isLastAttempt()) {
log.warn(
"Uncaught error during event processing {} - but another reconciliation will be attempted"
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/PostExecutionControl.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/PostExecutionControl.java
index 64d7a25166..ef09c74a08 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/PostExecutionControl.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/PostExecutionControl.java
@@ -27,6 +27,7 @@ final class PostExecutionControl {
private final Exception runtimeException;
private Long reScheduleDelay = null;
+ private boolean errorHandledByReconciler = false;
private PostExecutionControl(
boolean finalizerRemoved,
@@ -68,6 +69,22 @@ public static PostExecutionControl exceptionDuringExe
return new PostExecutionControl<>(false, null, false, exception);
}
+ /**
+ * Marks that the exception was handled by the reconciler's {@code updateErrorStatus} (i.e. the
+ * reconciler did not return {@link
+ * io.javaoperatorsdk.operator.api.reconciler.ErrorStatusUpdateControl#defaultErrorProcessing()}),
+ * but the error is still retried. In this case the framework logs the error on a lower level,
+ * since the reconciler already had the chance to handle and log it as needed.
+ */
+ public PostExecutionControl withErrorHandledByReconciler() {
+ this.errorHandledByReconciler = true;
+ return this;
+ }
+
+ public boolean isErrorHandledByReconciler() {
+ return errorHandledByReconciler;
+ }
+
public Optional getUpdatedCustomResource() {
return Optional.ofNullable(updatedCustomResource);
}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcher.java
index 6e7ace0447..e65a611c19 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcher.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcher.java
@@ -31,6 +31,7 @@
import io.javaoperatorsdk.operator.api.reconciler.Context;
import io.javaoperatorsdk.operator.api.reconciler.DefaultContext;
import io.javaoperatorsdk.operator.api.reconciler.DeleteControl;
+import io.javaoperatorsdk.operator.api.reconciler.ResourceOperations;
import io.javaoperatorsdk.operator.api.reconciler.RetryInfo;
import io.javaoperatorsdk.operator.api.reconciler.UpdateControl;
import io.javaoperatorsdk.operator.processing.Controller;
@@ -136,10 +137,9 @@ private PostExecutionControl
handleReconcile(
if (useSSA) {
updatedResource = context.resourceOperations().addFinalizerWithSSA();
} else {
- updatedResource = context.resourceOperations().addFinalizer();
+ updatedResource = context.resourceOperations().addFinalizer(true);
}
- return PostExecutionControl.onlyFinalizerAdded(updatedResource)
- .withReSchedule(BaseControl.INSTANT_RESCHEDULE);
+ return PostExecutionControl.onlyFinalizerAdded(updatedResource);
} else {
try {
return reconcileExecution(executionScope, resourceForExecution, originalResource, context);
@@ -266,7 +266,12 @@ public boolean isLastAttempt() {
errorStatusUpdateControl.getScheduleDelay().ifPresent(postExecutionControl::withReSchedule);
return postExecutionControl;
}
- throw e;
+ // The reconciler handled the error via updateErrorStatus (it did not return
+ // defaultErrorProcessing()) but still wants the error to be retried. The retry (and its
+ // backoff) is kept intact, but since the reconciler already had the chance to handle and log
+ // the error, the framework logs it on a lower level instead of emitting an "uncaught error"
+ // warning.
+ return PostExecutionControl.
createPostExecutionControl(
@@ -366,9 +371,14 @@ public R patchResource(Context context, R resource, R originalResource) {
log.debug("Trying to replace resource");
}
if (useSSA) {
- return context.resourceOperations().serverSideApplyPrimary(resource);
+ return context
+ .resourceOperations()
+ .serverSideApplyPrimary(resource, ResourceOperations.Options.cacheOnly());
} else {
- return context.resourceOperations().jsonPatchPrimary(originalResource, r -> resource);
+ return context
+ .resourceOperations()
+ .jsonPatchPrimary(
+ originalResource, r -> resource, ResourceOperations.Options.cacheOnly());
}
}
@@ -378,7 +388,9 @@ public R patchStatus(Context context, R resource, R originalResource) {
var managedFields = resource.getMetadata().getManagedFields();
try {
resource.getMetadata().setManagedFields(null);
- return context.resourceOperations().serverSideApplyPrimaryStatus(resource);
+ return context
+ .resourceOperations()
+ .serverSideApplyPrimaryStatus(resource, ResourceOperations.Options.cacheOnly());
} finally {
resource.getMetadata().setManagedFields(managedFields);
}
@@ -402,7 +414,8 @@ private R editStatus(Context context, R resource, R originalResource) {
r -> {
ReconcilerUtilsInternal.setStatus(r, ReconcilerUtilsInternal.getStatus(resource));
return r;
- });
+ },
+ ResourceOperations.Options.cacheOnly());
} finally {
// restore initial resource version
clonedOriginal.getMetadata().setResourceVersion(resourceVersion);
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/filter/GenericFilter.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/filter/GenericFilter.java
index 01a050f6d0..5ffeeadc64 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/filter/GenericFilter.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/filter/GenericFilter.java
@@ -15,19 +15,60 @@
*/
package io.javaoperatorsdk.operator.processing.event.source.filter;
+/**
+ * Filter applied to every kind of event (add, update and delete) received by an
+ * informer-backed event source. It is a convenient way to express a condition that is independent
+ * of the event type.
+ *
+ *
When both a type-specific filter (such as {@link OnAddFilter}, {@link OnUpdateFilter} or
+ * {@link OnDeleteFilter}) and a {@code GenericFilter} are configured, both must accept an
+ * event for it to be propagated.
+ *
+ *
Filters only apply to informer-backed event sources; event sources that are not backed by an
+ * informer have no filters.
+ *
+ * @param the type of the resource the events relate to
+ */
@FunctionalInterface
public interface GenericFilter {
+ /**
+ * Decides whether an event concerning the given resource should be propagated.
+ *
+ * @param resource the resource the event relates to
+ * @return {@code true} if the event should be accepted (propagated), {@code false} if it
+ * should be dropped
+ */
boolean accept(R resource);
+ /**
+ * Composes this filter with another one using a logical AND: the resulting filter accepts
+ * an event only when both this filter and the given filter accept it.
+ *
+ * @param genericFilter the other filter to combine with this one
+ * @return a filter that accepts an event only if both filters accept it
+ */
default GenericFilter and(GenericFilter genericFilter) {
return (resource) -> this.accept(resource) && genericFilter.accept(resource);
}
+ /**
+ * Composes this filter with another one using a logical OR: the resulting filter accepts
+ * an event when either this filter or the given filter accepts it.
+ *
+ * @param genericFilter the other filter to combine with this one
+ * @return a filter that accepts an event if at least one of the filters accepts it
+ */
default GenericFilter or(GenericFilter genericFilter) {
return (resource) -> this.accept(resource) || genericFilter.accept(resource);
}
+ /**
+ * Negates this filter: the resulting filter accepts exactly the events this one drops, and drops
+ * the events this one accepts.
+ *
+ * @return a filter that returns the logical NOT of this filter's result
+ */
default GenericFilter not() {
return (resource) -> !this.accept(resource);
}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/filter/OnAddFilter.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/filter/OnAddFilter.java
index 26dfd7199f..3946faa735 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/filter/OnAddFilter.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/filter/OnAddFilter.java
@@ -15,18 +15,55 @@
*/
package io.javaoperatorsdk.operator.processing.event.source.filter;
+/**
+ * Filter applied to resource add events received by an informer-backed event source. It lets
+ * you decide whether the addition of a resource should be propagated and (potentially) trigger a
+ * reconciliation.
+ *
+ *
Filters only apply to informer-backed event sources; event sources that are not backed by an
+ * informer have no filters.
+ *
+ * @param the type of the added resource
+ */
@FunctionalInterface
public interface OnAddFilter {
+ /**
+ * Decides whether the add event for the given resource should be propagated.
+ *
+ * @param resource the resource that was added
+ * @return {@code true} if the event should be accepted (propagated), {@code false} if it
+ * should be dropped
+ */
boolean accept(R resource);
+ /**
+ * Composes this filter with another one using a logical AND: the resulting filter accepts
+ * an event only when both this filter and the given filter accept it.
+ *
+ * @param onAddFilter the other filter to combine with this one
+ * @return a filter that accepts an event only if both filters accept it
+ */
default OnAddFilter and(OnAddFilter onAddFilter) {
return (resource) -> this.accept(resource) && onAddFilter.accept(resource);
}
+ /**
+ * Composes this filter with another one using a logical OR: the resulting filter accepts
+ * an event when either this filter or the given filter accepts it.
+ *
+ * @param onAddFilter the other filter to combine with this one
+ * @return a filter that accepts an event if at least one of the filters accepts it
+ */
default OnAddFilter or(OnAddFilter onAddFilter) {
return (resource) -> this.accept(resource) || onAddFilter.accept(resource);
}
+ /**
+ * Negates this filter: the resulting filter accepts exactly the events this one drops, and drops
+ * the events this one accepts.
+ *
+ * @return a filter that returns the logical NOT of this filter's result
+ */
default OnAddFilter not() {
return (resource) -> !this.accept(resource);
}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/filter/OnDeleteFilter.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/filter/OnDeleteFilter.java
index bde05783c2..89035c1a6c 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/filter/OnDeleteFilter.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/filter/OnDeleteFilter.java
@@ -15,23 +15,63 @@
*/
package io.javaoperatorsdk.operator.processing.event.source.filter;
+/**
+ * Filter applied to resource delete events received by an informer-backed event source. It
+ * lets you decide whether the deletion of a resource should be propagated and (potentially) trigger
+ * a reconciliation.
+ *
+ *
Filters only apply to informer-backed event sources; event sources that are not backed by an
+ * informer have no filters.
+ *
+ * @param the type of the deleted resource
+ */
@FunctionalInterface
public interface OnDeleteFilter {
+ /**
+ * Decides whether the delete event for the given resource should be propagated.
+ *
+ * @param hasMetadata the resource that was deleted
+ * @param deletedFinalStateUnknown {@code true} when the informer missed the delete event and the
+ * final state of the resource is unknown (the object may be stale), {@code false} when the
+ * deletion was observed with the resource's last known state
+ * @return {@code true} if the event should be accepted (propagated), {@code false} if it
+ * should be dropped
+ */
boolean accept(R hasMetadata, Boolean deletedFinalStateUnknown);
+ /**
+ * Composes this filter with another one using a logical AND: the resulting filter accepts
+ * an event only when both this filter and the given filter accept it.
+ *
+ * @param OnDeleteFilter the other filter to combine with this one
+ * @return a filter that accepts an event only if both filters accept it
+ */
default OnDeleteFilter and(OnDeleteFilter OnDeleteFilter) {
return (resource, deletedFinalStateUnknown) ->
this.accept(resource, deletedFinalStateUnknown)
&& OnDeleteFilter.accept(resource, deletedFinalStateUnknown);
}
+ /**
+ * Composes this filter with another one using a logical OR: the resulting filter accepts
+ * an event when either this filter or the given filter accepts it.
+ *
+ * @param OnDeleteFilter the other filter to combine with this one
+ * @return a filter that accepts an event if at least one of the filters accepts it
+ */
default OnDeleteFilter or(OnDeleteFilter OnDeleteFilter) {
return (resource, deletedFinalStateUnknown) ->
this.accept(resource, deletedFinalStateUnknown)
|| OnDeleteFilter.accept(resource, deletedFinalStateUnknown);
}
+ /**
+ * Negates this filter: the resulting filter accepts exactly the events this one drops, and drops
+ * the events this one accepts.
+ *
+ * @return a filter that returns the logical NOT of this filter's result
+ */
default OnDeleteFilter not() {
return (resource, deletedFinalStateUnknown) -> !this.accept(resource, deletedFinalStateUnknown);
}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/filter/OnUpdateFilter.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/filter/OnUpdateFilter.java
index 596565086c..602b52adcb 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/filter/OnUpdateFilter.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/filter/OnUpdateFilter.java
@@ -15,21 +15,60 @@
*/
package io.javaoperatorsdk.operator.processing.event.source.filter;
+/**
+ * Filter applied to resource update events received by an informer-backed event source. It
+ * receives both the new and the old version of the resource, so it can decide whether an update is
+ * meaningful enough to be propagated and (potentially) trigger a reconciliation.
+ *
+ *
Filters only apply to informer-backed event sources; event sources that are not backed by an
+ * informer have no filters.
+ *
+ * @param the type of the updated resource
+ */
@FunctionalInterface
public interface OnUpdateFilter {
+ /**
+ * Decides whether the update event should be propagated, based on the new and old versions of the
+ * resource.
+ *
+ * @param newResource the resource after the update
+ * @param oldResource the resource before the update
+ * @return {@code true} if the event should be accepted (propagated), {@code false} if it
+ * should be dropped
+ */
boolean accept(R newResource, R oldResource);
+ /**
+ * Composes this filter with another one using a logical AND: the resulting filter accepts
+ * an event only when both this filter and the given filter accept it.
+ *
+ * @param onUpdateFilter the other filter to combine with this one
+ * @return a filter that accepts an event only if both filters accept it
+ */
default OnUpdateFilter and(OnUpdateFilter onUpdateFilter) {
return (newResource, oldResource) ->
this.accept(newResource, oldResource) && onUpdateFilter.accept(newResource, oldResource);
}
+ /**
+ * Composes this filter with another one using a logical OR: the resulting filter accepts
+ * an event when either this filter or the given filter accepts it.
+ *
+ * @param onUpdateFilter the other filter to combine with this one
+ * @return a filter that accepts an event if at least one of the filters accepts it
+ */
default OnUpdateFilter or(OnUpdateFilter onUpdateFilter) {
return (newResource, oldResource) ->
this.accept(newResource, oldResource) || onUpdateFilter.accept(newResource, oldResource);
}
+ /**
+ * Negates this filter: the resulting filter accepts exactly the events this one drops, and drops
+ * the events this one accepts.
+ *
+ * @return a filter that returns the logical NOT of this filter's result
+ */
default OnUpdateFilter not() {
return (newResource, oldResource) -> !this.accept(newResource, oldResource);
}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java
index 5a239a7377..8352bef665 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java
@@ -39,6 +39,7 @@
import io.javaoperatorsdk.operator.api.config.ControllerConfiguration;
import io.javaoperatorsdk.operator.api.config.Informable;
import io.javaoperatorsdk.operator.api.config.NamespaceChangeable;
+import io.javaoperatorsdk.operator.api.reconciler.Experimental;
import io.javaoperatorsdk.operator.api.reconciler.dependent.RecentOperationCacheFiller;
import io.javaoperatorsdk.operator.health.InformerHealthIndicator;
import io.javaoperatorsdk.operator.health.InformerWrappingEventSourceHealthIndicator;
@@ -48,6 +49,8 @@
import io.javaoperatorsdk.operator.processing.event.source.*;
import io.javaoperatorsdk.operator.processing.event.source.ResourceAction;
+import static io.javaoperatorsdk.operator.api.reconciler.Experimental.API_MIGHT_CHANGE;
+
@SuppressWarnings("rawtypes")
public abstract class ManagedInformerEventSource<
R extends HasMetadata, P extends HasMetadata, C extends Informable>
@@ -93,11 +96,13 @@ public void changeNamespaces(Set namespaces) {
* Also makes sure that the even produced by this update is filtered, thus does not trigger the
* reconciliation.
*/
+ @Experimental(API_MIGHT_CHANGE)
@SuppressWarnings("unchecked")
public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator updateMethod) {
+
ResourceID id = ResourceID.fromResource(resourceToUpdate);
- log.debug("Starting event filtering and caching update for id={}", id);
- R updatedResource = null;
+ log.debug("Starting event filtering and caching update for id: {}", id);
+ R updatedResource;
Set relatedPrimaryIds = null;
try {
temporaryResourceCache.startEventFilteringModify(id);
@@ -134,6 +139,19 @@ public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator<
}
}
+ @Experimental(API_MIGHT_CHANGE)
+ public R updateAndCacheResource(R resourceToUpdate, UnaryOperator updateOperation) {
+ if (log.isDebugEnabled()) {
+ log.debug(
+ "Updating and caching resource without filtering. id: {} type: {}",
+ ResourceID.fromResource(resourceToUpdate),
+ resourceType());
+ }
+ var result = updateOperation.apply(resourceToUpdate);
+ handleRecentResourceUpdate(ResourceID.fromResource(resourceToUpdate), result, resourceToUpdate);
+ return result;
+ }
+
protected abstract void handleEvent(
ResourceAction action,
R resource,
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonMergePatchMatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonMergePatchMatcher.java
new file mode 100644
index 0000000000..6b2ac586df
--- /dev/null
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonMergePatchMatcher.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright Java Operator SDK Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.javaoperatorsdk.operator.processing.matcher;
+
+import io.fabric8.kubernetes.api.model.HasMetadata;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.matcher.Matcher;
+
+public class JsonMergePatchMatcher implements Matcher {
+
+ private static final JsonMergePatchMatcher INSTANCE = new JsonMergePatchMatcher();
+
+ public static JsonMergePatchMatcher getInstance() {
+ return INSTANCE;
+ }
+
+ private JsonMergePatchMatcher() {}
+
+ @Override
+ public boolean matches(HasMetadata desired, HasMetadata actual, Context> context) {
+ return MatcherUtils.mergePatchMatches(
+ MatcherUtils.toNode(actual, context), MatcherUtils.toNode(desired, context));
+ }
+}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonMergePatchStatusMatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonMergePatchStatusMatcher.java
new file mode 100644
index 0000000000..49be36c80e
--- /dev/null
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonMergePatchStatusMatcher.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright Java Operator SDK Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.javaoperatorsdk.operator.processing.matcher;
+
+import io.fabric8.kubernetes.api.model.HasMetadata;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.matcher.Matcher;
+
+public class JsonMergePatchStatusMatcher implements Matcher {
+
+ private static final JsonMergePatchStatusMatcher INSTANCE = new JsonMergePatchStatusMatcher();
+
+ public static JsonMergePatchStatusMatcher getInstance() {
+ return INSTANCE;
+ }
+
+ private JsonMergePatchStatusMatcher() {}
+
+ @Override
+ public boolean matches(HasMetadata desired, HasMetadata actual, Context> context) {
+ return MatcherUtils.mergePatchMatches(
+ MatcherUtils.statusNode(actual, context), MatcherUtils.statusNode(desired, context));
+ }
+}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonPatchMatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonPatchMatcher.java
new file mode 100644
index 0000000000..7ac65be9a6
--- /dev/null
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonPatchMatcher.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright Java Operator SDK Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.javaoperatorsdk.operator.processing.matcher;
+
+import io.fabric8.kubernetes.api.model.HasMetadata;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.matcher.Matcher;
+
+public class JsonPatchMatcher implements Matcher {
+
+ private static final JsonPatchMatcher INSTANCE = new JsonPatchMatcher();
+
+ public static JsonPatchMatcher getInstance() {
+ return INSTANCE;
+ }
+
+ private JsonPatchMatcher() {}
+
+ @Override
+ public boolean matches(HasMetadata desired, HasMetadata actual, Context> context) {
+ return MatcherUtils.jsonPatchMatches(
+ MatcherUtils.toNode(actual, context), MatcherUtils.toNode(desired, context));
+ }
+}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonPatchStatusMatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonPatchStatusMatcher.java
new file mode 100644
index 0000000000..2fcc1aebe4
--- /dev/null
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonPatchStatusMatcher.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright Java Operator SDK Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.javaoperatorsdk.operator.processing.matcher;
+
+import io.fabric8.kubernetes.api.model.HasMetadata;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.matcher.Matcher;
+
+public class JsonPatchStatusMatcher implements Matcher {
+
+ private static final JsonPatchStatusMatcher INSTANCE = new JsonPatchStatusMatcher();
+
+ public static JsonPatchStatusMatcher getInstance() {
+ return INSTANCE;
+ }
+
+ private JsonPatchStatusMatcher() {}
+
+ @Override
+ public boolean matches(HasMetadata desired, HasMetadata actual, Context> context) {
+ return MatcherUtils.jsonPatchMatches(
+ MatcherUtils.statusNode(actual, context), MatcherUtils.statusNode(desired, context));
+ }
+}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/MatcherUtils.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/MatcherUtils.java
new file mode 100644
index 0000000000..413a1c4acc
--- /dev/null
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/MatcherUtils.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright Java Operator SDK Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.javaoperatorsdk.operator.processing.matcher;
+
+import io.fabric8.kubernetes.api.model.HasMetadata;
+import io.fabric8.zjsonpatch.JsonDiff;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.matcher.Matcher;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import com.fasterxml.jackson.databind.node.NullNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+
+/** Shared helpers for the client-side patch based {@link Matcher} implementations. */
+final class MatcherUtils {
+
+ private static final String STATUS = "status";
+
+ private MatcherUtils() {}
+
+ static JsonNode toNode(HasMetadata resource, Context> context) {
+ return context.getClient().getKubernetesSerialization().convertValue(resource, JsonNode.class);
+ }
+
+ /** The {@code status} subresource as a node, or a {@link NullNode} if it is not present. */
+ static JsonNode statusNode(HasMetadata resource, Context> context) {
+ var status = toNode(resource, context).get(STATUS);
+ return status == null ? NullNode.getInstance() : status;
+ }
+
+ /**
+ * @return {@code true} if applying the desired state as a JSON Patch (RFC 6902) to the actual
+ * state would be a no-op, i.e. the computed patch contains no operations.
+ */
+ static boolean jsonPatchMatches(JsonNode actual, JsonNode desired) {
+ return JsonDiff.asJson(actual, desired).isEmpty();
+ }
+
+ /**
+ * @return {@code true} if applying the desired state as a JSON Merge Patch (RFC 7386) to the
+ * actual state would be a no-op, i.e. every value present in the desired state already equals
+ * the actual state (additional values only present in the actual state are allowed).
+ */
+ static boolean mergePatchMatches(JsonNode actual, JsonNode desired) {
+ return applyMergePatch(actual.deepCopy(), desired).equals(actual);
+ }
+
+ // See https://datatracker.ietf.org/doc/html/rfc7386#section-2
+ private static JsonNode applyMergePatch(JsonNode target, JsonNode patch) {
+ if (!patch.isObject()) {
+ return patch;
+ }
+ var targetObject =
+ target.isObject() ? (ObjectNode) target : JsonNodeFactory.instance.objectNode();
+ var fields = patch.fields();
+ while (fields.hasNext()) {
+ var entry = fields.next();
+ if (entry.getValue().isNull()) {
+ targetObject.remove(entry.getKey());
+ } else {
+ var current = targetObject.get(entry.getKey());
+ targetObject.set(
+ entry.getKey(),
+ applyMergePatch(current == null ? NullNode.getInstance() : current, entry.getValue()));
+ }
+ }
+ return targetObject;
+ }
+}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/SSAMatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/SSAMatcher.java
new file mode 100644
index 0000000000..c241ccfad5
--- /dev/null
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/SSAMatcher.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright Java Operator SDK Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.javaoperatorsdk.operator.processing.matcher;
+
+import io.fabric8.kubernetes.api.model.HasMetadata;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.matcher.Matcher;
+import io.javaoperatorsdk.operator.processing.dependent.kubernetes.SSABasedGenericKubernetesResourceMatcher;
+
+public class SSAMatcher implements Matcher {
+
+ private static final SSAMatcher INSTANCE = new SSAMatcher();
+
+ public static SSAMatcher getInstance() {
+ return INSTANCE;
+ }
+
+ private SSAMatcher() {}
+
+ @Override
+ public boolean matches(HasMetadata desired, HasMetadata actual, Context> context) {
+ return SSABasedGenericKubernetesResourceMatcher.getInstance().matches(actual, desired, context);
+ }
+}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/SSAStatusMatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/SSAStatusMatcher.java
new file mode 100644
index 0000000000..a6a59459f5
--- /dev/null
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/SSAStatusMatcher.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright Java Operator SDK Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.javaoperatorsdk.operator.processing.matcher;
+
+import io.fabric8.kubernetes.api.model.HasMetadata;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.matcher.Matcher;
+import io.javaoperatorsdk.operator.processing.dependent.kubernetes.GenericKubernetesResourceMatcher;
+
+public class SSAStatusMatcher implements Matcher {
+
+ private static final SSAStatusMatcher INSTANCE = new SSAStatusMatcher();
+
+ public static SSAStatusMatcher getInstance() {
+ return INSTANCE;
+ }
+
+ private SSAStatusMatcher() {}
+
+ @Override
+ public boolean matches(HasMetadata desired, HasMetadata actual, Context> context) {
+ return GenericKubernetesResourceMatcher.matchStatus(desired, actual, context).matched();
+ }
+}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/UpdateMatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/UpdateMatcher.java
new file mode 100644
index 0000000000..b47a4b79d5
--- /dev/null
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/UpdateMatcher.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright Java Operator SDK Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.javaoperatorsdk.operator.processing.matcher;
+
+import io.fabric8.kubernetes.api.model.HasMetadata;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.matcher.Matcher;
+import io.javaoperatorsdk.operator.processing.dependent.kubernetes.GenericKubernetesResourceMatcher;
+
+public class UpdateMatcher implements Matcher {
+
+ private static final UpdateMatcher INSTANCE = new UpdateMatcher();
+
+ public static UpdateMatcher getInstance() {
+ return INSTANCE;
+ }
+
+ private UpdateMatcher() {}
+
+ @Override
+ public boolean matches(HasMetadata desired, HasMetadata actual, Context> context) {
+ return GenericKubernetesResourceMatcher.match(desired, actual, context).matched();
+ }
+}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/UpdateStatusMatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/UpdateStatusMatcher.java
new file mode 100644
index 0000000000..cc3b045e9e
--- /dev/null
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/UpdateStatusMatcher.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright Java Operator SDK Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.javaoperatorsdk.operator.processing.matcher;
+
+import io.fabric8.kubernetes.api.model.HasMetadata;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.matcher.Matcher;
+import io.javaoperatorsdk.operator.processing.dependent.kubernetes.GenericKubernetesResourceMatcher;
+
+public class UpdateStatusMatcher implements Matcher {
+
+ private static final UpdateStatusMatcher INSTANCE = new UpdateStatusMatcher();
+
+ public static UpdateStatusMatcher getInstance() {
+ return INSTANCE;
+ }
+
+ private UpdateStatusMatcher() {}
+
+ @Override
+ public boolean matches(HasMetadata desired, HasMetadata actual, Context> context) {
+ return GenericKubernetesResourceMatcher.matchStatus(desired, actual, context).matched();
+ }
+}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/UpdateType.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/UpdateType.java
new file mode 100644
index 0000000000..327a9144df
--- /dev/null
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/UpdateType.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright Java Operator SDK Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.javaoperatorsdk.operator.processing.matcher;
+
+import io.javaoperatorsdk.operator.api.reconciler.matcher.Matcher;
+
+public enum UpdateType {
+ UPDATE(UpdateMatcher.getInstance()),
+ UPDATE_STATUS(UpdateStatusMatcher.getInstance()),
+ SSA(SSAMatcher.getInstance()),
+ SSA_STATUS(SSAStatusMatcher.getInstance()),
+ JSON_PATCH(JsonPatchMatcher.getInstance()),
+ JSON_PATCH_STATUS(JsonPatchStatusMatcher.getInstance()),
+ JSON_MERGE_PATCH(JsonMergePatchMatcher.getInstance()),
+ JSON_MERGE_PATCH_STATUS(JsonMergePatchStatusMatcher.getInstance());
+
+ private final Matcher defaultMatcher;
+
+ UpdateType(Matcher defaultMatcher) {
+ this.defaultMatcher = defaultMatcher;
+ }
+
+ public Matcher getMatcher() {
+ return defaultMatcher;
+ }
+}
diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java
index 8d0176cd4a..f5b010d21c 100644
--- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java
+++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java
@@ -17,17 +17,26 @@
import java.util.Collections;
import java.util.List;
+import java.util.Optional;
import java.util.function.UnaryOperator;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.mockito.stubbing.Answer;
+import io.fabric8.kubernetes.api.model.HasMetadata;
import io.fabric8.kubernetes.client.KubernetesClient;
import io.fabric8.kubernetes.client.KubernetesClientException;
import io.fabric8.kubernetes.client.dsl.MixedOperation;
+import io.fabric8.kubernetes.client.dsl.NamespaceableResource;
import io.fabric8.kubernetes.client.dsl.Resource;
+import io.fabric8.kubernetes.client.dsl.base.PatchContext;
+import io.fabric8.kubernetes.client.utils.KubernetesSerialization;
import io.javaoperatorsdk.operator.TestUtils;
+import io.javaoperatorsdk.operator.api.config.Cloner;
+import io.javaoperatorsdk.operator.api.config.ConfigurationService;
import io.javaoperatorsdk.operator.api.config.ControllerConfiguration;
+import io.javaoperatorsdk.operator.api.reconciler.matcher.Matcher;
import io.javaoperatorsdk.operator.processing.event.EventSourceRetriever;
import io.javaoperatorsdk.operator.processing.event.source.EventSource;
import io.javaoperatorsdk.operator.processing.event.source.controller.ControllerEventSource;
@@ -51,6 +60,12 @@ class ResourceOperationsTest {
private ControllerEventSource controllerEventSource;
private ResourceOperations resourceOperations;
+ @SuppressWarnings("rawtypes")
+ private ManagedInformerEventSource verbEventSource;
+
+ @SuppressWarnings("rawtypes")
+ private NamespaceableResource verbClientResource;
+
@BeforeEach
void setupMocks() {
context = mock(Context.class);
@@ -63,9 +78,20 @@ void setupMocks() {
var eventSourceRetriever = mock(EventSourceRetriever.class);
when(context.getClient()).thenReturn(client);
+ when(client.getKubernetesSerialization()).thenReturn(new KubernetesSerialization());
when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever);
when(context.getControllerConfiguration()).thenReturn(controllerConfiguration);
when(controllerConfiguration.getFinalizerName()).thenReturn(FINALIZER_NAME);
+ var configService = mock(ConfigurationService.class);
+ when(controllerConfiguration.getConfigurationService()).thenReturn(configService);
+ when(configService.getResourceCloner())
+ .thenReturn(
+ new Cloner() {
+ @Override
+ public R clone(R object) {
+ return new KubernetesSerialization().clone(object);
+ }
+ });
when(eventSourceRetriever.getControllerEventSource()).thenReturn(controllerEventSource);
when(client.resources(TestCustomResource.class)).thenReturn(mixedOperation);
@@ -110,8 +136,7 @@ void addsFinalizerWithSSA() {
when(context.getPrimaryResource()).thenReturn(resource);
// Mock successful SSA finalizer addition
- when(controllerEventSource.eventFilteringUpdateAndCacheResource(
- any(), any(UnaryOperator.class)))
+ when(controllerEventSource.updateAndCacheResource(any(), any(UnaryOperator.class)))
.thenAnswer(
invocation -> {
var res = TestUtils.testCustomResource1();
@@ -125,8 +150,7 @@ void addsFinalizerWithSSA() {
assertThat(result).isNotNull();
assertThat(result.hasFinalizer(FINALIZER_NAME)).isTrue();
assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2");
- verify(controllerEventSource, times(1))
- .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class));
+ verify(controllerEventSource, times(1)).updateAndCacheResource(any(), any(UnaryOperator.class));
}
@Test
@@ -324,4 +348,305 @@ void resourcePatchThrowsWhenEventSourceIsNotManagedInformer() {
assertThat(exception.getMessage()).contains("Target event source must be a subclass off");
assertThat(exception.getMessage()).contains("ManagedInformerEventSource");
}
+
+ @Test
+ void matcherMatchingSkipsUpdateAndReturnsActual() {
+ var desired = TestUtils.testCustomResource1();
+ var actual = TestUtils.testCustomResource1();
+ var ies = mock(ManagedInformerEventSource.class);
+ var matcher = mock(Matcher.class);
+ when(matcher.matches(desired, actual, context)).thenReturn(true);
+
+ var result =
+ resourceOperations.resourcePatch(
+ desired,
+ actual,
+ UnaryOperator.identity(),
+ ies,
+ ResourceOperations.Options.matchAndFilter(matcher));
+
+ assertThat(result).isSameAs(actual);
+ verify(ies, never()).eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class));
+ verify(ies, never()).updateAndCacheResource(any(), any(UnaryOperator.class));
+ }
+
+ @Test
+ void matcherNotMatchingProceedsToEventFilteringUpdate() {
+ var desired = TestUtils.testCustomResource1();
+ var actual = TestUtils.testCustomResource1();
+ var updated = TestUtils.testCustomResource1();
+ var ies = mock(ManagedInformerEventSource.class);
+ var matcher = mock(Matcher.class);
+ when(matcher.matches(desired, actual, context)).thenReturn(false);
+ when(ies.eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)))
+ .thenReturn(updated);
+
+ var result =
+ resourceOperations.resourcePatch(
+ desired,
+ actual,
+ UnaryOperator.identity(),
+ ies,
+ ResourceOperations.Options.matchAndFilter(matcher));
+
+ assertThat(result).isSameAs(updated);
+ verify(ies, times(1))
+ .eventFilteringUpdateAndCacheResource(eq(desired), any(UnaryOperator.class));
+ }
+
+ @Test
+ void onlyCacheModeSkipsEventFiltering() {
+ var desired = TestUtils.testCustomResource1();
+ var ies = mock(ManagedInformerEventSource.class);
+ when(ies.updateAndCacheResource(any(), any(UnaryOperator.class))).thenReturn(desired);
+
+ resourceOperations.resourcePatch(
+ desired, null, UnaryOperator.identity(), ies, ResourceOperations.Options.cacheOnly());
+
+ verify(ies, times(1)).updateAndCacheResource(eq(desired), any(UnaryOperator.class));
+ verify(ies, never()).eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class));
+ }
+
+ @Test
+ void filterWithOptimisticLockingFiltersWhenResourceVersionPresent() {
+ var desired = TestUtils.testCustomResource1();
+ desired.getMetadata().setResourceVersion("1");
+ var ies = mock(ManagedInformerEventSource.class);
+ when(ies.eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)))
+ .thenReturn(desired);
+
+ resourceOperations.resourcePatch(
+ desired,
+ null,
+ UnaryOperator.identity(),
+ ies,
+ ResourceOperations.Options.filterWithOptimisticLocking());
+
+ verify(ies, times(1))
+ .eventFilteringUpdateAndCacheResource(eq(desired), any(UnaryOperator.class));
+ verify(ies, never()).updateAndCacheResource(any(), any(UnaryOperator.class));
+ }
+
+ @Test
+ void createRejectsMatcher() {
+ var resource = TestUtils.testCustomResource1();
+ var matcher = mock(Matcher.class);
+
+ var exception =
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ resourceOperations.create(resource, ResourceOperations.Options.cacheOnly(matcher)));
+
+ assertThat(exception.getMessage()).contains("does not support matcher");
+ }
+
+ // ---------------------------------------------------------------------------
+ // High-level update / create / patch verbs with different Options variations.
+ // These go through the full public API (resolving the event source from the
+ // retriever and invoking the real client verb), verifying both the routing
+ // (filter vs cache) and which underlying Kubernetes operation is used.
+ // ---------------------------------------------------------------------------
+
+ /**
+ * Wires a {@link ManagedInformerEventSource} into the retriever and a client {@link Resource} so
+ * that both cache paths actually run the update operation, letting us assert which client verb is
+ * invoked. Returns the resource the client verbs are stubbed to return.
+ */
+ @SuppressWarnings("rawtypes")
+ private TestCustomResource wireVerbMocks() {
+ verbEventSource = mock(ManagedInformerEventSource.class);
+ when(context.eventSourceRetriever().getEventSourcesFor(TestCustomResource.class))
+ .thenReturn(List.of(verbEventSource));
+
+ verbClientResource = mock(NamespaceableResource.class);
+ when(context.getClient().resource(any(HasMetadata.class))).thenReturn(verbClientResource);
+
+ var updated = TestUtils.testCustomResource1();
+ updated.getMetadata().setResourceVersion("999");
+ when(verbClientResource.update()).thenReturn(updated);
+ when(verbClientResource.create()).thenReturn(updated);
+ when(verbClientResource.updateStatus()).thenReturn(updated);
+ when(verbClientResource.patch()).thenReturn(updated);
+ when(verbClientResource.patch(any(PatchContext.class))).thenReturn(updated);
+
+ // both cache paths execute the update operation so the underlying client verb runs
+ Answer