diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index e146009885..3298c73bc8 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -6,7 +6,7 @@ on: paths-ignore: - 'docs/**' - 'adr/**' - branches: [ main, next ] + branches: [ main, next, v5.3 ] push: paths-ignore: - 'docs/**' @@ -14,6 +14,7 @@ on: branches: - main - next + - v5.3 jobs: sample_operators_tests: @@ -24,6 +25,7 @@ jobs: - "sample-operators/tomcat-operator" - "sample-operators/webpage" - "sample-operators/leader-election" + - "sample-operators/metrics-processing" runs-on: ubuntu-latest steps: - name: Checkout diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 7a5964ba35..34bc6c7d0f 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -11,6 +11,7 @@ on: paths-ignore: - 'docs/**' - 'adr/**' + - 'observability/**' workflow_dispatch: jobs: check_format_and_unit_tests: diff --git a/bootstrapper-maven-plugin/pom.xml b/bootstrapper-maven-plugin/pom.xml index cd5d69bb8b..20bfa91885 100644 --- a/bootstrapper-maven-plugin/pom.xml +++ b/bootstrapper-maven-plugin/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk java-operator-sdk - 5.2.4-SNAPSHOT + 999-SNAPSHOT bootstrapper diff --git a/bootstrapper-maven-plugin/src/main/resources/templates/pom.xml b/bootstrapper-maven-plugin/src/main/resources/templates/pom.xml index 5d3451a4a6..9631566a29 100644 --- a/bootstrapper-maven-plugin/src/main/resources/templates/pom.xml +++ b/bootstrapper-maven-plugin/src/main/resources/templates/pom.xml @@ -57,7 +57,7 @@ io.javaoperatorsdk - operator-framework-junit-5 + operator-framework-junit ${josdk.version} test diff --git a/caffeine-bounded-cache-support/pom.xml b/caffeine-bounded-cache-support/pom.xml index c1cfea99e1..be70ab9a2e 100644 --- a/caffeine-bounded-cache-support/pom.xml +++ b/caffeine-bounded-cache-support/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.2.4-SNAPSHOT + 999-SNAPSHOT caffeine-bounded-cache-support @@ -43,7 +43,7 @@ io.javaoperatorsdk - operator-framework-junit-5 + operator-framework-junit ${project.version} test diff --git a/docs/content/en/blog/news/primary-cache-for-next-recon.md b/docs/content/en/blog/news/primary-cache-for-next-recon.md index 67326a6f17..be84e08a7e 100644 --- a/docs/content/en/blog/news/primary-cache-for-next-recon.md +++ b/docs/content/en/blog/news/primary-cache-for-next-recon.md @@ -5,6 +5,15 @@ author: >- [Attila Mészáros](https://github.com/csviri) and [Chris Laprun](https://github.com/metacosm) --- +{{% alert title="Deprecated" %}} + +Read-cache-after-write consistency feature replaces this functionality. (since version 5.3.0) + +> It provides this functionality also for secondary resources and optimistic locking +is not required anymore. See [details here](./../../docs/documentation/reconciler.md#read-cache-after-write-consistency-and-event-filtering). +{{% /alert %}} + + We recently released v5.1 of Java Operator SDK (JOSDK). One of the highlights of this release is related to a topic of so-called [allocated values](https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#representing-allocated-values diff --git a/docs/content/en/docs/documentation/configuration.md b/docs/content/en/docs/documentation/configuration.md index 888804628f..34aa639525 100644 --- a/docs/content/en/docs/documentation/configuration.md +++ b/docs/content/en/docs/documentation/configuration.md @@ -149,6 +149,212 @@ For more information on how to use this feature, we recommend looking at how thi `KubernetesDependentResource` in the core framework, `SchemaDependentResource` in the samples or `CustomAnnotationDep` in the `BaseConfigurationServiceTest` test class. -## EventSource-level configuration +## Loading Configuration from External Sources + +JOSDK ships a `ConfigLoader` that bridges any key-value configuration source to the operator and +controller configuration APIs. This lets you drive operator behaviour from environment variables, +system properties, YAML files, or any config library (MicroProfile Config, SmallRye Config, +Spring Environment, etc.) without writing glue code by hand. + +### Architecture + +The system is built around two thin abstractions: + +- **[`ConfigProvider`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigProvider.java)** + — a single-method interface that resolves a typed value for a dot-separated key: + + ```java + public interface ConfigProvider { + Optional getValue(String key, Class type); + } + ``` + +- **[`ConfigLoader`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java)** + — reads all known JOSDK keys from a `ConfigProvider` and returns + `Consumer` / `Consumer>` + values that you pass directly to the `Operator` constructor or `operator.register()`. + +The default `ConfigLoader` (no-arg constructor) stacks environment variables over system +properties: environment variables win, system properties are the fallback. + +```java +// uses env vars + system properties out of the box +Operator operator = new Operator(ConfigLoader.getDefault().applyConfigs()); +``` + +### Built-in Providers + +| Provider | Source | Key mapping | +|---|---|---| +| `EnvVarConfigProvider` | `System.getenv()` | dots and hyphens → underscores, upper-cased (`josdk.check-crd` → `JOSDK_CHECK_CRD`) | +| `PropertiesConfigProvider` | `java.util.Properties` or `.properties` file | key used as-is; use `PropertiesConfigProvider.systemProperties()` to read Java system properties | +| `YamlConfigProvider` | YAML file | dot-separated key traverses nested mappings | +| `AgregatePriorityListConfigProvider` | ordered list of providers | first non-empty result wins | + +All string-based providers convert values to the target type automatically. +Supported types: `String`, `Boolean`, `Integer`, `Long`, `Double`, `Duration` (ISO-8601, e.g. `PT30S`). + +### Plugging in Any Config Library + +`ConfigProvider` is a single-method interface, so adapting any config library takes only a few +lines. As an example, here is an adapter for +[SmallRye Config](https://smallrye.io/smallrye-config/): + +```java +public class SmallRyeConfigProvider implements ConfigProvider { + + private final SmallRyeConfig config; + + public SmallRyeConfigProvider(SmallRyeConfig config) { + this.config = config; + } + + @Override + public Optional getValue(String key, Class type) { + return config.getOptionalValue(key, type); + } +} +``` + +The same pattern applies to MicroProfile Config, Spring `Environment`, Apache Commons +Configuration, or any other library that can look up typed values by string key. + +### Wiring Everything Together + +Pass the `ConfigLoader` results when constructing the operator and registering reconcilers: + +```java +// Load operator-wide config from a YAML file via SmallRye Config +URL configUrl = MyOperator.class.getResource("/application.yaml"); +var configLoader = new ConfigLoader( + new SmallRyeConfigProvider( + new SmallRyeConfigBuilder() + .withSources(new YamlConfigSource(configUrl)) + .build())); + +// applyConfigs() → Consumer +Operator operator = new Operator(configLoader.applyConfigs()); + +// applyControllerConfigs(name) → Consumer> +operator.register(new MyReconciler(), + configLoader.applyControllerConfigs(MyReconciler.NAME)); +``` + +Only keys that are actually present in the source are applied; everything else retains its +programmatic or annotation-based default. + +You can also compose multiple sources with explicit priority using +`AgregatePriorityListConfigProvider`: + +```java +var configLoader = new ConfigLoader( + new AgregatePriorityListConfigProvider(List.of( + new EnvVarConfigProvider(), // highest priority + PropertiesConfigProvider.systemProperties(), + new YamlConfigProvider(Path.of("config/operator.yaml")) // lowest priority + ))); +``` + +### Operator-Level Configuration Keys + +All operator-level keys are prefixed with `josdk.`. + +#### General + +| Key | Type | Description | +|---|---|---| +| `josdk.check-crd` | `Boolean` | Validate CRDs against local model on startup | +| `josdk.close-client-on-stop` | `Boolean` | Close the Kubernetes client when the operator stops | +| `josdk.use-ssa-to-patch-primary-resource` | `Boolean` | Use Server-Side Apply to patch the primary resource | +| `josdk.clone-secondary-resources-when-getting-from-cache` | `Boolean` | Clone secondary resources on cache reads | + +#### Reconciliation + +| Key | Type | Description | +|---|---|---| +| `josdk.reconciliation.concurrent-threads` | `Integer` | Thread pool size for reconciliation | +| `josdk.reconciliation.termination-timeout` | `Duration` | How long to wait for in-flight reconciliations to finish on shutdown | + +#### Workflow + +| Key | Type | Description | +|---|---|---| +| `josdk.workflow.executor-threads` | `Integer` | Thread pool size for workflow execution | + +#### Informer + +| Key | Type | Description | +|---|---|---| +| `josdk.informer.cache-sync-timeout` | `Duration` | Timeout for the initial informer cache sync | +| `josdk.informer.stop-on-error-during-startup` | `Boolean` | Stop the operator if an informer fails to start | + +#### Dependent Resources + +| Key | Type | Description | +|---|---|---| +| `josdk.dependent-resources.ssa-based-create-update-match` | `Boolean` | Use SSA-based matching for dependent resource create/update | + +#### Leader Election + +Leader election is activated when at least one `josdk.leader-election.*` key is present. +`josdk.leader-election.lease-name` is required when any other leader-election key is set. +Setting `josdk.leader-election.enabled=false` suppresses leader election even if other keys are +present. + +| Key | Type | Description | +|---|---|---| +| `josdk.leader-election.enabled` | `Boolean` | Explicitly enable (`true`) or disable (`false`) leader election | +| `josdk.leader-election.lease-name` | `String` | **Required.** Name of the Kubernetes Lease object used for leader election | +| `josdk.leader-election.lease-namespace` | `String` | Namespace for the Lease object (defaults to the operator's namespace) | +| `josdk.leader-election.identity` | `String` | Unique identity for this instance; defaults to the pod name | +| `josdk.leader-election.lease-duration` | `Duration` | How long a lease is valid (default `PT15S`) | +| `josdk.leader-election.renew-deadline` | `Duration` | How long the leader tries to renew before giving up (default `PT10S`) | +| `josdk.leader-election.retry-period` | `Duration` | How often a candidate polls while waiting to become leader (default `PT2S`) | + +### Controller-Level Configuration Keys + +All controller-level keys are prefixed with `josdk.controller..`, where +`` is the value returned by the reconciler's name (typically set via +`@ControllerConfiguration(name = "...")`). + +#### General + +| Key | Type | Description | +|---|---|---| +| `josdk.controller..finalizer` | `String` | Finalizer string added to managed resources | +| `josdk.controller..generation-aware` | `Boolean` | Skip reconciliation when the resource generation has not changed | +| `josdk.controller..label-selector` | `String` | Label selector to filter watched resources | +| `josdk.controller..max-reconciliation-interval` | `Duration` | Maximum interval between reconciliations even without events | +| `josdk.controller..field-manager` | `String` | Field manager name used for SSA operations | +| `josdk.controller..trigger-reconciler-on-all-events` | `Boolean` | Trigger reconciliation on every event, not only meaningful changes | + +#### Informer + +| Key | Type | Description | +|---|---|---| +| `josdk.controller..informer.label-selector` | `String` | Label selector for the primary resource informer (alias for `label-selector`) | +| `josdk.controller..informer.list-limit` | `Long` | Page size for paginated informer list requests; omit for no pagination | + +#### Retry + +If any `retry.*` key is present, a `GenericRetry` is configured starting from the +[default limited exponential retry](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/GenericRetry.java). +Only explicitly set keys override the defaults. + +| Key | Type | Description | +|---|---|---| +| `josdk.controller..retry.max-attempts` | `Integer` | Maximum number of retry attempts | +| `josdk.controller..retry.initial-interval` | `Long` (ms) | Initial backoff interval in milliseconds | +| `josdk.controller..retry.interval-multiplier` | `Double` | Exponential backoff multiplier | +| `josdk.controller..retry.max-interval` | `Long` (ms) | Maximum backoff interval in milliseconds | + +#### Rate Limiter + +The rate limiter is only activated when `rate-limiter.limit-for-period` is present and has a +positive value. `rate-limiter.refresh-period` is optional and falls back to the default of 10 s. + +| Key | Type | Description | +|---|---|---| +| `josdk.controller..rate-limiter.limit-for-period` | `Integer` | Maximum number of reconciliations allowed per refresh period. Must be positive to activate the limiter | +| `josdk.controller..rate-limiter.refresh-period` | `Duration` | Window over which the limit is counted (default `PT10S`) | -TODO diff --git a/docs/content/en/docs/documentation/observability.md b/docs/content/en/docs/documentation/observability.md index 312c31967e..dc32f790c1 100644 --- a/docs/content/en/docs/documentation/observability.md +++ b/docs/content/en/docs/documentation/observability.md @@ -33,6 +33,35 @@ parts of reconciliation logic and during the execution of the controller: For more information about MDC see this [link](https://www.baeldung.com/mdc-in-log4j-2-logback). +### MDC entries during event handling + +Although, usually users might not require it in their day-to-day workflow, it is worth mentioning that +there are additional MDC entries managed for event handling. Typically, you might be interested in it +in your `SecondaryToPrimaryMapper` related logs. +For `InformerEventSource` and `ControllerEventSource` the following information is present: + +| MDC Key | Value from Resource from the Event | +|:-----------------------------------------------|:-------------------------------------------------| +| `eventsource.event.resource.name` | `.metadata.name` | +| `eventsource.event.resource.uid` | `.metadata.uid` | +| `eventsource.event.resource.namespace` | `.metadata.namespace` | +| `eventsource.event.resource.kind` | resource kind | +| `eventsource.event.resource.resourceVersion` | `.metadata.resourceVersion` | +| `eventsource.event.action` | action name (e.g. `ADDED`, `UPDATED`, `DELETED`) | +| `eventsource.name` | name of the event source | + +### Note on null values + +If a resource doesn't provide values for one of the specified keys, the key will be omitted and not added to the MDC +context. There is, however, one notable exception: the resource's namespace, where, instead of omitting the key, we emit +the `MDCUtils.NO_NAMESPACE` value instead. This allows searching for resources without namespace (notably, clustered +resources) in the logs more easily. + +### Disabling MDC support + +MDC support is enabled by default. If you want to disable it, you can set the `JAVA_OPERATOR_SDK_USE_MDC` environment +variable to `false` when you start your operator. + ## Metrics JOSDK provides built-in support for metrics reporting on what is happening with your reconcilers in the form of @@ -48,30 +77,108 @@ Metrics metrics; // initialize your metrics implementation Operator operator = new Operator(client, o -> o.withMetrics(metrics)); ``` -### Micrometer implementation +### MicrometerMetricsV2 -The micrometer implementation is typically created using one of the provided factory methods which, depending on which -is used, will return either a ready to use instance or a builder allowing users to customize how the implementation -behaves, in particular when it comes to the granularity of collected metrics. It is, for example, possible to collect -metrics on a per-resource basis via tags that are associated with meters. This is the default, historical behavior but -this will change in a future version of JOSDK because this dramatically increases the cardinality of metrics, which -could lead to performance issues. +[`MicrometerMetricsV2`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetricsV2.java) +is the recommended micrometer-based implementation. It is designed with low cardinality in mind: +all meters are scoped to the controller, not to individual resources. This avoids unbounded cardinality growth as +resources come and go. -To create a `MicrometerMetrics` implementation that behaves how it has historically behaved, you can just create an -instance via: +The simplest way to create an instance: ```java MeterRegistry registry; // initialize your registry implementation -Metrics metrics = MicrometerMetrics.newMicrometerMetricsBuilder(registry).build(); +Metrics metrics = MicrometerMetricsV2.newMicrometerMetricsV2Builder(registry).build(); ``` -The class provides factory methods which either return a fully pre-configured instance or a builder object that will -allow you to configure more easily how the instance will behave. You can, for example, configure whether the -implementation should collect metrics on a per-resource basis, whether associated meters should be removed when a -resource is deleted and how the clean-up is performed. See the relevant classes documentation for more details. +Optionally, include a `namespace` tag on per-reconciliation counters (disabled by default to avoid unexpected +cardinality increases in existing deployments): + +```java +Metrics metrics = MicrometerMetricsV2.newMicrometerMetricsV2Builder(registry) + .withNamespaceAsTag() + .build(); +``` + +You can also supply a custom timer configuration for `reconciliations.execution.duration`: + +```java +Metrics metrics = MicrometerMetricsV2.newMicrometerMetricsV2Builder(registry) + .withExecutionTimerConfig(builder -> builder.publishPercentiles(0.5, 0.95, 0.99)) + .build(); +``` + +#### MicrometerMetricsV2 metrics + +All meters use `controller.name` as their primary tag. Counters optionally carry a `namespace` tag when +`withNamespaceAsTag()` is enabled. + +| Meter name (Micrometer) | Type | Tags | Description | +|--------------------------------------|---------|---------------------------------------------------|------------------------------------------------------------------| +| `reconciliations.active` | gauge | `controller.name` | Number of reconciler executions currently executing | +| `reconciliations.queue` | gauge | `controller.name` | Number of resources currently queued for reconciliation | +| `custom_resources` | gauge | `controller.name` | Number of custom resources tracked by the controller | +| `reconciliations.execution.duration` | timer | `controller.name` | Reconciliation execution duration with explicit bucket histogram | +| `reconciliations.started.total` | counter | `controller.name`, `namespace`* | Number of reconciliations started (including retries) | +| `reconciliations.success.total` | counter | `controller.name`, `namespace`* | Number of successfully finished reconciliations | +| `reconciliations.failure.total` | counter | `controller.name`, `namespace`* | Number of failed reconciliations | +| `reconciliations.retries.total` | counter | `controller.name`, `namespace`* | Number of reconciliation retries | +| `events.received` | counter | `controller.name`, `event`, `action`, `namespace` | Number of Kubernetes events received by the controller | + +\* `namespace` tag is only included when `withNamespaceAsTag()` is enabled. + +The execution timer uses explicit boundaries (10ms, 50ms, 100ms, 250ms, 500ms, 1s, 2s, 5s, 10s, 30s) to ensure +compatibility with `histogram_quantile()` queries in Prometheus. This is important when using the OpenTelemetry Protocol (OTLP) registry, where +`publishPercentileHistogram()` would otherwise produce Base2 Exponential Histograms that are incompatible with classic +`_bucket` queries. + +> **Note on Prometheus metric names**: The exact Prometheus metric name suffix depends on the `MeterRegistry` in use. +> For `PrometheusMeterRegistry` the timer is exposed as `reconciliations_execution_duration_seconds_*`. For +> `OtlpMeterRegistry` (metrics exported via OpenTelemetry Collector), it is exposed as +> `reconciliations_execution_duration_milliseconds_*`. + +#### Grafana Dashboard + +A ready-to-use Grafana dashboard is available at +[`observability/josdk-operator-metrics-dashboard.json`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/observability/josdk-operator-metrics-dashboard.json). +It visualizes all of the metrics listed above, including reconciliation throughput, error rates, queue depth, active +executions, resource counts, and execution duration histograms and heatmaps. + +The dashboard is designed to work with metrics exported via OpenTelemetry Collector to Prometheus, as set up by the +observability sample (see below). + +#### Exploring metrics end-to-end + +The +[`metrics-processing` sample operator](https://github.com/java-operator-sdk/java-operator-sdk/tree/main/sample-operators/metrics-processing) +includes a full end-to-end test, +[`MetricsHandlingE2E`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/sample-operators/metrics-processing/src/test/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingE2E.java), +that: + +1. Installs a local observability stack (Prometheus, Grafana, OpenTelemetry Collector) via + `observability/install-observability.sh`. That imports also the Grafana dashboards. +2. Runs two reconcilers that produce both successful and failing reconciliations over a sustained period +3. Verifies that the expected metrics appear in Prometheus + +This is a good starting point for experimenting with the metrics and the Grafana dashboard in a real cluster without +having to deploy your own operator. + +### MicrometerMetrics (Deprecated) + +> **Deprecated**: `MicrometerMetrics` (V1) is deprecated as of JOSDK 5.3.0. Use `MicrometerMetricsV2` instead. +> V1 attaches resource-specific metadata (name, namespace, etc.) as tags to every meter, which causes unbounded +> cardinality growth and can lead to performance issues in your metrics backend. + +The legacy `MicrometerMetrics` implementation is still available. To create an instance that behaves as it historically +has: + +```java +MeterRegistry registry; // initialize your registry implementation +Metrics metrics = MicrometerMetrics.newMicrometerMetricsBuilder(registry).build(); +``` -For example, the following will create a `MicrometerMetrics` instance configured to collect metrics on a per-resource -basis, deleting the associated meters after 5 seconds when a resource is deleted, using up to 2 threads to do so. +To collect metrics on a per-resource basis, deleting the associated meters after 5 seconds when a resource is deleted, +using up to 2 threads: ```java MicrometerMetrics.newPerResourceCollectingMicrometerMetricsBuilder(registry) @@ -80,9 +187,9 @@ MicrometerMetrics.newPerResourceCollectingMicrometerMetricsBuilder(registry) .build(); ``` -### Operator SDK metrics +#### Operator SDK metrics (V1) -The micrometer implementation records the following metrics: +The V1 micrometer implementation records the following metrics: | Meter name | Type | Tag names | Description | |-------------------------------------------------------------|----------------|-------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------| @@ -101,12 +208,11 @@ The micrometer implementation records the following metrics: | operator.sdk.controllers.execution.cleanup.success | counter | controller, type | Number of successful cleanups per controller | | operator.sdk.controllers.execution.cleanup.failure | counter | controller, exception | Number of failed cleanups per controller | -As you can see all the recorded metrics start with the `operator.sdk` prefix. ``, in the table above, -refers to resource-specific metadata and depends on the considered metric and how the implementation is configured and -could be summed up as follows: `group?, version, kind, [name, namespace?], scope` where the tags in square -brackets (`[]`) won't be present when per-resource collection is disabled and tags followed by a question mark are -omitted if the associated value is empty. Of note, when in the context of controllers' execution metrics, these tag -names are prefixed with `resource.`. This prefix might be removed in a future version for greater consistency. +All V1 metrics start with the `operator.sdk` prefix. `` refers to resource-specific metadata and +depends on the considered metric and how the implementation is configured: `group?, version, kind, [name, namespace?], +scope` where tags in square brackets (`[]`) won't be present when per-resource collection is disabled and tags followed +by a question mark are omitted if the value is empty. In the context of controllers' execution metrics, these tag names +are prefixed with `resource.`. ### Aggregated Metrics diff --git a/docs/content/en/docs/documentation/reconciler.md b/docs/content/en/docs/documentation/reconciler.md index a1e3e802a3..81af350115 100644 --- a/docs/content/en/docs/documentation/reconciler.md +++ b/docs/content/en/docs/documentation/reconciler.md @@ -160,7 +160,84 @@ annotation. If you do not specify a finalizer name, one will be automatically ge From v5, by default, the finalizer is added using Server Side Apply. See also `UpdateControl` in docs. -### Making sure the primary resource is up to date for the next reconciliation +### Read-cache-after-write consistency and event filtering + +It is an inherent issue with Informers that their caches are eventually consistent even +with the updates to Kubernetes API done from the controller. From version 5.3.0 the framework +supports stronger guarantees, both for primary and secondary resources. If this feature is used: + +1. Reading from the cache after our update — even within the same reconciliation — returns a fresh resource. + "Fresh" means at least the version of the resource that is in the response from our update, + or a more recent version if some other party updated the resource after our update. In particular, this means that + you can safely store state (e.g. generated IDs) in the status sub-resource of your resources since it is now + guaranteed that the stored values will be observable during the next reconciliation. +2. Filtering events for our updates. When a controller updates a resource an event is produced by the Kubernetes API and + propagated to Informer. This would normally trigger another reconciliation. This is, however, not optimal since we + already have that up-to-date resource in the current reconciliation cache. There is generally no need to reconcile + that resource again. This feature also makes sure that the reconciliation is not triggered from the event from our + writes. + + +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: + +```java + +public UpdateControl reconcile(WebPage webPage, Context context) { + + ConfigMap managedConfigMap = prepareConfigMap(webPage); + // filtering and caching update + context.resourceOperations().serverSideApply(managedConfigMap); + + // fresh resource instantly available from our update in the cache + var upToDateResource = context.getSecondaryResource(ConfigMap.class); + + makeStatusChanges(webPage); + + // built in update methods by default use this feature + 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`: + +```java + +public UpdateControl reconcile(WebPage webPage, Context context) { + + makeStatusChanges(webPage); + // this is equivalent to UpdateControl.patchStatus(webpage) + context.resourceOperations().serverSideApplyPrimaryStatus(webPage); + return UpdateControl.noUpdate(); +} +``` + +If your reconciler is built around the assumption that new reconciliations would occur after its own updates, a new +`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. +- We were able to implement this feature since Kubernetes introduces guideline to compare + resource versions. See the details [here](https://github.com/kubernetes/enhancements/tree/master/keps/sig-api-machinery/5504-comparable-resource-version). + +### Making sure the primary resource is up to date for the next reconciliation (deprecated) + +{{% alert title="Deprecated" %}} + +Read-cache-after-write consistency feature replaces this functionality. + +> It provides this functionality also for secondary resources and optimistic locking is not required anymore. See details above. +{{% /alert %}} It is typical to want to update the status subresource with the information that is available during the reconciliation. This is sometimes referred to as the last observed state. When the primary resource is updated, though, the framework @@ -263,30 +340,30 @@ See also [sample](https://github.com/operator-framework/java-operator-sdk/blob/m ### Expectations Expectations are a pattern to ensure that, during reconciliation, your secondary resources are in a certain state. -For a more detailed explanation see [this blogpost](https://ahmet.im/blog/controller-pitfalls/#expectations-pattern). -You can find framework support for this pattern in [`io.javaoperatorsdk.operator.processing.expectation`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/) -package. See also related [integration test](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationReconciler.java). -Note that this feature is marked as `@Experimental`, since based on feedback the API might be improved / changed, but we intend -to support it, later also might be integrated to Dependent Resources and/or Workflows. +For a more detailed explanation, see [this blogpost](https://ahmet.im/blog/controller-pitfalls/#expectations-pattern). +You can find framework support for this pattern in the [`io.javaoperatorsdk.operator.processing.expectation`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/) +package. See also the related [integration test](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/onallevent/ExpectationReconciler.java). +Note that this feature is marked as `@Experimental`: based on feedback the API may be improved or changed, but we intend +to keep supporting it and may later integrate it into Dependent Resources and/or Workflows. -The idea is the nutshell, is that you can track your expectations in the expectation manager in the reconciler -which has an API that covers the common use cases. +The idea, in a nutshell, is that you can track your expectations in the expectation manager within the reconciler, +which provides an API covering the common use cases. -The following sample is the simplified version of the integration test that implements the logic that creates a -deployment and sets status message if there are the target three replicas ready: +The following is a simplified version of the integration test that implements the logic to create a +deployment and set a status message once the target three replicas are ready: ```java public class ExpectationReconciler implements Reconciler { // some code is omitted - + private final ExpectationManager expectationManager = new ExpectationManager<>(); @Override public UpdateControl reconcile( ExpectationCustomResource primary, Context context) { - // exiting asap if there is an expectation that is not timed out neither fulfilled yet + // exit early if there is an expectation that has not yet timed out or been fulfilled if (expectationManager.ongoingExpectationPresent(primary, context)) { return UpdateControl.noUpdate(); } @@ -299,7 +376,7 @@ public class ExpectationReconciler implements Reconciler + io.javaoperatorsdk + operator-framework-junit-5 + 5.2.x + test + +``` + +to + +``` + + io.javaoperatorsdk + operator-framework-junit + 5.3.0 + test + +``` + +## Metrics interface changes + +The [Metrics](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/Metrics.java) +interface changed in non backwards compatible way, in order to make the API cleaner: + +The following table shows the relevant method renames: + +| v5.2 method | v5.3 method | +|------------------------------------|------------------------------| +| `reconcileCustomResource` | `reconciliationSubmitted` | +| `reconciliationExecutionStarted` | `reconciliationStarted` | +| `reconciliationExecutionFinished` | `reconciliationSucceeded` | +| `failedReconciliation` | `reconciliationFailed` | +| `finishedReconciliation` | `reconciliationFinished` | +| `cleanupDoneFor` | `cleanupDone` | +| `receivedEvent` | `eventReceived` | + + +Other changes: +- `reconciliationFinished(..)` method is extended with `RetryInfo` +- `monitorSizeOf(..)` method is removed. \ No newline at end of file diff --git a/micrometer-support/pom.xml b/micrometer-support/pom.xml index 0dc734be3b..ae3c4d0be1 100644 --- a/micrometer-support/pom.xml +++ b/micrometer-support/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.2.4-SNAPSHOT + 999-SNAPSHOT micrometer-support @@ -58,7 +58,7 @@ io.javaoperatorsdk - operator-framework-junit-5 + operator-framework-junit ${project.version} test 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 7beabb7a6e..2499b2f131 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 @@ -39,6 +39,10 @@ import static io.javaoperatorsdk.operator.api.reconciler.Constants.CONTROLLER_NAME; +/** + * @deprecated Use {@link MicrometerMetricsV2} instead + */ +@Deprecated(forRemoval = true) public class MicrometerMetrics implements Metrics { private static final String PREFIX = "operator.sdk."; @@ -68,7 +72,6 @@ public class MicrometerMetrics implements Metrics { private static final String EVENTS_RECEIVED = "events.received"; private static final String EVENTS_DELETE = "events.delete"; private static final String CLUSTER = "cluster"; - private static final String SIZE_SUFFIX = ".size"; private static final String UNKNOWN_ACTION = "UNKNOWN"; private final boolean collectPerResourceMetrics; private final MeterRegistry registry; @@ -182,7 +185,7 @@ public T timeControllerExecution(ControllerExecution execution) { } @Override - public void receivedEvent(Event event, Map metadata) { + public void eventReceived(Event event, Map metadata) { if (event instanceof ResourceEvent) { incrementCounter( event.getRelatedCustomResourceID(), @@ -201,14 +204,14 @@ public void receivedEvent(Event event, Map metadata) { } @Override - public void cleanupDoneFor(ResourceID resourceID, Map metadata) { + public void cleanupDone(ResourceID resourceID, Map metadata) { incrementCounter(resourceID, EVENTS_DELETE, metadata); cleaner.removeMetersFor(resourceID); } @Override - public void reconcileCustomResource( + public void reconciliationSubmitted( HasMetadata resource, RetryInfo retryInfoNullable, Map metadata) { Optional retryInfo = Optional.ofNullable(retryInfoNullable); incrementCounter( @@ -228,19 +231,20 @@ public void reconcileCustomResource( } @Override - public void finishedReconciliation(HasMetadata resource, Map metadata) { + public void reconciliationSucceeded(HasMetadata resource, Map metadata) { incrementCounter(ResourceID.fromResource(resource), RECONCILIATIONS_SUCCESS, metadata); } @Override - public void reconciliationExecutionStarted(HasMetadata resource, Map metadata) { + public void reconciliationStarted(HasMetadata resource, Map metadata) { var reconcilerExecutions = gauges.get(RECONCILIATIONS_EXECUTIONS + metadata.get(CONTROLLER_NAME)); reconcilerExecutions.incrementAndGet(); } @Override - public void reconciliationExecutionFinished(HasMetadata resource, Map metadata) { + public void reconciliationFinished( + HasMetadata resource, RetryInfo retryInfo, Map metadata) { var reconcilerExecutions = gauges.get(RECONCILIATIONS_EXECUTIONS + metadata.get(CONTROLLER_NAME)); reconcilerExecutions.decrementAndGet(); @@ -251,8 +255,8 @@ public void reconciliationExecutionFinished(HasMetadata resource, Map metadata) { + public void reconciliationFailed( + HasMetadata resource, RetryInfo retry, Exception exception, Map metadata) { var cause = exception.getCause(); if (cause == null) { cause = exception; @@ -266,11 +270,6 @@ public void failedReconciliation( Tag.of(EXCEPTION, cause.getClass().getSimpleName())); } - @Override - public > T monitorSizeOf(T map, String name) { - return registry.gaugeMapSize(PREFIX + name + SIZE_SUFFIX, Collections.emptyList(), map); - } - private void addMetadataTags( ResourceID resourceID, Map metadata, List tags, boolean prefixed) { if (collectPerResourceMetrics) { 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 new file mode 100644 index 0000000000..f2eadd21aa --- /dev/null +++ b/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetricsV2.java @@ -0,0 +1,316 @@ +/* + * 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.monitoring.micrometer; + +import java.time.Duration; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.function.Function; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.OperatorException; +import io.javaoperatorsdk.operator.api.monitoring.Metrics; +import io.javaoperatorsdk.operator.api.reconciler.Constants; +import io.javaoperatorsdk.operator.api.reconciler.RetryInfo; +import io.javaoperatorsdk.operator.processing.Controller; +import io.javaoperatorsdk.operator.processing.event.Event; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; +import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEvent; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Timer; + +/** + * @since 5.3.0 + */ +public class MicrometerMetricsV2 implements Metrics { + + private static final String CONTROLLER_NAME = "controller.name"; + private static final String NAMESPACE = "namespace"; + private static final String EVENT = "event"; + private static final String ACTION = "action"; + private static final String EVENTS_RECEIVED = "events.received"; + public static final String TOTAL_SUFFIX = ".total"; + + private static final String RECONCILIATIONS = "reconciliations."; + + public static final String RECONCILIATIONS_FAILED = RECONCILIATIONS + "failure" + TOTAL_SUFFIX; + public static final String RECONCILIATIONS_SUCCESS = RECONCILIATIONS + "success" + TOTAL_SUFFIX; + public static final String RECONCILIATIONS_RETRIES_NUMBER = + RECONCILIATIONS + "retries" + TOTAL_SUFFIX; + public static final String RECONCILIATIONS_STARTED = RECONCILIATIONS + "started" + TOTAL_SUFFIX; + + 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 RECONCILIATION_EXECUTION_DURATION = + RECONCILIATIONS + "execution.duration"; + + private final MeterRegistry registry; + private final Map gauges = new ConcurrentHashMap<>(); + private final Map executionTimers = new ConcurrentHashMap<>(); + private final Function timerConfig; + private final boolean includeNamespaceTag; + + /** + * Creates a new builder to configure how the eventual MicrometerMetricsV2 instance will behave, + * pre-configuring it to collect metrics per resource. + * + * @param registry the {@link MeterRegistry} instance to use for metrics recording + * @return a MicrometerMetricsV2 instance configured to not collect per-resource metrics + * @see MicrometerMetricsV2Builder + */ + public static MicrometerMetricsV2Builder newMicrometerMetricsV2Builder(MeterRegistry registry) { + return new MicrometerMetricsV2Builder(registry); + } + + /** + * Creates a micrometer-based Metrics implementation. + * + * @param registry the {@link MeterRegistry} instance to use for metrics recording + * @param timerConfig optional configuration for timers, defaults to publishing percentiles 0.5, + * 0.95, 0.99 and histogram + */ + private MicrometerMetricsV2( + MeterRegistry registry, Consumer timerConfig, boolean includeNamespaceTag) { + this.registry = registry; + this.includeNamespaceTag = includeNamespaceTag; + this.timerConfig = + timerConfig != null + ? builder -> { + timerConfig.accept(builder); + return builder; + } + // Use explicit SLO buckets rather than publishPercentileHistogram(). When using + // OtlpMeterRegistry (Micrometer 1.12+), publishPercentileHistogram() sends Base2 + // Exponential Histograms over OTLP, which the OTel collector exposes as Prometheus + // native histograms — incompatible with histogram_quantile() and classic _bucket + // queries. Explicit SLO boundaries force EXPLICIT_BUCKET_HISTOGRAM format, which the + // collector reliably exposes as _bucket metrics. + : builder -> + builder.serviceLevelObjectives( + Duration.ofMillis(10), + Duration.ofMillis(50), + Duration.ofMillis(100), + Duration.ofMillis(250), + Duration.ofMillis(500), + Duration.ofSeconds(1), + Duration.ofSeconds(2), + Duration.ofSeconds(5), + Duration.ofSeconds(10), + Duration.ofSeconds(30)); + } + + @Override + public void controllerRegistered(Controller controller) { + final var configuration = controller.getConfiguration(); + final var name = configuration.getName(); + final var executingThreadsRefName = reconciliationExecutionGaugeRefKey(name); + final var tags = new ArrayList(); + addControllerNameTag(name, tags); + AtomicInteger executingThreads = + registry.gauge(RECONCILIATIONS_EXECUTIONS_GAUGE, tags, new AtomicInteger(0)); + gauges.put(executingThreadsRefName, executingThreads); + + final var controllerQueueRefName = controllerQueueSizeGaugeRefKey(name); + AtomicInteger controllerQueueSize = + registry.gauge(RECONCILIATIONS_QUEUE_SIZE_GAUGE, tags, new AtomicInteger(0)); + gauges.put(controllerQueueRefName, controllerQueueSize); + + var numberOfResources = registry.gauge(NUMBER_OF_RESOURCE_GAUGE, tags, new AtomicInteger(0)); + gauges.put(numberOfResourcesRefName(name), numberOfResources); + + var timerBuilder = Timer.builder(RECONCILIATION_EXECUTION_DURATION).tags(tags); + timerBuilder = timerConfig.apply(timerBuilder); + var timer = timerBuilder.register(registry); + executionTimers.put(name, timer); + } + + private String numberOfResourcesRefName(String name) { + return NUMBER_OF_RESOURCE_GAUGE + name; + } + + @Override + public T timeControllerExecution(ControllerExecution execution) { + final var name = execution.controllerName(); + final var timer = executionTimers.get(name); + return timer.record( + () -> { + try { + return execution.execute(); + } catch (Exception e) { + throw new OperatorException(e); + } + }); + } + + @Override + public void eventReceived(Event event, Map metadata) { + if (event instanceof ResourceEvent resourceEvent) { + if (resourceEvent.getAction() == ResourceAction.ADDED) { + gauges.get(numberOfResourcesRefName(getControllerName(metadata))).incrementAndGet(); + } + var namespace = resourceEvent.getRelatedCustomResourceID().getNamespace().orElse(null); + incrementCounter( + EVENTS_RECEIVED, + namespace, + metadata, + Tag.of(EVENT, event.getClass().getSimpleName()), + Tag.of(ACTION, resourceEvent.getAction().toString())); + } else { + incrementCounter( + EVENTS_RECEIVED, null, metadata, Tag.of(EVENT, event.getClass().getSimpleName())); + } + } + + @Override + public void cleanupDone(ResourceID resourceID, Map metadata) { + gauges.get(numberOfResourcesRefName(getControllerName(metadata))).decrementAndGet(); + } + + @Override + public void reconciliationSubmitted( + HasMetadata resource, RetryInfo retryInfoNullable, Map metadata) { + Optional retryInfo = Optional.ofNullable(retryInfoNullable); + + var namespace = resource.getMetadata().getNamespace(); + incrementCounter(RECONCILIATIONS_STARTED, namespace, metadata); + + int retryNumber = retryInfo.map(RetryInfo::getAttemptCount).orElse(0); + if (retryNumber > 0) { + incrementCounter(RECONCILIATIONS_RETRIES_NUMBER, namespace, metadata); + } + + var controllerQueueSize = + gauges.get(controllerQueueSizeGaugeRefKey(getControllerName(metadata))); + controllerQueueSize.incrementAndGet(); + } + + @Override + public void reconciliationSucceeded(HasMetadata resource, Map metadata) { + incrementCounter(RECONCILIATIONS_SUCCESS, resource.getMetadata().getNamespace(), metadata); + } + + @Override + public void reconciliationStarted(HasMetadata resource, Map metadata) { + final var controllerName = getControllerName(metadata); + var reconcilerExecutions = gauges.get(reconciliationExecutionGaugeRefKey(controllerName)); + reconcilerExecutions.incrementAndGet(); + var controllerQueueSize = gauges.get(controllerQueueSizeGaugeRefKey(controllerName)); + controllerQueueSize.decrementAndGet(); + } + + @Override + public void reconciliationFinished( + HasMetadata resource, RetryInfo retryInfo, Map metadata) { + var reconcilerExecutions = + gauges.get(reconciliationExecutionGaugeRefKey(getControllerName(metadata))); + reconcilerExecutions.decrementAndGet(); + } + + @Override + public void reconciliationFailed( + HasMetadata resource, RetryInfo retry, Exception exception, Map metadata) { + incrementCounter(RECONCILIATIONS_FAILED, resource.getMetadata().getNamespace(), metadata); + } + + private static void addTag(String name, String value, List tags) { + tags.add(Tag.of(name, value)); + } + + private static void addControllerNameTag(Map metadata, List tags) { + addControllerNameTag(getControllerName(metadata), tags); + } + + private static void addControllerNameTag(String name, List tags) { + addTag(CONTROLLER_NAME, name, tags); + } + + private void addNamespaceTag(String namespace, List tags) { + if (includeNamespaceTag && namespace != null && !namespace.isBlank()) { + addTag(NAMESPACE, namespace, tags); + } + } + + private void incrementCounter( + String counterName, String namespace, Map metadata, Tag... additionalTags) { + final var tags = new ArrayList(2 + additionalTags.length); + addControllerNameTag(metadata, tags); + addNamespaceTag(namespace, tags); + if (additionalTags.length > 0) { + Collections.addAll(tags, additionalTags); + } + registry.counter(counterName, tags).increment(); + } + + private static String reconciliationExecutionGaugeRefKey(String controllerName) { + return RECONCILIATIONS_EXECUTIONS_GAUGE + "." + controllerName; + } + + private static String controllerQueueSizeGaugeRefKey(String controllerName) { + return RECONCILIATIONS_QUEUE_SIZE_GAUGE + "." + controllerName; + } + + public static String getControllerName(Map metadata) { + return (String) metadata.get(Constants.CONTROLLER_NAME); + } + + public static class MicrometerMetricsV2Builder { + protected final MeterRegistry registry; + protected Consumer executionTimerConfig = null; + protected boolean includeNamespaceTag = false; + + public MicrometerMetricsV2Builder(MeterRegistry registry) { + this.registry = registry; + } + + /** + * Configures the Timer used for timing controller executions. By default, timers are configured + * to publish percentiles 0.5, 0.95, 0.99 and a percentile histogram. + * + * @param executionTimerConfig a consumer that will configure the Timer.Builder. The builder + * will already have the metric name and tags set. + * @return this builder for method chaining + */ + public MicrometerMetricsV2Builder withExecutionTimerConfig( + Consumer executionTimerConfig) { + this.executionTimerConfig = executionTimerConfig; + return this; + } + + /** + * When enabled, a {@code namespace} tag is added to all per-reconciliation counters (started, + * success, failure, retries, events, deletes). Gauges remain controller-scoped because + * namespaces are not known at controller registration time. + * + *

Disabled by default to avoid unexpected cardinality increases in existing deployments. + * + * @return this builder for method chaining + */ + public MicrometerMetricsV2Builder withNamespaceAsTag() { + this.includeNamespaceTag = true; + return this; + } + + public MicrometerMetricsV2 build() { + return new MicrometerMetricsV2(registry, executionTimerConfig, includeNamespaceTag); + } + } +} diff --git a/observability/install-observability.sh b/observability/install-observability.sh new file mode 100755 index 0000000000..f9aacf85e7 --- /dev/null +++ b/observability/install-observability.sh @@ -0,0 +1,312 @@ +#!/bin/bash +# +# 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. +# + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN}Installing Observability Stack${NC}" +echo -e "${GREEN}OpenTelemetry + Prometheus + Grafana${NC}" +echo -e "${GREEN}========================================${NC}" + +# Check if helm is installed, download locally if not +echo -e "\n${YELLOW}Checking helm installation...${NC}" +if ! command -v helm &> /dev/null; then + echo -e "${YELLOW}helm not found, downloading locally...${NC}" + HELM_INSTALL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.helm" + mkdir -p "$HELM_INSTALL_DIR" + HELM_BIN="$HELM_INSTALL_DIR/helm" + if [ ! -f "$HELM_BIN" ]; then + curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 \ + | HELM_INSTALL_DIR="$HELM_INSTALL_DIR" USE_SUDO=false bash + fi + export PATH="$HELM_INSTALL_DIR:$PATH" + echo -e "${GREEN}✓ helm downloaded to $HELM_BIN${NC}" +else + echo -e "${GREEN}✓ helm is installed${NC}" +fi + +# Add Helm repositories +echo -e "\n${YELLOW}Adding Helm repositories...${NC}" +helm repo add jetstack https://charts.jetstack.io +helm repo add open-telemetry https://open-telemetry.github.io/opentelemetry-helm-charts +helm repo add prometheus-community https://prometheus-community.github.io/helm-charts +helm repo update +echo -e "${GREEN}✓ Helm repositories added${NC}" + +echo -e "\n${GREEN}========================================${NC}" +echo -e "${GREEN}Installing Components (Parallel)${NC}" +echo -e "${GREEN}========================================${NC}" +echo -e "The following will be installed:" +echo -e " • cert-manager" +echo -e " • OpenTelemetry Operator" +echo -e " • Prometheus & Grafana" +echo -e " • OpenTelemetry Collector" +echo -e " • Service Monitors" +echo -e "\n${YELLOW}All resources will be applied first, then we'll wait for them to become ready.${NC}\n" + +# Install cert-manager (required for OpenTelemetry Operator) +echo -e "\n${YELLOW}Installing cert-manager...${NC}" +helm upgrade --install cert-manager jetstack/cert-manager \ + --namespace cert-manager \ + --create-namespace \ + --set crds.enabled=true +echo -e "${GREEN}✓ cert-manager installation or upgrade started${NC}" + +# Create observability namespace +echo -e "\n${YELLOW}Creating observability namespace...${NC}" +kubectl create namespace observability --dry-run=client -o yaml | kubectl apply -f - +echo -e "${GREEN}✓ observability namespace ready${NC}" + +# Install OpenTelemetry Operator +echo -e "\n${YELLOW}Installing OpenTelemetry Operator...${NC}" + +if helm list -n observability | grep -q opentelemetry-operator; then + echo -e "${YELLOW}OpenTelemetry Operator already installed, upgrading...${NC}" + helm upgrade opentelemetry-operator open-telemetry/opentelemetry-operator \ + --namespace observability \ + --set "manager.collectorImage.repository=otel/opentelemetry-collector-contrib" +else + helm install opentelemetry-operator open-telemetry/opentelemetry-operator \ + --namespace observability \ + --set "manager.collectorImage.repository=otel/opentelemetry-collector-contrib" +fi +echo -e "${GREEN}✓ OpenTelemetry Operator installation started${NC}" + +# Install kube-prometheus-stack (includes Prometheus + Grafana) +echo -e "\n${YELLOW}Installing Prometheus and Grafana stack...${NC}" +if helm list -n observability | grep -q kube-prometheus-stack; then + echo -e "${YELLOW}kube-prometheus-stack already installed, upgrading...${NC}" + helm upgrade kube-prometheus-stack prometheus-community/kube-prometheus-stack \ + --namespace observability \ + --set prometheus.prometheusSpec.serviceMonitorSelectorNilUsesHelmValues=false \ + --set prometheus.prometheusSpec.podMonitorSelectorNilUsesHelmValues=false \ + --set grafana.adminPassword=admin +else + helm install kube-prometheus-stack prometheus-community/kube-prometheus-stack \ + --namespace observability \ + --set prometheus.prometheusSpec.serviceMonitorSelectorNilUsesHelmValues=false \ + --set prometheus.prometheusSpec.podMonitorSelectorNilUsesHelmValues=false \ + --set grafana.adminPassword=admin +fi +echo -e "${GREEN}✓ Prometheus and Grafana installation started${NC}" + +# Create OpenTelemetry Collector instance +echo -e "\n${YELLOW}Creating OpenTelemetry Collector...${NC}" +cat </dev/null || echo -e "${YELLOW}cert-manager already running or skipped${NC}" + +# Wait for observability pods +echo -e "${YELLOW}Checking observability pods...${NC}" +kubectl wait --for=condition=ready pod --all -n observability --timeout=300s + +echo -e "${GREEN}✓ All pods are ready${NC}" + +# Import Grafana dashboards +echo -e "\n${YELLOW}Importing Grafana dashboards...${NC}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [ -f "$SCRIPT_DIR/jvm-metrics-dashboard.json" ]; then + kubectl create configmap jvm-metrics-dashboard \ + --from-file="$SCRIPT_DIR/jvm-metrics-dashboard.json" \ + -n observability \ + --dry-run=client -o yaml | \ + kubectl label --dry-run=client --local -f - grafana_dashboard=1 -o yaml | \ + kubectl apply -f - + echo -e "${GREEN}✓ JVM Metrics dashboard imported${NC}" +else + echo -e "${YELLOW}⚠ JVM Metrics dashboard not found at $SCRIPT_DIR/jvm-metrics-dashboard.json${NC}" +fi + +if [ -f "$SCRIPT_DIR/josdk-operator-metrics-dashboard.json" ]; then + kubectl create configmap josdk-operator-metrics-dashboard \ + --from-file="$SCRIPT_DIR/josdk-operator-metrics-dashboard.json" \ + -n observability \ + --dry-run=client -o yaml | \ + kubectl label --dry-run=client --local -f - grafana_dashboard=1 -o yaml | \ + kubectl apply -f - + echo -e "${GREEN}✓ JOSDK Operator Metrics dashboard imported${NC}" +else + echo -e "${YELLOW}⚠ JOSDK Operator Metrics dashboard not found at $SCRIPT_DIR/josdk-operator-metrics-dashboard.json${NC}" +fi + +echo -e "${GREEN}✓ Dashboards will be available in Grafana shortly${NC}" + +# Get pod statuses +echo -e "\n${GREEN}========================================${NC}" +echo -e "${GREEN}Installation Complete!${NC}" +echo -e "${GREEN}========================================${NC}" + +echo -e "\n${YELLOW}Pod Status:${NC}" +kubectl get pods -n observability + +echo -e "\n${GREEN}========================================${NC}" +echo -e "${GREEN}Access Information${NC}" +echo -e "${GREEN}========================================${NC}" + +echo -e "\n${YELLOW}Grafana:${NC}" +echo -e " Username: ${GREEN}admin${NC}" +echo -e " Password: ${GREEN}admin${NC}" +echo -e " Access with: ${GREEN}kubectl port-forward -n observability svc/kube-prometheus-stack-grafana 3000:80${NC}" +echo -e " Then open: ${GREEN}http://localhost:3000${NC}" + +echo -e "\n${YELLOW}Prometheus:${NC}" +echo -e " Access with: ${GREEN}kubectl port-forward -n observability svc/kube-prometheus-stack-prometheus 9090:9090${NC}" +echo -e " Then open: ${GREEN}http://localhost:9090${NC}" + +echo -e "\n${YELLOW}OpenTelemetry Collector:${NC}" +echo -e " OTLP gRPC endpoint: ${GREEN}otel-collector-collector.observability.svc.cluster.local:4317${NC}" +echo -e " OTLP HTTP endpoint: ${GREEN}otel-collector-collector.observability.svc.cluster.local:4318${NC}" +echo -e " Prometheus metrics: ${GREEN}http://otel-collector-prometheus.observability.svc.cluster.local:8889/metrics${NC}" + +echo -e "\n${YELLOW}Configure your Java Operator to use OpenTelemetry:${NC}" +echo -e " Add dependency: ${GREEN}io.javaoperatorsdk:operator-framework-opentelemetry-support${NC}" +echo -e " Set environment variables:" +echo -e " ${GREEN}OTEL_SERVICE_NAME=your-operator-name${NC}" +echo -e " ${GREEN}OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector-collector.observability.svc.cluster.local:4318${NC}" +echo -e " ${GREEN}OTEL_METRICS_EXPORTER=otlp${NC}" +echo -e " ${GREEN}OTEL_TRACES_EXPORTER=otlp${NC}" + +echo -e "\n${GREEN}========================================${NC}" +echo -e "${GREEN}Grafana Dashboards${NC}" +echo -e "${GREEN}========================================${NC}" +echo -e "\nAutomatically imported dashboards:" +echo -e " - ${GREEN}JOSDK - JVM Metrics${NC} - Java Virtual Machine health and performance" +echo -e " - ${GREEN}JOSDK - Operator Metrics${NC} - Kubernetes operator performance and reconciliation" +echo -e "\nPre-installed Kubernetes dashboards:" +echo -e " - Kubernetes / Compute Resources / Cluster" +echo -e " - Kubernetes / Compute Resources / Namespace (Pods)" +echo -e " - Node Exporter / Nodes" +echo -e "\n${YELLOW}Note:${NC} Dashboards may take 30-60 seconds to appear in Grafana after installation." + +echo -e "\n${YELLOW}To uninstall:${NC}" +echo -e " kubectl delete configmap -n observability jvm-metrics-dashboard josdk-operator-metrics-dashboard" +echo -e " kubectl delete -n observability OpenTelemetryCollector otel-collector" +echo -e " helm uninstall -n observability kube-prometheus-stack" +echo -e " helm uninstall -n observability opentelemetry-operator" +echo -e " helm uninstall -n cert-manager cert-manager" +echo -e " kubectl delete namespace observability cert-manager" + +echo -e "\n${GREEN}Done!${NC}" diff --git a/observability/josdk-operator-metrics-dashboard.json b/observability/josdk-operator-metrics-dashboard.json new file mode 100644 index 0000000000..f8320f9dc3 --- /dev/null +++ b/observability/josdk-operator-metrics-dashboard.json @@ -0,0 +1,1018 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Rate of reconciliations started per second", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": ["last", "mean"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(reconciliations_started_total{service_name=~\"$service_name\"}[5m])) by (controller_name)", + "legendFormat": "{{controller_name}}", + "range": true, + "refId": "A" + } + ], + "title": "Reconciliation Rate (Started)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Success vs Failure rate of reconciliations", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ops" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Success" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Failure" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "Retries.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": ["last", "mean"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(reconciliations_success_total{service_name=~\"$service_name\"}[5m])) by (controller_name)", + "legendFormat": "Success - {{controller_name}}", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(reconciliations_failure_total{service_name=~\"$service_name\"}[5m])) by (controller_name)", + "legendFormat": "Failure - {{controller_name}}", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(reconciliations_retries_total{service_name=~\"$service_name\"}[5m])) by (controller_name)", + "legendFormat": "Retries - {{controller_name}}", + "range": true, + "refId": "C" + } + ], + "title": "Reconciliation Success / Failure / Retry Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Current number of reconciliations being executed", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "red", + "value": 10 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(reconciliations_active{service_name=~\"$service_name\"})", + "legendFormat": "Executing", + "range": true, + "refId": "A" + } + ], + "title": "Currently Executing Reconciliations", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Number of custom resources submitted to reconciliation to executor service", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 10 + }, + { + "color": "red", + "value": 50 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 6, + "y": 8 + }, + "id": 4, + "options": { + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(reconciliations_queue{service_name=~\"$service_name\"})", + "legendFormat": "Active", + "range": true, + "refId": "A" + } + ], + "title": "Reconciliation queue size", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Total reconciliations started", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 12, + "y": 8 + }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(reconciliations_started_total{service_name=~\"$service_name\"})", + "legendFormat": "Total", + "range": true, + "refId": "A" + } + ], + "title": "Total Reconciliations", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Error rate by exception type", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1 + } + ] + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 18, + "y": 8 + }, + "id": 6, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(reconciliations_failure_total{service_name=~\"$service_name\"}[5m]))", + "legendFormat": "Error Rate", + "range": true, + "refId": "A" + } + ], + "title": "Error Rate", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Number of custom resources tracked by controller", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "lineWidth": 2, + "fillOpacity": 10 + }, + "mappings": [], + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 13, + "options": { + "tooltip": { + "mode": "multi", + "sort": "none" + }, + "legend": { + "displayMode": "list", + "placement": "bottom" + } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "custom_resources{service_name=~\"$service_name\"}", + "legendFormat": "{{controller_name}}", + "range": true, + "refId": "A" + } + ], + "title": "Custom Resources Count", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Controller execution time percentiles", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 7, + "options": { + "legend": { + "calcs": ["last", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(reconciliations_execution_duration_milliseconds_bucket{service_name=~\"$service_name\"}[5m])) by (le, controller_name))", + "legendFormat": "p50 - {{controller_name}}", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum(rate(reconciliations_execution_duration_milliseconds_bucket{service_name=~\"$service_name\"}[5m])) by (le, controller_name))", + "legendFormat": "p95 - {{controller_name}}", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.99, sum(rate(reconciliations_execution_duration_milliseconds_bucket{service_name=~\"$service_name\"}[5m])) by (le, controller_name))", + "legendFormat": "p99 - {{controller_name}}", + "range": true, + "refId": "C" + } + ], + "title": "Reconciliation Execution Time (Percentiles)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Rate of events received by the operator", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 24 + }, + "id": 8, + "options": { + "legend": { + "calcs": ["last", "mean"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(events_received_total{service_name=~\"$service_name\"}[5m])) by (event, action)", + "legendFormat": "{{event}} - {{action}}", + "range": true, + "refId": "A" + } + ], + "title": "Event Reception Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Reconciliation failures", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 24 + }, + "id": 9, + "options": { + "legend": { + "calcs": ["last", "sum"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(reconciliations_failure_total{service_name=~\"$service_name\"}[5m])) by (controller_name)", + "legendFormat": "{{controller_name}}", + "range": true, + "refId": "A" + } + ], + "title": "Failures by Controller", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Rate of retry attempts", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 1 + }, + { + "color": "red", + "value": 3 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 40 + }, + "id": 12, + "options": { + "legend": { + "calcs": ["last", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(reconciliations_retries_total{service_name=~\"$service_name\"}[5m])) by (controller_name)", + "legendFormat": "Retries - {{controller_name}}", + "range": true, + "refId": "A" + } + ], + "title": "Reconciliation Retry Rate", + "type": "timeseries" + } + ], + "refresh": "10s", + "schemaVersion": 38, + "style": "dark", + "tags": ["operator", "kubernetes", "josdk"], + "templating": { + "list": [ + { + "current": {}, + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "definition": "label_values(reconciliations_started_total, service_name)", + "hide": 0, + "includeAll": false, + "label": "Service", + "multi": false, + "name": "service_name", + "options": [], + "query": { + "query": "label_values(reconciliations_started_total, service_name)", + "refId": "StandardVariableQuery" + }, + "refresh": 2, + "regex": "", + "sort": 1, + "type": "query" + } + ] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "JOSDK - Operator Metrics", + "uid": "josdk-operator-metrics", + "version": 0, + "weekStart": "" +} diff --git a/observability/jvm-metrics-dashboard.json b/observability/jvm-metrics-dashboard.json new file mode 100644 index 0000000000..528f29674e --- /dev/null +++ b/observability/jvm-metrics-dashboard.json @@ -0,0 +1,857 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": ["last", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "jvm_memory_used_bytes{service_name=\"josdk\"}", + "legendFormat": "{{area}} - {{id}}", + "range": true, + "refId": "A" + } + ], + "title": "JVM Memory Used", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": ["last", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "jvm_threads_live{service_name=\"josdk\"}", + "legendFormat": "Live Threads", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "jvm_threads_daemon_threads{service_name=\"josdk\"}", + "legendFormat": "Daemon Threads", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "jvm_threads_peak_threads{service_name=\"josdk\"}", + "legendFormat": "Peak Threads", + "range": true, + "refId": "C" + } + ], + "title": "JVM Threads", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "legend": { + "calcs": ["last", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "rate(jvm_gc_pause_milliseconds_sum{service_name=\"josdk\"}[5m])", + "legendFormat": "{{action}} - {{cause}}", + "range": true, + "refId": "A" + } + ], + "title": "GC Pause Time Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "legend": { + "calcs": ["last"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "rate(jvm_gc_pause_milliseconds_count{service_name=\"josdk\"}[5m])", + "legendFormat": "{{action}} - {{cause}}", + "range": true, + "refId": "A" + } + ], + "title": "GC Pause Count Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 0, + "y": 16 + }, + "id": 5, + "options": { + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "system_cpu_usage{service_name=\"josdk\"}", + "legendFormat": "CPU Usage", + "range": true, + "refId": "A" + } + ], + "title": "CPU Usage", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 6, + "y": 16 + }, + "id": 6, + "options": { + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "jvm_classes_loaded{service_name=\"josdk\"}", + "legendFormat": "Classes Loaded", + "range": true, + "refId": "A" + } + ], + "title": "Classes Loaded", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 12, + "y": 16 + }, + "id": 7, + "options": { + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "process_uptime_milliseconds{service_name=\"josdk\"}", + "legendFormat": "Uptime", + "range": true, + "refId": "A" + } + ], + "title": "Process Uptime", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 18, + "y": 16 + }, + "id": 8, + "options": { + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "system_cpu_count{service_name=\"josdk\"}", + "legendFormat": "CPU Count", + "range": true, + "refId": "A" + } + ], + "title": "CPU Count", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 24 + }, + "id": 9, + "options": { + "legend": { + "calcs": ["last"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "rate(jvm_gc_memory_allocated_bytes_total{service_name=\"josdk\"}[5m])", + "legendFormat": "Allocated", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "rate(jvm_gc_memory_promoted_bytes_total{service_name=\"josdk\"}[5m])", + "legendFormat": "Promoted", + "range": true, + "refId": "B" + } + ], + "title": "GC Memory Allocation Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 24 + }, + "id": 10, + "options": { + "legend": { + "calcs": ["last", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "jvm_memory_max_bytes{service_name=\"josdk\", area=\"heap\"}", + "legendFormat": "Max Heap", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "jvm_memory_committed_bytes{service_name=\"josdk\", area=\"heap\"}", + "legendFormat": "Committed Heap", + "range": true, + "refId": "B" + } + ], + "title": "Heap Memory Max vs Committed", + "type": "timeseries" + } + ], + "refresh": "10s", + "schemaVersion": 38, + "style": "dark", + "tags": ["jvm", "java", "josdk"], + "templating": { + "list": [] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "JOSDK - JVM Metrics", + "uid": "josdk-jvm-metrics", + "version": 0, + "weekStart": "" +} diff --git a/operator-framework-bom/pom.xml b/operator-framework-bom/pom.xml index 2db2e25203..643d000c2f 100644 --- a/operator-framework-bom/pom.xml +++ b/operator-framework-bom/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk operator-framework-bom - 5.2.4-SNAPSHOT + 999-SNAPSHOT pom Operator SDK - Bill of Materials Java SDK for implementing Kubernetes operators @@ -77,7 +77,7 @@ io.javaoperatorsdk - operator-framework-junit-5 + operator-framework-junit ${project.version} diff --git a/operator-framework-core/pom.xml b/operator-framework-core/pom.xml index aa95a5078b..2356433ca9 100644 --- a/operator-framework-core/pom.xml +++ b/operator-framework-core/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.2.4-SNAPSHOT + 999-SNAPSHOT ../pom.xml diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/Operator.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/Operator.java index 5adc90182d..0cfe0e997a 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/Operator.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/Operator.java @@ -263,7 +263,7 @@ public

RegisteredController

register( "Cannot register reconciler with name " + reconciler.getClass().getCanonicalName() + " reconciler named " - + ReconcilerUtils.getNameFor(reconciler) + + ReconcilerUtilsInternal.getNameFor(reconciler) + " because its configuration cannot be found.\n" + " Known reconcilers are: " + configurationService.getKnownReconcilerNames()); diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/ReconcilerUtils.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/ReconcilerUtilsInternal.java similarity index 64% rename from operator-framework-core/src/main/java/io/javaoperatorsdk/operator/ReconcilerUtils.java rename to operator-framework-core/src/main/java/io/javaoperatorsdk/operator/ReconcilerUtilsInternal.java index 354c2aa420..26ae5af554 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/ReconcilerUtils.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/ReconcilerUtilsInternal.java @@ -31,10 +31,11 @@ import io.fabric8.kubernetes.client.utils.Serialization; import io.javaoperatorsdk.operator.api.reconciler.Constants; import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.NonComparableResourceVersionException; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; @SuppressWarnings("rawtypes") -public class ReconcilerUtils { +public class ReconcilerUtilsInternal { private static final String FINALIZER_NAME_SUFFIX = "/finalizer"; protected static final String MISSING_GROUP_SUFFIX = ".javaoperatorsdk.io"; @@ -46,7 +47,7 @@ public class ReconcilerUtils { Pattern.compile(".*http(s?)://[^/]*/api(s?)/(\\S*).*"); // NOSONAR: input is controlled // prevent instantiation of util class - private ReconcilerUtils() {} + private ReconcilerUtilsInternal() {} public static boolean isFinalizerValid(String finalizer) { return HasMetadata.validateFinalizer(finalizer); @@ -241,4 +242,123 @@ private static boolean matchesResourceType( } return false; } + + /** + * Compares resource versions of two resources. This is a convenience method that extracts the + * resource versions from the metadata and delegates to {@link + * #validateAndCompareResourceVersions(String, String)}. + * + * @param h1 first resource + * @param h2 second resource + * @return negative if h1 is older, zero if equal, positive if h1 is newer + * @throws NonComparableResourceVersionException if either resource version is invalid + */ + public static int validateAndCompareResourceVersions(HasMetadata h1, HasMetadata h2) { + return validateAndCompareResourceVersions( + h1.getMetadata().getResourceVersion(), h2.getMetadata().getResourceVersion()); + } + + /** + * Compares the resource versions of two Kubernetes resources. + * + *

This method extracts the resource versions from the metadata of both resources and delegates + * to {@link #compareResourceVersions(String, String)} for the actual comparison. + * + * @param h1 the first resource to compare + * @param h2 the second resource to compare + * @return a negative integer if h1's version is less than h2's version, zero if they are equal, + * or a positive integer if h1's version is greater than h2's version + * @see #compareResourceVersions(String, String) + */ + public static int compareResourceVersions(HasMetadata h1, HasMetadata h2) { + return compareResourceVersions( + h1.getMetadata().getResourceVersion(), h2.getMetadata().getResourceVersion()); + } + + /** + * Compares two resource version strings using a length-first, then lexicographic comparison + * algorithm. + * + *

The comparison is performed in two steps: + * + *

    + *
  1. First, compare the lengths of the version strings. A longer version string is considered + * greater than a shorter one. This works correctly for numeric versions because larger + * numbers have more digits (e.g., "100" > "99"). + *
  2. If the lengths are equal, perform a character-by-character lexicographic comparison until + * a difference is found. + *
+ * + *

This algorithm is more efficient than parsing the versions as numbers, especially for + * Kubernetes resource versions which are typically monotonically increasing numeric strings. + * + *

Note: This method does not validate that the input strings are numeric. For + * validated numeric comparison, use {@link #validateAndCompareResourceVersions(String, String)}. + * + * @param v1 the first resource version string + * @param v2 the second resource version string + * @return a negative integer if v1 is less than v2, zero if they are equal, or a positive integer + * if v1 is greater than v2 + * @see #validateAndCompareResourceVersions(String, String) + */ + public static int compareResourceVersions(String v1, String v2) { + int comparison = v1.length() - v2.length(); + if (comparison != 0) { + return comparison; + } + for (int i = 0; i < v2.length(); i++) { + int comp = v1.charAt(i) - v2.charAt(i); + if (comp != 0) { + return comp; + } + } + return 0; + } + + /** + * Compares two Kubernetes resource versions numerically. Kubernetes resource versions are + * expected to be numeric strings that increase monotonically. This method assumes both versions + * are valid numeric strings without leading zeros. + * + * @param v1 first resource version + * @param v2 second resource version + * @return negative if v1 is older, zero if equal, positive if v1 is newer + * @throws NonComparableResourceVersionException if either resource version is empty, has leading + * zeros, or contains non-numeric characters + */ + public static int validateAndCompareResourceVersions(String v1, String v2) { + int v1Length = validateResourceVersion(v1); + int v2Length = validateResourceVersion(v2); + int comparison = v1Length - v2Length; + if (comparison != 0) { + return comparison; + } + for (int i = 0; i < v2Length; i++) { + int comp = v1.charAt(i) - v2.charAt(i); + if (comp != 0) { + return comp; + } + } + return 0; + } + + private static int validateResourceVersion(String v1) { + int v1Length = v1.length(); + if (v1Length == 0) { + throw new NonComparableResourceVersionException("Resource version is empty"); + } + for (int i = 0; i < v1Length; i++) { + char char1 = v1.charAt(i); + if (char1 == '0') { + if (i == 0) { + throw new NonComparableResourceVersionException( + "Resource version cannot begin with 0: " + v1); + } + } else if (char1 < '0' || char1 > '9') { + throw new NonComparableResourceVersionException( + "Non numeric characters in resource version: " + v1); + } + } + return v1Length; + } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/RuntimeInfo.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/RuntimeInfo.java index 1a51c45b70..ba874bdc07 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/RuntimeInfo.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/RuntimeInfo.java @@ -63,9 +63,7 @@ private void checkIfStarted() { public boolean allEventSourcesAreHealthy() { checkIfStarted(); return registeredControllers.stream() - .filter(rc -> !rc.getControllerHealthInfo().unhealthyEventSources().isEmpty()) - .findFirst() - .isEmpty(); + .noneMatch(rc -> rc.getControllerHealthInfo().hasUnhealthyEventSources()); } /** diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/AbstractConfigurationService.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/AbstractConfigurationService.java index b85ee03fcb..a1b37d6fe9 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/AbstractConfigurationService.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/AbstractConfigurationService.java @@ -22,7 +22,7 @@ import io.fabric8.kubernetes.api.model.HasMetadata; import io.fabric8.kubernetes.client.KubernetesClient; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; /** @@ -145,7 +145,7 @@ private String getReconcilersNameMessage() { } protected String keyFor(Reconciler reconciler) { - return ReconcilerUtils.getNameFor(reconciler); + return ReconcilerUtilsInternal.getNameFor(reconciler); } @SuppressWarnings("unused") diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/BaseConfigurationService.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/BaseConfigurationService.java index 0a7d3ece04..6b7579b6a8 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/BaseConfigurationService.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/BaseConfigurationService.java @@ -28,7 +28,7 @@ import io.fabric8.kubernetes.api.model.HasMetadata; import io.fabric8.kubernetes.client.KubernetesClient; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.config.Utils.Configurator; import io.javaoperatorsdk.operator.api.config.dependent.DependentResourceConfigurationResolver; import io.javaoperatorsdk.operator.api.config.dependent.DependentResourceSpec; @@ -265,7 +265,7 @@ private

ResolvedControllerConfiguration

controllerCon io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration annotation) { final var resourceClass = getResourceClassResolver().getPrimaryResourceClass(reconcilerClass); - final var name = ReconcilerUtils.getNameFor(reconcilerClass); + final var name = ReconcilerUtilsInternal.getNameFor(reconcilerClass); final var generationAware = valueOrDefaultFromAnnotation( annotation, diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java index 6215c20179..6ed9b7ff64 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java @@ -28,8 +28,6 @@ import io.fabric8.kubernetes.api.model.ConfigMap; import io.fabric8.kubernetes.api.model.HasMetadata; import io.fabric8.kubernetes.api.model.Secret; -import io.fabric8.kubernetes.api.model.apps.Deployment; -import io.fabric8.kubernetes.api.model.apps.StatefulSet; import io.fabric8.kubernetes.client.Config; import io.fabric8.kubernetes.client.ConfigBuilder; import io.fabric8.kubernetes.client.CustomResource; @@ -447,64 +445,6 @@ default Set> defaultNonSSAResource() { return defaultNonSSAResources(); } - /** - * If a javaoperatorsdk.io/previous annotation should be used so that the operator sdk can detect - * events from its own updates of dependent resources and then filter them. - * - *

Disable this if you want to react to your own dependent resource updates - * - * @return if special annotation should be used for dependent resource to filter events - * @since 4.5.0 - */ - default boolean previousAnnotationForDependentResourcesEventFiltering() { - return true; - } - - /** - * For dependent resources, the framework can add an annotation to filter out events resulting - * directly from the framework's operation. There are, however, some resources that do not follow - * the Kubernetes API conventions that changes in metadata should not increase the generation of - * the resource (as recorded in the {@code generation} field of the resource's {@code metadata}). - * For these resources, this convention is not respected and results in a new event for the - * framework to process. If that particular case is not handled correctly in the resource matcher, - * the framework will consider that the resource doesn't match the desired state and therefore - * triggers an update, which in turn, will re-add the annotation, thus starting the loop again, - * infinitely. - * - *

As a workaround, we automatically skip adding previous annotation for those well-known - * resources. Note that if you are sure that the matcher works for your use case, and it should in - * most instances, you can remove the resource type from the blocklist. - * - *

The consequence of adding a resource type to the set is that the framework will not use - * event filtering to prevent events, initiated by changes made by the framework itself as a - * result of its processing of dependent resources, to trigger the associated reconciler again. - * - *

Note that this method only takes effect if annotating dependent resources to prevent - * dependent resources events from triggering the associated reconciler again is activated as - * controlled by {@link #previousAnnotationForDependentResourcesEventFiltering()} - * - * @return a Set of resource classes where the previous version annotation won't be used. - */ - default Set> withPreviousAnnotationForDependentResourcesBlocklist() { - return Set.of(Deployment.class, StatefulSet.class); - } - - /** - * If the event logic should parse the resourceVersion to determine the ordering of dependent - * resource events. This is typically not needed. - * - *

Disabled by default as Kubernetes does not support, and discourages, this interpretation of - * resourceVersions. Enable only if your api server event processing seems to lag the operator - * logic, and you want to further minimize the amount of work done / updates issued by the - * operator. - * - * @return if resource version should be parsed (as integer) - * @since 4.5.0 - */ - default boolean parseResourceVersionsForEventFilteringAndCaching() { - return false; - } - /** * {@link io.javaoperatorsdk.operator.api.reconciler.UpdateControl} patch resource or status can * either use simple patches or SSA. Setting this to {@code true}, controllers will use SSA for diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverrider.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverrider.java index 3d29bb6589..cd9cdafb39 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverrider.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverrider.java @@ -51,11 +51,8 @@ public class ConfigurationServiceOverrider { private Duration reconciliationTerminationTimeout; private Boolean ssaBasedCreateUpdateMatchForDependentResources; private Set> defaultNonSSAResource; - private Boolean previousAnnotationForDependentResources; - private Boolean parseResourceVersions; private Boolean useSSAToPatchPrimaryResource; private Boolean cloneSecondaryResourcesWhenGettingFromCache; - private Set> previousAnnotationUsageBlocklist; @SuppressWarnings("rawtypes") private DependentResourceFactory dependentResourceFactory; @@ -168,31 +165,6 @@ public ConfigurationServiceOverrider withDefaultNonSSAResource( return this; } - public ConfigurationServiceOverrider withPreviousAnnotationForDependentResources(boolean value) { - this.previousAnnotationForDependentResources = value; - return this; - } - - /** - * @param value true if internal algorithms can use metadata.resourceVersion as a numeric value. - * @return this - */ - public ConfigurationServiceOverrider withParseResourceVersions(boolean value) { - this.parseResourceVersions = value; - return this; - } - - /** - * @deprecated use withParseResourceVersions - * @param value true if internal algorithms can use metadata.resourceVersion as a numeric value. - * @return this - */ - @Deprecated(forRemoval = true) - public ConfigurationServiceOverrider wihtParseResourceVersions(boolean value) { - this.parseResourceVersions = value; - return this; - } - public ConfigurationServiceOverrider withUseSSAToPatchPrimaryResource(boolean value) { this.useSSAToPatchPrimaryResource = value; return this; @@ -204,12 +176,6 @@ public ConfigurationServiceOverrider withCloneSecondaryResourcesWhenGettingFromC return this; } - public ConfigurationServiceOverrider withPreviousAnnotationForDependentResourcesBlocklist( - Set> blocklist) { - this.previousAnnotationUsageBlocklist = blocklist; - return this; - } - public ConfigurationService build() { return new BaseConfigurationService(original.getVersion(), cloner, client) { @Override @@ -331,20 +297,6 @@ public Set> defaultNonSSAResources() { defaultNonSSAResource, ConfigurationService::defaultNonSSAResources); } - @Override - public boolean previousAnnotationForDependentResourcesEventFiltering() { - return overriddenValueOrDefault( - previousAnnotationForDependentResources, - ConfigurationService::previousAnnotationForDependentResourcesEventFiltering); - } - - @Override - public boolean parseResourceVersionsForEventFilteringAndCaching() { - return overriddenValueOrDefault( - parseResourceVersions, - ConfigurationService::parseResourceVersionsForEventFilteringAndCaching); - } - @Override public boolean useSSAToPatchPrimaryResource() { return overriddenValueOrDefault( @@ -357,14 +309,6 @@ public boolean cloneSecondaryResourcesWhenGettingFromCache() { cloneSecondaryResourcesWhenGettingFromCache, ConfigurationService::cloneSecondaryResourcesWhenGettingFromCache); } - - @Override - public Set> - withPreviousAnnotationForDependentResourcesBlocklist() { - return overriddenValueOrDefault( - previousAnnotationUsageBlocklist, - ConfigurationService::withPreviousAnnotationForDependentResourcesBlocklist); - } }; } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfiguration.java index 8bddc8479e..63177b614f 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfiguration.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfiguration.java @@ -20,7 +20,7 @@ import java.util.Set; import io.fabric8.kubernetes.api.model.HasMetadata; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.config.dependent.DependentResourceSpec; import io.javaoperatorsdk.operator.api.config.workflow.WorkflowSpec; import io.javaoperatorsdk.operator.api.reconciler.MaxReconciliationInterval; @@ -42,16 +42,18 @@ default String getName() { } default String getFinalizerName() { - return ReconcilerUtils.getDefaultFinalizerName(getResourceClass()); + return ReconcilerUtilsInternal.getDefaultFinalizerName(getResourceClass()); } static String ensureValidName(String name, String reconcilerClassName) { - return name != null ? name : ReconcilerUtils.getDefaultReconcilerName(reconcilerClassName); + return name != null + ? name + : ReconcilerUtilsInternal.getDefaultReconcilerName(reconcilerClassName); } static String ensureValidFinalizerName(String finalizer, String resourceTypeName) { if (finalizer != null && !finalizer.isBlank()) { - if (ReconcilerUtils.isFinalizerValid(finalizer)) { + if (ReconcilerUtilsInternal.isFinalizerValid(finalizer)) { return finalizer; } else { throw new IllegalArgumentException( @@ -61,7 +63,7 @@ static String ensureValidFinalizerName(String finalizer, String resourceTypeName + " for details"); } } else { - return ReconcilerUtils.getDefaultFinalizerName(resourceTypeName); + return ReconcilerUtilsInternal.getDefaultFinalizerName(resourceTypeName); } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/LeaderElectionConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/LeaderElectionConfiguration.java index 1072fb823d..ca777bd2cc 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/LeaderElectionConfiguration.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/LeaderElectionConfiguration.java @@ -37,6 +37,10 @@ public class LeaderElectionConfiguration { private final LeaderCallbacks leaderCallbacks; private final boolean exitOnStopLeading; + /** + * @deprecated Use {@link LeaderElectionConfigurationBuilder} instead + */ + @Deprecated(forRemoval = true) public LeaderElectionConfiguration(String leaseName, String leaseNamespace, String identity) { this( leaseName, @@ -49,30 +53,26 @@ public LeaderElectionConfiguration(String leaseName, String leaseNamespace, Stri true); } + /** + * @deprecated Use {@link LeaderElectionConfigurationBuilder} instead + */ + @Deprecated(forRemoval = true) public LeaderElectionConfiguration(String leaseName, String leaseNamespace) { - this( - leaseName, - leaseNamespace, - LEASE_DURATION_DEFAULT_VALUE, - RENEW_DEADLINE_DEFAULT_VALUE, - RETRY_PERIOD_DEFAULT_VALUE, - null, - null, - true); + this(leaseName, leaseNamespace, null); } + /** + * @deprecated Use {@link LeaderElectionConfigurationBuilder} instead + */ + @Deprecated(forRemoval = true) public LeaderElectionConfiguration(String leaseName) { - this( - leaseName, - null, - LEASE_DURATION_DEFAULT_VALUE, - RENEW_DEADLINE_DEFAULT_VALUE, - RETRY_PERIOD_DEFAULT_VALUE, - null, - null, - true); + this(leaseName, null); } + /** + * @deprecated Use {@link LeaderElectionConfigurationBuilder} instead + */ + @Deprecated(forRemoval = true) public LeaderElectionConfiguration( String leaseName, String leaseNamespace, @@ -82,6 +82,10 @@ public LeaderElectionConfiguration( this(leaseName, leaseNamespace, leaseDuration, renewDeadline, retryPeriod, null, null, true); } + /** + * @deprecated Use {@link LeaderElectionConfigurationBuilder} instead + */ + @Deprecated // this will be made package-only public LeaderElectionConfiguration( String leaseName, String leaseNamespace, diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/LeaderElectionConfigurationBuilder.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/LeaderElectionConfigurationBuilder.java index 74f2c81cba..51ee40d84c 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/LeaderElectionConfigurationBuilder.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/LeaderElectionConfigurationBuilder.java @@ -31,7 +31,6 @@ public final class LeaderElectionConfigurationBuilder { private Duration renewDeadline = RENEW_DEADLINE_DEFAULT_VALUE; private Duration retryPeriod = RETRY_PERIOD_DEFAULT_VALUE; private LeaderCallbacks leaderCallbacks; - private boolean exitOnStopLeading = true; private LeaderElectionConfigurationBuilder(String leaseName) { this.leaseName = leaseName; @@ -71,12 +70,22 @@ public LeaderElectionConfigurationBuilder withLeaderCallbacks(LeaderCallbacks le return this; } + /** + * @deprecated Use {@link #buildForTest(boolean)} instead as setting this to false should only be + * used for testing purposes + */ + @Deprecated(forRemoval = true) public LeaderElectionConfigurationBuilder withExitOnStopLeading(boolean exitOnStopLeading) { - this.exitOnStopLeading = exitOnStopLeading; - return this; + throw new UnsupportedOperationException( + "Setting exitOnStopLeading should only be used for testing purposes, use buildForTest" + + " instead"); } public LeaderElectionConfiguration build() { + return buildForTest(false); + } + + public LeaderElectionConfiguration buildForTest(boolean exitOnStopLeading) { return new LeaderElectionConfiguration( leaseName, leaseNamespace, diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/Informer.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/Informer.java index 9264db66bc..e6655641a2 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/Informer.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/Informer.java @@ -28,6 +28,7 @@ import io.javaoperatorsdk.operator.processing.event.source.filter.OnDeleteFilter; import io.javaoperatorsdk.operator.processing.event.source.filter.OnUpdateFilter; +import static io.javaoperatorsdk.operator.api.reconciler.Constants.DEFAULT_COMPARABLE_RESOURCE_VERSION; import static io.javaoperatorsdk.operator.api.reconciler.Constants.DEFAULT_FOLLOW_CONTROLLER_NAMESPACE_CHANGES; import static io.javaoperatorsdk.operator.api.reconciler.Constants.NO_LONG_VALUE_SET; import static io.javaoperatorsdk.operator.api.reconciler.Constants.NO_VALUE_SET; @@ -131,4 +132,11 @@ /** Kubernetes field selector for additional resource filtering */ Field[] fieldSelector() default {}; + + /** + * true if we can consider resource versions as integers, therefore it is valid to compare them + * + * @since 5.3.0 + */ + boolean comparableResourceVersions() default DEFAULT_COMPARABLE_RESOURCE_VERSION; } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerConfiguration.java index 24f78eb7be..f6caa4fe4d 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerConfiguration.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerConfiguration.java @@ -25,7 +25,7 @@ import io.fabric8.kubernetes.api.model.HasMetadata; import io.fabric8.kubernetes.client.informers.cache.ItemStore; import io.javaoperatorsdk.operator.OperatorException; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; import io.javaoperatorsdk.operator.api.config.Utils; import io.javaoperatorsdk.operator.api.reconciler.Constants; @@ -53,6 +53,7 @@ public class InformerConfiguration { private ItemStore itemStore; private Long informerListLimit; private FieldSelector fieldSelector; + private boolean comparableResourceVersions; protected InformerConfiguration( Class resourceClass, @@ -66,7 +67,8 @@ protected InformerConfiguration( GenericFilter genericFilter, ItemStore itemStore, Long informerListLimit, - FieldSelector fieldSelector) { + FieldSelector fieldSelector, + boolean comparableResourceVersions) { this(resourceClass); this.name = name; this.namespaces = namespaces; @@ -79,6 +81,7 @@ protected InformerConfiguration( this.itemStore = itemStore; this.informerListLimit = informerListLimit; this.fieldSelector = fieldSelector; + this.comparableResourceVersions = comparableResourceVersions; } private InformerConfiguration(Class resourceClass) { @@ -89,7 +92,7 @@ private InformerConfiguration(Class resourceClass) { // controller // where GenericKubernetesResource now does not apply ? GenericKubernetesResource.class.getSimpleName() - : ReconcilerUtils.getResourceTypeName(resourceClass); + : ReconcilerUtilsInternal.getResourceTypeName(resourceClass); } @SuppressWarnings({"rawtypes", "unchecked"}) @@ -113,7 +116,8 @@ public static InformerConfiguration.Builder builder( original.genericFilter, original.itemStore, original.informerListLimit, - original.fieldSelector) + original.fieldSelector, + original.comparableResourceVersions) .builder; } @@ -288,6 +292,10 @@ public FieldSelector getFieldSelector() { return fieldSelector; } + public boolean isComparableResourceVersions() { + return comparableResourceVersions; + } + @SuppressWarnings("UnusedReturnValue") public class Builder { @@ -359,6 +367,7 @@ public InformerConfiguration.Builder initFromAnnotation( Arrays.stream(informerConfig.fieldSelector()) .map(f -> new FieldSelector.Field(f.path(), f.value(), f.negated())) .toList())); + withComparableResourceVersions(informerConfig.comparableResourceVersions()); } return this; } @@ -459,5 +468,10 @@ public Builder withFieldSelector(FieldSelector fieldSelector) { InformerConfiguration.this.fieldSelector = fieldSelector; return this; } + + public Builder withComparableResourceVersions(boolean comparableResourceVersions) { + InformerConfiguration.this.comparableResourceVersions = comparableResourceVersions; + return this; + } } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerEventSourceConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerEventSourceConfiguration.java index bca605a41c..69903e805f 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerEventSourceConfiguration.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerEventSourceConfiguration.java @@ -33,6 +33,7 @@ import io.javaoperatorsdk.operator.processing.event.source.filter.OnUpdateFilter; import io.javaoperatorsdk.operator.processing.event.source.informer.Mappers; +import static io.javaoperatorsdk.operator.api.reconciler.Constants.DEFAULT_COMPARABLE_RESOURCE_VERSION; import static io.javaoperatorsdk.operator.api.reconciler.Constants.SAME_AS_CONTROLLER_NAMESPACES_SET; import static io.javaoperatorsdk.operator.api.reconciler.Constants.WATCH_ALL_NAMESPACE_SET; import static io.javaoperatorsdk.operator.api.reconciler.Constants.WATCH_CURRENT_NAMESPACE_SET; @@ -96,18 +97,21 @@ class DefaultInformerEventSourceConfiguration private final GroupVersionKind groupVersionKind; private final InformerConfiguration informerConfig; private final KubernetesClient kubernetesClient; + private final boolean comparableResourceVersion; protected DefaultInformerEventSourceConfiguration( GroupVersionKind groupVersionKind, PrimaryToSecondaryMapper primaryToSecondaryMapper, SecondaryToPrimaryMapper secondaryToPrimaryMapper, InformerConfiguration informerConfig, - KubernetesClient kubernetesClient) { + KubernetesClient kubernetesClient, + boolean comparableResourceVersion) { this.informerConfig = Objects.requireNonNull(informerConfig); this.groupVersionKind = groupVersionKind; this.primaryToSecondaryMapper = primaryToSecondaryMapper; this.secondaryToPrimaryMapper = secondaryToPrimaryMapper; this.kubernetesClient = kubernetesClient; + this.comparableResourceVersion = comparableResourceVersion; } @Override @@ -135,6 +139,11 @@ public Optional getGroupVersionKind() { public Optional getKubernetesClient() { return Optional.ofNullable(kubernetesClient); } + + @Override + public boolean comparableResourceVersion() { + return this.comparableResourceVersion; + } } @SuppressWarnings({"unused", "UnusedReturnValue"}) @@ -148,6 +157,7 @@ class Builder { private PrimaryToSecondaryMapper primaryToSecondaryMapper; private SecondaryToPrimaryMapper secondaryToPrimaryMapper; private KubernetesClient kubernetesClient; + private boolean comparableResourceVersion = DEFAULT_COMPARABLE_RESOURCE_VERSION; private Builder(Class resourceClass, Class primaryResourceClass) { this(resourceClass, primaryResourceClass, null); @@ -285,6 +295,11 @@ public Builder withFieldSelector(FieldSelector fieldSelector) { return this; } + public Builder withComparableResourceVersion(boolean comparableResourceVersion) { + this.comparableResourceVersion = comparableResourceVersion; + return this; + } + public void updateFrom(InformerConfiguration informerConfig) { if (informerConfig != null) { final var informerConfigName = informerConfig.getName(); @@ -324,7 +339,10 @@ public InformerEventSourceConfiguration build() { HasMetadata.getKind(primaryResourceClass), false)), config.build(), - kubernetesClient); + kubernetesClient, + comparableResourceVersion); } } + + boolean comparableResourceVersion(); } 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 f66bdc47c6..6ae3ebe65a 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 @@ -71,50 +71,46 @@ public void controllerRegistered(Controller controller) { } @Override - public void receivedEvent(Event event, Map metadata) { - metricsList.forEach(metrics -> metrics.receivedEvent(event, metadata)); + public void eventReceived(Event event, Map metadata) { + metricsList.forEach(metrics -> metrics.eventReceived(event, metadata)); } @Override - public void reconcileCustomResource( + public void reconciliationSubmitted( HasMetadata resource, RetryInfo retryInfo, Map metadata) { - metricsList.forEach(metrics -> metrics.reconcileCustomResource(resource, retryInfo, metadata)); + metricsList.forEach(metrics -> metrics.reconciliationSubmitted(resource, retryInfo, metadata)); } @Override - public void failedReconciliation( - HasMetadata resource, Exception exception, Map metadata) { - metricsList.forEach(metrics -> metrics.failedReconciliation(resource, exception, metadata)); + public void reconciliationFailed( + HasMetadata resource, RetryInfo retry, Exception exception, Map metadata) { + metricsList.forEach( + metrics -> metrics.reconciliationFailed(resource, retry, exception, metadata)); } @Override - public void reconciliationExecutionStarted(HasMetadata resource, Map metadata) { - metricsList.forEach(metrics -> metrics.reconciliationExecutionStarted(resource, metadata)); + public void reconciliationStarted(HasMetadata resource, Map metadata) { + metricsList.forEach(metrics -> metrics.reconciliationStarted(resource, metadata)); } @Override - public void reconciliationExecutionFinished(HasMetadata resource, Map metadata) { - metricsList.forEach(metrics -> metrics.reconciliationExecutionFinished(resource, metadata)); + public void reconciliationFinished( + HasMetadata resource, RetryInfo retryInfo, Map metadata) { + metricsList.forEach(metrics -> metrics.reconciliationFinished(resource, retryInfo, metadata)); } @Override - public void cleanupDoneFor(ResourceID resourceID, Map metadata) { - metricsList.forEach(metrics -> metrics.cleanupDoneFor(resourceID, metadata)); + public void cleanupDone(ResourceID resourceID, Map metadata) { + metricsList.forEach(metrics -> metrics.cleanupDone(resourceID, metadata)); } @Override - public void finishedReconciliation(HasMetadata resource, Map metadata) { - metricsList.forEach(metrics -> metrics.finishedReconciliation(resource, metadata)); + public void reconciliationSucceeded(HasMetadata resource, Map metadata) { + metricsList.forEach(metrics -> metrics.reconciliationSucceeded(resource, metadata)); } @Override public T timeControllerExecution(ControllerExecution execution) throws Exception { return metricsList.get(0).timeControllerExecution(execution); } - - @Override - public > T monitorSizeOf(T map, String name) { - metricsList.forEach(metrics -> metrics.monitorSizeOf(map, name)); - return map; - } } 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 10b2db6774..fbbb20d92d 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 @@ -23,6 +23,7 @@ import io.javaoperatorsdk.operator.processing.Controller; import io.javaoperatorsdk.operator.processing.event.Event; import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.retry.RetryExecution; /** * An interface that metrics providers can implement and that the SDK will call at different times @@ -41,59 +42,72 @@ public interface Metrics { default void controllerRegistered(Controller controller) {} /** - * Called when an event has been accepted by the SDK from an event source, which would result in - * potentially triggering the associated Reconciler. + * Called when an event has been accepted by the SDK from an event source, which would potentially + * trigger the Reconciler. * * @param event the event * @param metadata metadata associated with the resource being processed */ - default void receivedEvent(Event event, Map metadata) {} + default void eventReceived(Event event, Map metadata) {} /** - * Called right before a resource is dispatched to the ExecutorService for reconciliation. + * Called right before a resource is submitted to the ExecutorService for reconciliation. * * @param resource the associated with the resource * @param retryInfo the current retry state information for the reconciliation request * @param metadata metadata associated with the resource being processed */ - default void reconcileCustomResource( + default void reconciliationSubmitted( HasMetadata resource, RetryInfo retryInfo, Map metadata) {} + default void reconciliationStarted(HasMetadata resource, Map metadata) {} + /** * Called when a precedent reconciliation for the resource associated with the specified {@link * ResourceID} resulted in the provided exception, resulting in a retry of the reconciliation. * * @param resource the {@link ResourceID} associated with the resource being processed + * @param retryInfo the state of retry before {@link RetryExecution#nextDelay()} is called * @param exception the exception that caused the failed reconciliation resulting in a retry * @param metadata metadata associated with the resource being processed */ - default void failedReconciliation( - HasMetadata resource, Exception exception, Map metadata) {} - - default void reconciliationExecutionStarted(HasMetadata resource, Map metadata) {} - - default void reconciliationExecutionFinished( - HasMetadata resource, Map metadata) {} + default void reconciliationFailed( + HasMetadata resource, + RetryInfo retryInfo, + Exception exception, + Map metadata) {} /** - * Called when the resource associated with the specified {@link ResourceID} has been successfully - * deleted and the clean-up performed by the associated reconciler is finished. + * Called when the {@link + * io.javaoperatorsdk.operator.api.reconciler.Reconciler#reconcile(HasMetadata, Context)} method + * of the Reconciler associated with the resource associated with the specified {@link ResourceID} + * has successfully finished. * - * @param resourceID the {@link ResourceID} associated with the resource being processed + * @param resource the {@link ResourceID} associated with the resource being processed * @param metadata metadata associated with the resource being processed */ - default void cleanupDoneFor(ResourceID resourceID, Map metadata) {} + default void reconciliationSucceeded(HasMetadata resource, Map metadata) {} /** - * Called when the {@link - * io.javaoperatorsdk.operator.api.reconciler.Reconciler#reconcile(HasMetadata, Context)} method - * of the Reconciler associated with the resource associated with the specified {@link ResourceID} - * has sucessfully finished. + * Always called when the reconciliation is finished, not only if reconciliation successfully + * finished. * * @param resource the {@link ResourceID} associated with the resource being processed + * @param retryInfo note that this retry info is in state after {@link RetryExecution#nextDelay()} + * is called in case of exception. * @param metadata metadata associated with the resource being processed */ - default void finishedReconciliation(HasMetadata resource, Map metadata) {} + default void reconciliationFinished( + HasMetadata resource, RetryInfo retryInfo, Map metadata) {} + + /** + * Called when the resource associated with the specified {@link ResourceID} has been successfully + * deleted and the cleanup of internal caches is completed. + * + * @param resourceID the {@link ResourceID} associated with the primary resource being processed + * @param metadata metadata associated with the resource being processed + */ + default void cleanupDone(ResourceID resourceID, Map metadata) {} /** * Encapsulates the information about a controller execution i.e. a call to either {@link @@ -173,19 +187,4 @@ interface ControllerExecution { default T timeControllerExecution(ControllerExecution execution) throws Exception { return execution.execute(); } - - /** - * Monitors the size of the specified map. This currently isn't used directly by the SDK but could - * be used by operators to monitor some of their structures, such as cache size. - * - * @param map the Map which size is to be monitored - * @param name the name of the provided Map to be used in metrics data - * @return the Map that was passed in so the registration can be done as part of an assignment - * statement. - * @param the type of the Map being monitored - */ - @SuppressWarnings("unused") - default > T monitorSizeOf(T map, String name) { - return map; - } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/BaseControl.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/BaseControl.java index 5087f4052a..6ac46ee0a6 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/BaseControl.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/BaseControl.java @@ -21,22 +21,53 @@ public abstract class BaseControl> { + public static final Long INSTANT_RESCHEDULE = 0L; + private Long scheduleDelay = null; + /** + * Schedules a reconciliation to occur after the specified delay in milliseconds. + * + * @param delay the delay in milliseconds after which to reschedule + * @return this control instance for fluent chaining + */ public T rescheduleAfter(long delay) { rescheduleAfter(Duration.ofMillis(delay)); return (T) this; } + /** + * Schedules a reconciliation to occur after the specified delay. + * + * @param delay the {@link Duration} after which to reschedule + * @return this control instance for fluent chaining + */ public T rescheduleAfter(Duration delay) { this.scheduleDelay = delay.toMillis(); return (T) this; } + /** + * Schedules a reconciliation to occur after the specified delay using the given time unit. + * + * @param delay the delay value + * @param timeUnit the time unit of the delay + * @return this control instance for fluent chaining + */ public T rescheduleAfter(long delay, TimeUnit timeUnit) { return rescheduleAfter(timeUnit.toMillis(delay)); } + /** + * Schedules an instant reconciliation. The reconciliation will be triggered as soon as possible. + * + * @return this control instance for fluent chaining + */ + public T reschedule() { + this.scheduleDelay = INSTANT_RESCHEDULE; + return (T) this; + } + public Optional getScheduleDelay() { return Optional.ofNullable(scheduleDelay); } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Constants.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Constants.java index 052b4d8c44..7330a407c1 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Constants.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Constants.java @@ -41,6 +41,7 @@ public final class Constants { public static final String RESOURCE_GVK_KEY = "josdk.resource.gvk"; public static final String CONTROLLER_NAME = "controller.name"; public static final boolean DEFAULT_FOLLOW_CONTROLLER_NAMESPACE_CHANGES = true; + public static final boolean DEFAULT_COMPARABLE_RESOURCE_VERSION = true; private Constants() {} } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Context.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Context.java index cc7c865dc5..2df74d4298 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Context.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Context.java @@ -35,12 +35,83 @@ default Optional getSecondaryResource(Class expectedType) { return getSecondaryResource(expectedType, null); } - Set getSecondaryResources(Class expectedType); + /** + * Retrieves a {@link Set} of the secondary resources of the specified type, which are associated + * with the primary resource being processed, possibly making sure that only the latest version of + * each resource is retrieved. + * + *

Note: While this method returns a {@link Set}, it is possible to get several copies of a + * given resource albeit all with different {@code resourceVersion}. If you want to avoid this + * situation, call {@link #getSecondaryResources(Class, boolean)} with the {@code deduplicate} + * parameter set to {@code true}. + * + * @param expectedType a class representing the type of secondary resources to retrieve + * @param the type of secondary resources to retrieve + * @return a {@link Stream} of secondary resources of the specified type, possibly deduplicated + */ + default Set getSecondaryResources(Class expectedType) { + return getSecondaryResources(expectedType, false); + } + + /** + * Retrieves a {@link Set} of the secondary resources of the specified type, which are associated + * with the primary resource being processed, possibly making sure that only the latest version of + * each resource is retrieved. + * + *

Note: While this method returns a {@link Set}, it is possible to get several copies of a + * given resource albeit all with different {@code resourceVersion}. If you want to avoid this + * situation, ask for the deduplicated version by setting the {@code deduplicate} parameter to + * {@code true}. + * + * @param expectedType a class representing the type of secondary resources to retrieve + * @param deduplicate {@code true} if only the latest version of each resource should be kept, + * {@code false} otherwise + * @param the type of secondary resources to retrieve + * @return a {@link Set} of secondary resources of the specified type, possibly deduplicated + * @throws IllegalArgumentException if the secondary resource type cannot be deduplicated because + * it's not extending {@link HasMetadata}, which is required to access the resource version + * @since 5.3.0 + */ + Set getSecondaryResources(Class expectedType, boolean deduplicate); + /** + * Retrieves a {@link Stream} of the secondary resources of the specified type, which are + * associated with the primary resource being processed, possibly making sure that only the latest + * version of each resource is retrieved. + * + *

Note: It is possible to get several copies of a given resource albeit all with different + * {@code resourceVersion}. If you want to avoid this situation, call {@link + * #getSecondaryResourcesAsStream(Class, boolean)} with the {@code deduplicate} parameter set to + * {@code true}. + * + * @param expectedType a class representing the type of secondary resources to retrieve + * @param the type of secondary resources to retrieve + * @return a {@link Stream} of secondary resources of the specified type, possibly deduplicated + */ default Stream getSecondaryResourcesAsStream(Class expectedType) { - return getSecondaryResources(expectedType).stream(); + return getSecondaryResourcesAsStream(expectedType, false); } + /** + * Retrieves a {@link Stream} of the secondary resources of the specified type, which are + * associated with the primary resource being processed, possibly making sure that only the latest + * version of each resource is retrieved. + * + *

Note: It is possible to get several copies of a given resource albeit all with different + * {@code resourceVersion}. If you want to avoid this situation, ask for the deduplicated version + * by setting the {@code deduplicate} parameter to {@code true}. + * + * @param expectedType a class representing the type of secondary resources to retrieve + * @param deduplicate {@code true} if only the latest version of each resource should be kept, + * {@code false} otherwise + * @param the type of secondary resources to retrieve + * @return a {@link Stream} of secondary resources of the specified type, possibly deduplicated + * @throws IllegalArgumentException if the secondary resource type cannot be deduplicated because + * it's not extending {@link HasMetadata}, which is required to access the resource version + * @since 5.3.0 + */ + Stream getSecondaryResourcesAsStream(Class expectedType, boolean deduplicate); + Optional getSecondaryResource(Class expectedType, String eventSourceName); ControllerConfiguration

getControllerConfiguration(); @@ -58,6 +129,8 @@ default Stream getSecondaryResourcesAsStream(Class expectedType) { KubernetesClient getClient(); + ResourceOperations

resourceOperations(); + /** ExecutorService initialized by framework for workflows. Used for workflow standalone mode. */ ExecutorService getWorkflowExecutorService(); diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContext.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContext.java index f3fade4659..ac5a7b41b9 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContext.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContext.java @@ -15,15 +15,21 @@ */ package io.javaoperatorsdk.operator.api.reconciler; +import java.util.HashSet; +import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; +import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; import io.fabric8.kubernetes.api.model.HasMetadata; import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; import io.javaoperatorsdk.operator.api.reconciler.dependent.managed.DefaultManagedWorkflowAndDependentResourceContext; import io.javaoperatorsdk.operator.api.reconciler.dependent.managed.ManagedWorkflowAndDependentResourceContext; import io.javaoperatorsdk.operator.processing.Controller; @@ -32,7 +38,6 @@ import io.javaoperatorsdk.operator.processing.event.ResourceID; public class DefaultContext

implements Context

{ - private RetryInfo retryInfo; private final Controller

controller; private final P primaryResource; @@ -41,6 +46,8 @@ public class DefaultContext

implements Context

{ defaultManagedDependentResourceContext; private final boolean primaryResourceDeleted; private final boolean primaryResourceFinalStateUnknown; + private final Map, Object> desiredStates = new ConcurrentHashMap<>(); + private final ResourceOperations

resourceOperations; public DefaultContext( RetryInfo retryInfo, @@ -56,6 +63,7 @@ public DefaultContext( this.primaryResourceFinalStateUnknown = primaryResourceFinalStateUnknown; this.defaultManagedDependentResourceContext = new DefaultManagedWorkflowAndDependentResourceContext<>(controller, primaryResource, this); + this.resourceOperations = new ResourceOperations<>(this); } @Override @@ -64,15 +72,44 @@ public Optional getRetryInfo() { } @Override - public Set getSecondaryResources(Class expectedType) { + public Set getSecondaryResources(Class expectedType, boolean deduplicate) { + if (deduplicate) { + final var deduplicatedMap = deduplicatedMap(getSecondaryResourcesAsStream(expectedType)); + return new HashSet<>(deduplicatedMap.values()); + } return getSecondaryResourcesAsStream(expectedType).collect(Collectors.toSet()); } - @Override - public Stream getSecondaryResourcesAsStream(Class expectedType) { - return controller.getEventSourceManager().getEventSourcesFor(expectedType).stream() - .map(es -> es.getSecondaryResources(primaryResource)) - .flatMap(Set::stream); + public Stream getSecondaryResourcesAsStream(Class expectedType, boolean deduplicate) { + final var stream = + controller.getEventSourceManager().getEventSourcesFor(expectedType).stream() + .mapMulti( + (es, consumer) -> es.getSecondaryResources(primaryResource).forEach(consumer)); + if (deduplicate) { + if (!HasMetadata.class.isAssignableFrom(expectedType)) { + throw new IllegalArgumentException("Can only de-duplicate HasMetadata descendants"); + } + return deduplicatedMap(stream).values().stream(); + } else { + return stream; + } + } + + private Map deduplicatedMap(Stream stream) { + return stream.collect( + Collectors.toUnmodifiableMap( + DefaultContext::resourceID, + Function.identity(), + (existing, replacement) -> + compareResourceVersions(existing, replacement) >= 0 ? existing : replacement)); + } + + private static ResourceID resourceID(Object hasMetadata) { + return ResourceID.fromResource((HasMetadata) hasMetadata); + } + + private static int compareResourceVersions(Object v1, Object v2) { + return ReconcilerUtilsInternal.compareResourceVersions((HasMetadata) v1, (HasMetadata) v2); } @Override @@ -119,6 +156,11 @@ public KubernetesClient getClient() { return controller.getClient(); } + @Override + public ResourceOperations

resourceOperations() { + return resourceOperations; + } + @Override public ExecutorService getWorkflowExecutorService() { // note that this should be always received from executor service manager, so we are able to do @@ -157,4 +199,12 @@ public DefaultContext

setRetryInfo(RetryInfo retryInfo) { this.retryInfo = retryInfo; return this; } + + @SuppressWarnings("unchecked") + public R getOrComputeDesiredStateFor( + DependentResource dependentResource, Function desiredStateComputer) { + return (R) + desiredStates.computeIfAbsent( + dependentResource, ignored -> desiredStateComputer.apply(getPrimaryResource())); + } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/PrimaryUpdateAndCacheUtils.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/PrimaryUpdateAndCacheUtils.java index 6103b4b12b..f74cd49ee7 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/PrimaryUpdateAndCacheUtils.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/PrimaryUpdateAndCacheUtils.java @@ -45,7 +45,11 @@ * caches the updated resource from the response in an overlay cache on top of the Informer cache. * If the update fails, it reads the primary resource from the cluster, applies the modifications * again and retries the update. + * + * @deprecated Use {@link Context#resourceOperations()} that contains the more efficient up-to-date + * versions of methods. */ +@Deprecated(forRemoval = true) public class PrimaryUpdateAndCacheUtils { public static final int DEFAULT_MAX_RETRY = 10; @@ -450,4 +454,45 @@ public static

P addFinalizerWithSSA( e); } } + + public static int compareResourceVersions(HasMetadata h1, HasMetadata h2) { + return compareResourceVersions( + h1.getMetadata().getResourceVersion(), h2.getMetadata().getResourceVersion()); + } + + public static int compareResourceVersions(String v1, String v2) { + int v1Length = validateResourceVersion(v1); + int v2Length = validateResourceVersion(v2); + int comparison = v1Length - v2Length; + if (comparison != 0) { + return comparison; + } + for (int i = 0; i < v2Length; i++) { + int comp = v1.charAt(i) - v2.charAt(i); + if (comp != 0) { + return comp; + } + } + return 0; + } + + private static int validateResourceVersion(String v1) { + int v1Length = v1.length(); + if (v1Length == 0) { + throw new NonComparableResourceVersionException("Resource version is empty"); + } + for (int i = 0; i < v1Length; i++) { + char char1 = v1.charAt(i); + if (char1 == '0') { + if (i == 0) { + throw new NonComparableResourceVersionException( + "Resource version cannot begin with 0: " + v1); + } + } else if (char1 < '0' || char1 > '9') { + throw new NonComparableResourceVersionException( + "Non numeric characters in resource version: " + v1); + } + } + return v1Length; + } } 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 new file mode 100644 index 0000000000..de4d00d717 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -0,0 +1,756 @@ +/* + * 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; + +import java.lang.reflect.InvocationTargetException; +import java.util.function.Predicate; +import java.util.function.UnaryOperator; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.client.KubernetesClientException; +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.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.ManagedInformerEventSource; + +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. + * + * @param

the resource type on which this object operates + */ +public class ResourceOperations

{ + + public static final int DEFAULT_MAX_RETRY = 10; + + private static final Logger log = LoggerFactory.getLogger(ResourceOperations.class); + + private final Context

context; + + public ResourceOperations(Context

context) { + this.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. + * + *

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 fresh resource for server side apply + * @return updated resource + * @param resource type + */ + public R serverSideApply(R resource) { + return resourcePatch( + resource, + r -> + context + .getClient() + .resource(r) + .patch( + new PatchContext.Builder() + .withForce(true) + .withFieldManager(context.getControllerConfiguration().fieldManager()) + .withPatchType(PatchType.SERVER_SIDE_APPLY) + .build())); + } + + /** + * 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. + * + * @param resource fresh resource for server side apply + * @return updated resource + * @param informerEventSource InformerEventSource to use for resource caching and filtering + * @param resource type + */ + public R serverSideApply( + R resource, InformerEventSource informerEventSource) { + if (informerEventSource == null) { + return serverSideApply(resource); + } + return resourcePatch( + resource, + r -> + context + .getClient() + .resource(r) + .patch( + new PatchContext.Builder() + .withForce(true) + .withFieldManager(context.getControllerConfiguration().fieldManager()) + .withPatchType(PatchType.SERVER_SIDE_APPLY) + .build()), + informerEventSource); + } + + /** + * 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. + * + * @param resource fresh resource for server side apply + * @return updated resource + * @param resource type + */ + public R serverSideApplyStatus(R resource) { + return resourcePatch( + resource, + r -> + context + .getClient() + .resource(r) + .subresource("status") + .patch( + new PatchContext.Builder() + .withForce(true) + .withFieldManager(context.getControllerConfiguration().fieldManager()) + .withPatchType(PatchType.SERVER_SIDE_APPLY) + .build())); + } + + /** + * Server-Side Apply 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. In case of SSA we advise not to do updates with optimistic locking. + * + * @param resource primary resource for server side apply + * @return updated resource + */ + public P serverSideApplyPrimary(P resource) { + return resourcePatch( + resource, + r -> + context + .getClient() + .resource(r) + .patch( + new PatchContext.Builder() + .withForce(true) + .withFieldManager(context.getControllerConfiguration().fieldManager()) + .withPatchType(PatchType.SERVER_SIDE_APPLY) + .build()), + context.eventSourceRetriever().getControllerEventSource()); + } + + /** + * Server-Side Apply 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. + * + *

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 primary resource for server side apply + * @return updated resource + */ + public P serverSideApplyPrimaryStatus(P resource) { + return resourcePatch( + resource, + r -> + context + .getClient() + .resource(r) + .subresource("status") + .patch( + new PatchContext.Builder() + .withForce(true) + .withFieldManager(context.getControllerConfiguration().fieldManager()) + .withPatchType(PatchType.SERVER_SIDE_APPLY) + .build()), + context.eventSourceRetriever().getControllerEventSource()); + } + + /** + * 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 + */ + public R update(R resource) { + return resourcePatch(resource, r -> context.getClient().resource(r).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. + * + *

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 informerEventSource InformerEventSource to use for resource caching and filtering + * @param resource type + */ + public R update( + R resource, InformerEventSource informerEventSource) { + if (informerEventSource == null) { + return update(resource); + } + return resourcePatch( + resource, r -> context.getClient().resource(r).update(), informerEventSource); + } + + /** + * 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. + * + *

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 + */ + public R create(R resource) { + return resourcePatch(resource, r -> context.getClient().resource(r).create()); + } + + /** + * 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. + * + *

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 informerEventSource InformerEventSource to use for resource caching and filtering + * @param resource type + */ + public R create( + R resource, InformerEventSource informerEventSource) { + if (informerEventSource == null) { + return create(resource); + } + return resourcePatch( + resource, r -> context.getClient().resource(r).create(), informerEventSource); + } + + /** + * Updates 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. + * + * @param resource resource to update + * @return updated resource + * @param resource type + */ + public R updateStatus(R resource) { + return resourcePatch(resource, r -> context.getClient().resource(r).updateStatus()); + } + + /** + * Updates 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. + * + * @param resource primary resource to update + * @return updated resource + */ + public P updatePrimary(P resource) { + return resourcePatch( + resource, + r -> context.getClient().resource(r).update(), + context.eventSourceRetriever().getControllerEventSource()); + } + + /** + * 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. + * + *

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 + */ + public P updatePrimaryStatus(P resource) { + return resourcePatch( + resource, + r -> context.getClient().resource(r).updateStatus(), + context.eventSourceRetriever().getControllerEventSource()); + } + + /** + * 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. + * + *

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 + */ + public R jsonPatch(R resource, UnaryOperator unaryOperator) { + return resourcePatch(resource, r -> context.getClient().resource(r).edit(unaryOperator)); + } + + /** + * 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. + * + *

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 + */ + public R jsonPatchStatus(R resource, UnaryOperator unaryOperator) { + return resourcePatch(resource, r -> context.getClient().resource(r).editStatus(unaryOperator)); + } + + /** + * Applies a JSON 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. + * + * @param resource primary resource to patch + * @param unaryOperator function to modify the resource + * @return updated resource + */ + public P jsonPatchPrimary(P resource, UnaryOperator

unaryOperator) { + return resourcePatch( + resource, + r -> context.getClient().resource(r).edit(unaryOperator), + context.eventSourceRetriever().getControllerEventSource()); + } + + /** + * Applies a JSON Patch to 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. + * + *

You are free to control the optimistic locking by setting the resource version in resource + * metadata. + * + * @param resource primary resource to patch + * @param unaryOperator function to modify the resource + * @return updated resource + */ + public P jsonPatchPrimaryStatus(P resource, UnaryOperator

unaryOperator) { + return resourcePatch( + resource, + r -> context.getClient().resource(r).editStatus(unaryOperator), + context.eventSourceRetriever().getControllerEventSource()); + } + + /** + * 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. + * + *

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 patch + * @return updated resource + * @param resource type + */ + public R jsonMergePatch(R resource) { + return resourcePatch(resource, r -> context.getClient().resource(r).patch()); + } + + /** + * Applies a JSON Merge Patch to 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. + * + * @param resource resource to patch + * @return updated resource + * @param resource type + */ + public R jsonMergePatchStatus(R resource) { + return resourcePatch(resource, r -> context.getClient().resource(r).patchStatus()); + } + + /** + * Applies a JSON Merge Patch to the primary resource. Caches the response using the controller's + * event source. + * + *

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 primary resource to patch reconciliation + * @return updated resource + */ + public P jsonMergePatchPrimary(P resource) { + return resourcePatch( + resource, + r -> context.getClient().resource(r).patch(), + context.eventSourceRetriever().getControllerEventSource()); + } + + /** + * 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. + * + * @param resource primary resource to patch + * @return updated resource + * @see #jsonMergePatchPrimaryStatus(HasMetadata) + */ + public P jsonMergePatchPrimaryStatus(P resource) { + return resourcePatch( + resource, + r -> context.getClient().resource(r).patchStatus(), + context.eventSourceRetriever().getControllerEventSource()); + } + + /** + * 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)}. + * + * @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 + */ + @SuppressWarnings({"rawtypes", "unchecked"}) + public R resourcePatch(R resource, UnaryOperator updateOperation) { + + var esList = context.eventSourceRetriever().getEventSourcesFor(resource.getClass()); + if (esList.isEmpty()) { + throw new IllegalStateException("No event source found for type: " + resource.getClass()); + } + var es = esList.get(0); + if (esList.size() > 1) { + log.warn( + "Multiple event sources found for type: {}, selecting first with name {}", + resource.getClass(), + es.name()); + } + if (es instanceof ManagedInformerEventSource mes) { + return resourcePatch(resource, updateOperation, (ManagedInformerEventSource) mes); + } else { + throw new IllegalStateException( + "Target event source must be a subclass off " + + ManagedInformerEventSource.class.getName()); + } + } + + /** + * 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. + * + * @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 + */ + public R resourcePatch( + R resource, UnaryOperator updateOperation, ManagedInformerEventSource ies) { + return ies.eventFilteringUpdateAndCacheResource(resource, updateOperation); + } + + /** + * Adds the default finalizer (from controller configuration) to the primary resource. This is a + * convenience method that calls {@link #addFinalizer(String)} with the configured finalizer name. + * Note that explicitly adding/removing finalizer is required only if "Trigger reconciliation on + * all event" mode is on. + * + * @return updated resource from the server response + * @see #addFinalizer(String) + */ + public P addFinalizer() { + return addFinalizer(context.getControllerConfiguration().getFinalizerName()); + } + + /** + * Adds finalizer to the resource using JSON Patch. Retries conflicts and unprocessable content + * (HTTP 422). It does not try to add finalizer if there is already a finalizer or resource is + * marked for deletion. Note that explicitly adding/removing finalizer is required only if + * "Trigger reconciliation on all event" mode is on. + * + * @return updated resource from the server response + */ + public P addFinalizer(String finalizerName) { + var resource = context.getPrimaryResource(); + if (resource.isMarkedForDeletion() || resource.hasFinalizer(finalizerName)) { + return resource; + } + return conflictRetryingPatchPrimary( + r -> { + r.addFinalizer(finalizerName); + return r; + }, + r -> !r.hasFinalizer(finalizerName)); + } + + /** + * Removes the default finalizer (from controller configuration) from the primary resource. This + * is a convenience method that calls {@link #removeFinalizer(String)} with the configured + * finalizer name. Note that explicitly adding/removing finalizer is required only if "Trigger + * reconciliation on all event" mode is on. + * + * @return updated resource from the server response + * @see #removeFinalizer(String) + */ + public P removeFinalizer() { + return removeFinalizer(context.getControllerConfiguration().getFinalizerName()); + } + + /** + * Removes the target finalizer from the primary resource. Uses JSON Patch and handles retries. It + * does not try to remove finalizer if finalizer is not present on the resource. Note that + * explicitly adding/removing finalizer is required only if "Trigger reconciliation on all event" + * mode is on. + * + * @return updated resource from the server response + */ + public P removeFinalizer(String finalizerName) { + var resource = context.getPrimaryResource(); + if (!resource.hasFinalizer(finalizerName)) { + return resource; + } + return conflictRetryingPatchPrimary( + r -> { + r.removeFinalizer(finalizerName); + return r; + }, + r -> { + if (r == null) { + log.warn("Cannot remove finalizer since resource not exists."); + return false; + } + return r.hasFinalizer(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}. + * + * @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. + */ + @SuppressWarnings("unchecked") + public P conflictRetryingPatchPrimary( + UnaryOperator

resourceChangesOperator, Predicate

preCondition) { + var resource = context.getPrimaryResource(); + var client = context.getClient(); + if (log.isDebugEnabled()) { + log.debug("Conflict retrying update for: {}", ResourceID.fromResource(resource)); + } + int retryIndex = 0; + while (true) { + try { + if (!preCondition.test(resource)) { + return resource; + } + return jsonPatchPrimary(resource, resourceChangesOperator); + } catch (KubernetesClientException e) { + log.trace("Exception during patch for resource: {}", resource); + retryIndex++; + // only retry on conflict (409) and unprocessable content (422) which + // can happen if JSON Patch is not a valid request since there was + // a concurrent request which already removed another finalizer: + // List element removal from a list is by index in JSON Patch + // so if addressing a second finalizer but first is meanwhile removed + // it is a wrong request. + if (e.getCode() != 409 && e.getCode() != 422) { + throw e; + } + if (retryIndex >= DEFAULT_MAX_RETRY) { + throw new OperatorException( + "Exceeded maximum (" + + DEFAULT_MAX_RETRY + + ") retry attempts to patch resource: " + + ResourceID.fromResource(resource)); + } + log.debug( + "Retrying patch for resource name: {}, namespace: {}; HTTP code: {}", + resource.getMetadata().getName(), + resource.getMetadata().getNamespace(), + e.getCode()); + var operation = client.resources(resource.getClass()); + if (resource.getMetadata().getNamespace() != null) { + resource = + (P) + operation + .inNamespace(resource.getMetadata().getNamespace()) + .withName(resource.getMetadata().getName()) + .get(); + } else { + resource = (P) operation.withName(resource.getMetadata().getName()).get(); + } + } + } + } + + /** + * Adds the default finalizer (from controller configuration) to the primary resource using + * Server-Side Apply. This is a convenience method that calls {@link #addFinalizerWithSSA( + * String)} with the configured finalizer name. Note that explicitly adding finalizer is required + * only if "Trigger reconciliation on all event" mode is on. + * + * @return the patched resource from the server response + * @see #addFinalizerWithSSA(String) + */ + public P addFinalizerWithSSA() { + return addFinalizerWithSSA(context.getControllerConfiguration().getFinalizerName()); + } + + /** + * Adds finalizer using Server-Side Apply. In the background this method creates a fresh copy of + * the target resource, setting only name, namespace and finalizer. Does not use optimistic + * locking for the patch. Note that explicitly adding finalizer is required only if "Trigger + * reconciliation on all event" mode is on. + * + * @param finalizerName name of the finalizer to add + * @return the patched resource from the server response + */ + public P addFinalizerWithSSA(String finalizerName) { + var originalResource = context.getPrimaryResource(); + if (log.isDebugEnabled()) { + log.debug( + "Adding finalizer (using SSA) for resource: {} version: {}", + getUID(originalResource), + getVersion(originalResource)); + } + try { + @SuppressWarnings("unchecked") + P resource = (P) originalResource.getClass().getConstructor().newInstance(); + resource.initNameAndNamespaceFrom(originalResource); + resource.addFinalizer(finalizerName); + + return serverSideApplyPrimary(resource); + } catch (InstantiationException + | IllegalAccessException + | InvocationTargetException + | NoSuchMethodException e) { + throw new RuntimeException( + "Issue with creating custom resource instance with reflection." + + " Custom Resources must provide a no-arg constructor. Class: " + + originalResource.getClass().getName(), + e); + } + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/health/ControllerHealthInfo.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/health/ControllerHealthInfo.java index 4a78e60f05..f2a9359e04 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/health/ControllerHealthInfo.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/health/ControllerHealthInfo.java @@ -16,7 +16,10 @@ package io.javaoperatorsdk.operator.health; import java.util.Map; +import java.util.function.Predicate; +import java.util.stream.Collector; import java.util.stream.Collectors; +import java.util.stream.Stream; import io.javaoperatorsdk.operator.processing.event.EventSourceManager; import io.javaoperatorsdk.operator.processing.event.source.EventSource; @@ -25,6 +28,17 @@ @SuppressWarnings("rawtypes") public class ControllerHealthInfo { + private static final Predicate UNHEALTHY = e -> e.getStatus() == Status.UNHEALTHY; + private static final Predicate INFORMER = + e -> e instanceof InformerWrappingEventSourceHealthIndicator; + private static final Predicate UNHEALTHY_INFORMER = + e -> INFORMER.test(e) && e.getStatus() == Status.UNHEALTHY; + private static final Collector> + NAME_TO_ES_MAP = Collectors.toMap(EventSource::name, e -> e); + private static final Collector< + EventSource, ?, Map> + NAME_TO_ES_HEALTH_MAP = + Collectors.toMap(EventSource::name, e -> (InformerWrappingEventSourceHealthIndicator) e); private final EventSourceManager eventSourceManager; public ControllerHealthInfo(EventSourceManager eventSourceManager) { @@ -32,23 +46,31 @@ public ControllerHealthInfo(EventSourceManager eventSourceManager) { } public Map eventSourceHealthIndicators() { - return eventSourceManager.allEventSources().stream() - .collect(Collectors.toMap(EventSource::name, e -> e)); + return eventSourceManager.allEventSourcesStream().collect(NAME_TO_ES_MAP); + } + + /** + * Whether the associated {@link io.javaoperatorsdk.operator.processing.Controller} has unhealthy + * event sources. + * + * @return {@code true} if any of the associated controller is unhealthy, {@code false} otherwise + * @since 5.3.0 + */ + public boolean hasUnhealthyEventSources() { + return filteredEventSources(UNHEALTHY).findAny().isPresent(); } public Map unhealthyEventSources() { - return eventSourceManager.allEventSources().stream() - .filter(e -> e.getStatus() == Status.UNHEALTHY) - .collect(Collectors.toMap(EventSource::name, e -> e)); + return filteredEventSources(UNHEALTHY).collect(NAME_TO_ES_MAP); + } + + private Stream filteredEventSources(Predicate filter) { + return eventSourceManager.allEventSourcesStream().filter(filter); } public Map informerEventSourceHealthIndicators() { - return eventSourceManager.allEventSources().stream() - .filter(e -> e instanceof InformerWrappingEventSourceHealthIndicator) - .collect( - Collectors.toMap( - EventSource::name, e -> (InformerWrappingEventSourceHealthIndicator) e)); + return filteredEventSources(INFORMER).collect(NAME_TO_ES_HEALTH_MAP); } /** @@ -58,11 +80,6 @@ public Map unhealthyEventSources() { */ public Map unhealthyInformerEventSourceHealthIndicators() { - return eventSourceManager.allEventSources().stream() - .filter(e -> e.getStatus() == Status.UNHEALTHY) - .filter(e -> e instanceof InformerWrappingEventSourceHealthIndicator) - .collect( - Collectors.toMap( - EventSource::name, e -> (InformerWrappingEventSourceHealthIndicator) e)); + return filteredEventSources(UNHEALTHY_INFORMER).collect(NAME_TO_ES_HEALTH_MAP); } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/health/InformerHealthIndicator.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/health/InformerHealthIndicator.java index 66d24aa383..6c39a2601b 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/health/InformerHealthIndicator.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/health/InformerHealthIndicator.java @@ -23,8 +23,5 @@ public interface InformerHealthIndicator extends EventSourceHealthIndicator { boolean isRunning(); - @Override - Status getStatus(); - String getTargetNamespace(); } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/MDCUtils.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/MDCUtils.java index 01a8b62e9d..e4931b6447 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/MDCUtils.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/MDCUtils.java @@ -20,8 +20,10 @@ import io.fabric8.kubernetes.api.model.HasMetadata; import io.javaoperatorsdk.operator.api.config.Utils; import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; public class MDCUtils { + public static final String NO_NAMESPACE = "no namespace"; private static final String NAME = "resource.name"; private static final String NAMESPACE = "resource.namespace"; @@ -30,10 +32,49 @@ public class MDCUtils { private static final String RESOURCE_VERSION = "resource.resourceVersion"; private static final String GENERATION = "resource.generation"; private static final String UID = "resource.uid"; - private static final String NO_NAMESPACE = "no namespace"; private static final boolean enabled = Utils.getBooleanFromSystemPropsOrDefault(Utils.USE_MDC_ENV_KEY, true); + private static final String EVENT_SOURCE_PREFIX = "eventsource.event."; + private static final String EVENT_ACTION = EVENT_SOURCE_PREFIX + "action"; + private static final String EVENT_SOURCE_NAME = "eventsource.name"; + + public static void addInformerEventInfo( + HasMetadata resource, ResourceAction action, String eventSourceName) { + if (enabled) { + addResourceInfo(resource, true); + MDC.put(EVENT_ACTION, action.name()); + MDC.put(EVENT_SOURCE_NAME, eventSourceName); + } + } + + public static void removeInformerEventInfo() { + if (enabled) { + removeResourceInfo(true); + MDC.remove(EVENT_ACTION); + MDC.remove(EVENT_SOURCE_NAME); + } + } + + public static void withMDCForEvent( + HasMetadata resource, ResourceAction action, Runnable runnable, String eventSourceName) { + try { + MDCUtils.addInformerEventInfo(resource, action, eventSourceName); + runnable.run(); + } finally { + MDCUtils.removeInformerEventInfo(); + } + } + + public static void withMDCForResource(HasMetadata resource, Runnable runnable) { + try { + MDCUtils.addResourceInfo(resource); + runnable.run(); + } finally { + MDCUtils.removeResourceInfo(); + } + } + public static void addResourceIDInfo(ResourceID resourceID) { if (enabled) { MDC.put(NAME, resourceID.getName()); @@ -49,33 +90,46 @@ public static void removeResourceIDInfo() { } public static void addResourceInfo(HasMetadata resource) { + addResourceInfo(resource, false); + } + + public static void addResourceInfo(HasMetadata resource, boolean forEventSource) { if (enabled) { - MDC.put(API_VERSION, resource.getApiVersion()); - MDC.put(KIND, resource.getKind()); + MDC.put(key(API_VERSION, forEventSource), resource.getApiVersion()); + MDC.put(key(KIND, forEventSource), resource.getKind()); final var metadata = resource.getMetadata(); if (metadata != null) { - MDC.put(NAME, metadata.getName()); - if (metadata.getNamespace() != null) { - MDC.put(NAMESPACE, metadata.getNamespace()); - } - MDC.put(RESOURCE_VERSION, metadata.getResourceVersion()); + MDC.put(key(NAME, forEventSource), metadata.getName()); + + final var namespace = metadata.getNamespace(); + MDC.put(key(NAMESPACE, forEventSource), namespace != null ? namespace : NO_NAMESPACE); + + MDC.put(key(RESOURCE_VERSION, forEventSource), metadata.getResourceVersion()); if (metadata.getGeneration() != null) { - MDC.put(GENERATION, metadata.getGeneration().toString()); + MDC.put(key(GENERATION, forEventSource), metadata.getGeneration().toString()); } - MDC.put(UID, metadata.getUid()); + MDC.put(key(UID, forEventSource), metadata.getUid()); } } } + private static String key(String baseKey, boolean forEventSource) { + return forEventSource ? EVENT_SOURCE_PREFIX + baseKey : baseKey; + } + public static void removeResourceInfo() { + removeResourceInfo(false); + } + + public static void removeResourceInfo(boolean forEventSource) { if (enabled) { - MDC.remove(API_VERSION); - MDC.remove(KIND); - MDC.remove(NAME); - MDC.remove(NAMESPACE); - MDC.remove(RESOURCE_VERSION); - MDC.remove(GENERATION); - MDC.remove(UID); + MDC.remove(key(API_VERSION, forEventSource)); + MDC.remove(key(KIND, forEventSource)); + MDC.remove(key(NAME, forEventSource)); + MDC.remove(key(NAMESPACE, forEventSource)); + MDC.remove(key(RESOURCE_VERSION, forEventSource)); + MDC.remove(key(GENERATION, forEventSource)); + MDC.remove(key(UID, forEventSource)); } } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/AbstractDependentResource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/AbstractDependentResource.java index a7c5ce9e2d..8dc62b4ca7 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/AbstractDependentResource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/AbstractDependentResource.java @@ -23,6 +23,7 @@ import io.fabric8.kubernetes.api.model.HasMetadata; import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.DefaultContext; import io.javaoperatorsdk.operator.api.reconciler.Ignore; import io.javaoperatorsdk.operator.api.reconciler.dependent.Deleter; import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; @@ -85,7 +86,7 @@ protected ReconcileResult reconcile(P primary, R actualResource, Context

c if (creatable() || updatable()) { if (actualResource == null) { if (creatable) { - var desired = desired(primary, context); + var desired = getOrComputeDesired(context); throwIfNull(desired, primary, "Desired"); logForOperation("Creating", primary, desired); var createdResource = handleCreate(desired, primary, context); @@ -95,7 +96,8 @@ protected ReconcileResult reconcile(P primary, R actualResource, Context

c if (updatable()) { final Matcher.Result match = match(actualResource, primary, context); if (!match.matched()) { - final var desired = match.computedDesired().orElseGet(() -> desired(primary, context)); + final var desired = + match.computedDesired().orElseGet(() -> getOrComputeDesired(context)); throwIfNull(desired, primary, "Desired"); logForOperation("Updating", primary, desired); var updatedResource = handleUpdate(actualResource, desired, primary, context); @@ -127,7 +129,6 @@ protected ReconcileResult reconcile(P primary, R actualResource, Context

c @Override public Optional getSecondaryResource(P primary, Context

context) { - var secondaryResources = context.getSecondaryResources(resourceType()); if (secondaryResources.isEmpty()) { return Optional.empty(); @@ -212,6 +213,27 @@ protected R desired(P primary, Context

context) { + " updated"); } + /** + * Retrieves the desired state from the {@link Context} if it has already been computed or calls + * {@link #desired(HasMetadata, Context)} and stores its result in the context for further use. + * This ensures that {@code desired} is only called once per reconciliation to avoid unneeded + * processing and supports scenarios where idempotent computation of the desired state is not + * feasible. + * + *

Note that this method should normally only be called by the SDK itself and exclusively (i.e. + * {@link #desired(HasMetadata, Context)} should not be called directly by the SDK) whenever the + * desired state is needed to ensure it is properly cached for the current reconciliation. + * + * @param context the {@link Context} in scope for the current reconciliation + * @return the desired state associated with this dependent resource based on the currently + * in-scope primary resource as found in the context + */ + protected R getOrComputeDesired(Context

context) { + assert context instanceof DefaultContext

; + DefaultContext

defaultContext = (DefaultContext

) context; + return defaultContext.getOrComputeDesiredStateFor(this, p -> desired(p, defaultContext)); + } + public void delete(P primary, Context

context) { dependentResourceReconciler.delete(primary, context); } 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 e601e937cf..7b83a377c1 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 @@ -105,7 +105,7 @@ protected void handleExplicitStateCreation(P primary, R created, Context

cont @Override public Matcher.Result match(R resource, P primary, Context

context) { - var desired = desired(primary, context); + var desired = getOrComputeDesired(context); return Matcher.Result.computed(resource.equals(desired), desired); } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/BulkDependentResourceReconciler.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/BulkDependentResourceReconciler.java index 5b3617c26c..23135f81b1 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/BulkDependentResourceReconciler.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/BulkDependentResourceReconciler.java @@ -27,7 +27,6 @@ import io.javaoperatorsdk.operator.api.reconciler.Ignore; import io.javaoperatorsdk.operator.api.reconciler.dependent.Deleter; import io.javaoperatorsdk.operator.api.reconciler.dependent.ReconcileResult; -import io.javaoperatorsdk.operator.processing.dependent.Matcher.Result; class BulkDependentResourceReconciler implements DependentResourceReconciler { 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 0ba48797af..5562c883e2 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 @@ -138,7 +138,7 @@ public static Matcher.Result m Context

context, boolean labelsAndAnnotationsEquality, String... ignorePaths) { - final var desired = dependentResource.desired(primary, context); + final var desired = dependentResource.getOrComputeDesired(context); return match(desired, actualResource, labelsAndAnnotationsEquality, context, ignorePaths); } @@ -150,7 +150,7 @@ public static Matcher.Result m boolean specEquality, boolean labelsAndAnnotationsEquality, String... ignorePaths) { - final var desired = dependentResource.desired(primary, context); + final var desired = dependentResource.getOrComputeDesired(context); return match( desired, actualResource, labelsAndAnnotationsEquality, specEquality, context, ignorePaths); } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GroupVersionKindPlural.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GroupVersionKindPlural.java index 4818760888..a3ed4d2d97 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GroupVersionKindPlural.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GroupVersionKindPlural.java @@ -119,7 +119,7 @@ public static GroupVersionKindPlural gvkFor(Class resourc * @return the default plural form for the specified kind */ public static String getDefaultPluralFor(String kind) { - // todo: replace by Fabric8 version when available, see + // TODO replace by Fabric8 version when available, see // https://github.com/fabric8io/kubernetes-client/pull/6314 return kind != null ? Pluralize.toPlural(kind.toLowerCase()) : null; } 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 05cddcade1..f8d7c07b01 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 @@ -25,7 +25,6 @@ import io.fabric8.kubernetes.api.model.HasMetadata; import io.fabric8.kubernetes.api.model.Namespaced; -import io.fabric8.kubernetes.client.dsl.Resource; import io.javaoperatorsdk.operator.api.config.dependent.Configured; import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; import io.javaoperatorsdk.operator.api.reconciler.Context; @@ -55,7 +54,6 @@ public abstract class KubernetesDependentResource kubernetesDependentResourceConfig; private volatile Boolean useSSA; - private volatile Boolean usePreviousAnnotationForEventFiltering; public KubernetesDependentResource() {} @@ -74,7 +72,8 @@ public void configureWith(KubernetesDependentResourceConfig config) { @SuppressWarnings("unused") public R create(R desired, P primary, Context

context) { - if (useSSA(context)) { + var ssa = useSSA(context); + if (ssa) { // setting resource version for SSA so only created if it doesn't exist already var createIfNotExisting = kubernetesDependentResourceConfig == null @@ -86,35 +85,40 @@ public R create(R desired, P primary, Context

context) { } } addMetadata(false, null, desired, primary, context); - final var resource = prepare(context, desired, primary, "Creating"); - return useSSA(context) - ? resource - .fieldManager(context.getControllerConfiguration().fieldManager()) - .forceConflicts() - .serverSideApply() - : resource.create(); + log.debug( + "Creating target resource with type: {}, with id: {} use ssa: {}", + desired.getClass(), + ResourceID.fromResource(desired), + ssa); + + return ssa + ? context.resourceOperations().serverSideApply(desired, eventSource().orElse(null)) + : context.resourceOperations().create(desired, eventSource().orElse(null)); } public R update(R actual, R desired, P primary, Context

context) { - boolean useSSA = useSSA(context); + boolean ssa = useSSA(context); if (log.isDebugEnabled()) { log.debug( "Updating actual resource: {} version: {}; SSA: {}", ResourceID.fromResource(actual), actual.getMetadata().getResourceVersion(), - useSSA); + ssa); } R updatedResource; addMetadata(false, actual, desired, primary, context); - if (useSSA) { + log.debug( + "Updating target resource with type: {}, with id: {} use ssa: {}", + desired.getClass(), + ResourceID.fromResource(desired), + ssa); + if (ssa) { updatedResource = - prepare(context, desired, primary, "Updating") - .fieldManager(context.getControllerConfiguration().fieldManager()) - .forceConflicts() - .serverSideApply(); + context.resourceOperations().serverSideApply(desired, eventSource().orElse(null)); } else { var updatedActual = GenericResourceUpdater.updateResource(actual, desired, context); - updatedResource = prepare(context, updatedActual, primary, "Updating").update(); + updatedResource = + context.resourceOperations().update(updatedActual, eventSource().orElse(null)); } log.debug( "Resource version after update: {}", updatedResource.getMetadata().getResourceVersion()); @@ -123,7 +127,7 @@ public R update(R actual, R desired, P primary, Context

context) { @Override public Result match(R actualResource, P primary, Context

context) { - final var desired = desired(primary, context); + final var desired = getOrComputeDesired(context); return match(actualResource, desired, primary, context); } @@ -158,14 +162,6 @@ protected void addMetadata( } else { annotations.remove(InformerEventSource.PREVIOUS_ANNOTATION_KEY); } - } else if (usePreviousAnnotation(context)) { // set a new one - eventSource() - .orElseThrow() - .addPreviousAnnotation( - Optional.ofNullable(actualResource) - .map(r -> r.getMetadata().getResourceVersion()) - .orElse(null), - target); } addReferenceHandlingMetadata(target, primary); } @@ -181,22 +177,6 @@ protected boolean useSSA(Context

context) { return useSSA; } - private boolean usePreviousAnnotation(Context

context) { - if (usePreviousAnnotationForEventFiltering == null) { - usePreviousAnnotationForEventFiltering = - context - .getControllerConfiguration() - .getConfigurationService() - .previousAnnotationForDependentResourcesEventFiltering() - && !context - .getControllerConfiguration() - .getConfigurationService() - .withPreviousAnnotationForDependentResourcesBlocklist() - .contains(this.resourceType()); - } - return usePreviousAnnotationForEventFiltering; - } - @Override protected void handleDelete(P primary, R secondary, Context

context) { if (secondary != null) { @@ -209,17 +189,6 @@ public void deleteTargetResource(P primary, R resource, ResourceID key, Context< context.getClient().resource(resource).delete(); } - @SuppressWarnings("unused") - protected Resource prepare(Context

context, R desired, P primary, String actionName) { - log.debug( - "{} target resource with type: {}, with id: {}", - actionName, - desired.getClass(), - ResourceID.fromResource(desired)); - - return context.getClient().resource(desired); - } - protected void addReferenceHandlingMetadata(R desired, P primary) { if (addOwnerReference()) { desired.addOwnerReference(primary); @@ -301,7 +270,7 @@ protected Optional selectTargetSecondaryResource( * @return id of the target managed resource */ protected ResourceID targetSecondaryResourceID(P primary, Context

context) { - return ResourceID.fromResource(desired(primary, context)); + return ResourceID.fromResource(getOrComputeDesired(context)); } protected boolean addOwnerReference() { @@ -309,8 +278,8 @@ protected boolean addOwnerReference() { } @Override - protected R desired(P primary, Context

context) { - return super.desired(primary, context); + protected R getOrComputeDesired(Context

context) { + return super.getOrComputeDesired(context); } @Override diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/AbstractWorkflowExecutor.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/AbstractWorkflowExecutor.java index 12099ffa25..f032c1a05c 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/AbstractWorkflowExecutor.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/AbstractWorkflowExecutor.java @@ -174,10 +174,10 @@ protected void submit( DependentResourceNode dependentResourceNode, NodeExecutor nodeExecutor, String operation) { + logger() + .debug("Submitting to {}: {} primaryID: {}", operation, dependentResourceNode, primaryID); final Future future = executorService.submit(nodeExecutor); markAsExecuting(dependentResourceNode, future); - logger() - .debug("Submitted to {}: {} primaryID: {}", operation, dependentResourceNode, primaryID); } protected void registerOrDeregisterEventSourceBasedOnActivation( diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/NodeExecutor.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/NodeExecutor.java index 98756f7eb6..6da5d0f0ff 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/NodeExecutor.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/NodeExecutor.java @@ -19,6 +19,7 @@ import org.slf4j.LoggerFactory; import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.processing.MDCUtils; abstract class NodeExecutor implements Runnable { @@ -36,19 +37,22 @@ protected NodeExecutor( @Override public void run() { - try { - doRun(dependentResourceNode); - - } catch (Exception e) { - // Exception is required because of Kotlin - workflowExecutor.handleExceptionInExecutor(dependentResourceNode, e); - } catch (Error e) { - // without this user would see no sign about the error - log.error("java.lang.Error during execution", e); - throw e; - } finally { - workflowExecutor.handleNodeExecutionFinish(dependentResourceNode); - } + MDCUtils.withMDCForResource( + workflowExecutor.primary, + () -> { + try { + doRun(dependentResourceNode); + } catch (Exception e) { + // Exception is required because of Kotlin + workflowExecutor.handleExceptionInExecutor(dependentResourceNode, e); + } catch (Error e) { + // without this user would see no sign about the error + log.error("java.lang.Error during execution", e); + throw e; + } finally { + workflowExecutor.handleNodeExecutionFinish(dependentResourceNode); + } + }); } protected abstract void doRun(DependentResourceNode dependentResourceNode); diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java index 3685b509aa..1aecac6c9a 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java @@ -37,15 +37,13 @@ import io.javaoperatorsdk.operator.processing.event.rate.RateLimiter; import io.javaoperatorsdk.operator.processing.event.rate.RateLimiter.RateLimitState; import io.javaoperatorsdk.operator.processing.event.source.Cache; -import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceAction; +import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceDeleteEvent; import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEvent; import io.javaoperatorsdk.operator.processing.event.source.timer.TimerEventSource; import io.javaoperatorsdk.operator.processing.retry.Retry; import io.javaoperatorsdk.operator.processing.retry.RetryExecution; -import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getName; - public class EventProcessor

implements EventHandler, LifecycleAware { private static final Logger log = LoggerFactory.getLogger(EventProcessor.class); @@ -129,7 +127,7 @@ public synchronized void handleEvent(Event event) { var state = optionalState.orElseThrow(); final var resourceID = event.getRelatedCustomResourceID(); MDCUtils.addResourceIDInfo(resourceID); - metrics.receivedEvent(event, metricsMetadata); + metrics.eventReceived(event, metricsMetadata); handleEventMarking(event, state); if (!this.running) { if (state.deleteEventPresent()) { @@ -182,14 +180,13 @@ private void submitReconciliationExecution(ResourceState state) { state.deleteEventPresent(), state.isDeleteFinalStateUnknown()); state.unMarkEventReceived(triggerOnAllEvents()); - metrics.reconcileCustomResource(latest, state.getRetry(), metricsMetadata); + metrics.reconciliationSubmitted(latest, state.getRetry(), metricsMetadata); log.debug("Executing events for custom resource. Scope: {}", executionScope); executor.execute(new ReconcilerExecutor(resourceID, executionScope)); } else { log.debug( - "Skipping executing controller for resource id: {}. Controller in execution: {}. Latest" + "Skipping executing controller. Controller in execution: {}. Latest" + " Resource present: {}", - resourceID, controllerUnderExecution, maybeLatest.isPresent()); if (maybeLatest.isEmpty()) { @@ -198,7 +195,7 @@ private void submitReconciliationExecution(ResourceState state) { // resource. Other is that simply there is no primary resource present for an event, this // might indicate issue with the implementation, but could happen also naturally, thus // this is not necessarily a problem. - log.debug("no primary resource found in cache with resource id: {}", resourceID); + log.debug("No primary resource found in cache with resource id: {}", resourceID); } } } finally { @@ -209,7 +206,7 @@ private void submitReconciliationExecution(ResourceState state) { @SuppressWarnings("unchecked") private P getResourceFromState(ResourceState state) { if (triggerOnAllEvents()) { - log.debug("Getting resource from state for {}", state.getId()); + log.debug("Getting resource from state"); return (P) state.getLastKnownResource(); } else { throw new IllegalStateException( @@ -218,10 +215,9 @@ private P getResourceFromState(ResourceState state) { } private void handleEventMarking(Event event, ResourceState state) { - final var relatedCustomResourceID = event.getRelatedCustomResourceID(); if (event instanceof ResourceEvent resourceEvent) { if (resourceEvent.getAction() == ResourceAction.DELETED) { - log.debug("Marking delete event received for: {}", relatedCustomResourceID); + log.debug("Marking delete event received"); state.markDeleteEventReceived( resourceEvent.getResource().orElseThrow(), ((ResourceDeleteEvent) resourceEvent).isDeletedFinalStateUnknown()); @@ -229,8 +225,7 @@ private void handleEventMarking(Event event, ResourceState state) { if (state.processedMarkForDeletionPresent() && isResourceMarkedForDeletion(resourceEvent)) { log.debug( "Skipping mark of event received, since already processed mark for deletion and" - + " resource marked for deletion: {}", - relatedCustomResourceID); + + " resource marked for deletion"); return; } // Normally when eventMarker is in state PROCESSED_MARK_FOR_DELETION it is expected to @@ -260,8 +255,7 @@ private boolean isResourceMarkedForDeletion(ResourceEvent resourceEvent) { private void handleRateLimitedSubmission(ResourceID resourceID, Duration minimalDuration) { var minimalDurationMillis = minimalDuration.toMillis(); - log.debug( - "Rate limited resource: {}, rescheduled in {} millis", resourceID, minimalDurationMillis); + log.debug("Rate limited resource; rescheduled in {} millis", minimalDurationMillis); retryEventSource() .scheduleOnce( resourceID, Math.max(minimalDurationMillis, MINIMAL_RATE_LIMIT_RESCHEDULE_DURATION)); @@ -292,13 +286,12 @@ synchronized void eventProcessingFinished( return; } cleanupOnSuccessfulExecution(executionScope); - metrics.finishedReconciliation(executionScope.getResource(), metricsMetadata); + metrics.reconciliationSucceeded(executionScope.getResource(), metricsMetadata); if ((triggerOnAllEvents() && executionScope.isDeleteEvent()) || (!triggerOnAllEvents() && state.deleteEventPresent())) { cleanupForDeletedEvent(executionScope.getResourceID()); } else if (postExecutionControl.isFinalizerRemoved()) { state.markProcessedMarkForDeletion(); - metrics.cleanupDoneFor(resourceID, metricsMetadata); } else { if (state.eventPresent() || isTriggerOnAllEventAndDeleteEventPresent(state)) { log.debug("Submitting for reconciliation."); @@ -334,7 +327,7 @@ private void reScheduleExecutionIfInstructed( .ifPresentOrElse( delay -> { var resourceID = ResourceID.fromResource(customResource); - log.debug("Rescheduling event for resource: {} with delay: {}", resourceID, delay); + log.debug("Rescheduling event with delay: {}", delay); retryEventSource().scheduleOnce(resourceID, delay); }, () -> scheduleExecutionForMaxReconciliationInterval(customResource)); @@ -347,11 +340,7 @@ private void scheduleExecutionForMaxReconciliationInterval(P customResource) { m -> { var resourceID = ResourceID.fromResource(customResource); var delay = m.toMillis(); - log.debug( - "Rescheduling event for max reconciliation interval for resource: {} : " - + "with delay: {}", - resourceID, - delay); + log.debug("Rescheduling event for max reconciliation interval with delay: {}", delay); retryEventSource().scheduleOnce(resourceID, delay); }); } @@ -372,20 +361,18 @@ private void handleRetryOnException(ExecutionScope

executionScope, Exception state.eventPresent() || (triggerOnAllEvents() && state.isAdditionalEventPresentAfterDeleteEvent()); state.markEventReceived(triggerOnAllEvents()); - retryAwareErrorLogging(state.getRetry(), eventPresent, exception, executionScope); + metrics.reconciliationFailed( + executionScope.getResource(), state.getRetry(), exception, metricsMetadata); if (eventPresent) { - log.debug("New events exists for for resource id: {}", resourceID); + log.debug("New events exist for resource id"); submitReconciliationExecution(state); return; } Optional nextDelay = state.getRetry().nextDelay(); - nextDelay.ifPresentOrElse( delay -> { - log.debug( - "Scheduling timer event for retry with delay:{} for resource: {}", delay, resourceID); - metrics.failedReconciliation(executionScope.getResource(), exception, metricsMetadata); + log.debug("Scheduling timer event for retry with delay:{}", delay); retryEventSource().scheduleOnce(resourceID, delay); }, () -> { @@ -425,8 +412,7 @@ private void retryAwareErrorLogging( } private void cleanupOnSuccessfulExecution(ExecutionScope

executionScope) { - log.debug( - "Cleanup for successful execution for resource: {}", getName(executionScope.getResource())); + log.debug("Cleanup for successful execution"); if (isRetryConfigured()) { resourceStateManager.getOrCreate(executionScope.getResourceID()).setRetry(null); } @@ -444,9 +430,9 @@ private ResourceState getOrInitRetryExecution(ExecutionScope

executionScope) } private void cleanupForDeletedEvent(ResourceID resourceID) { - log.debug("Cleaning up for delete event for: {}", resourceID); + log.debug("Cleaning up for delete event"); resourceStateManager.remove(resourceID); - metrics.cleanupDoneFor(resourceID, metricsMetadata); + metrics.cleanupDone(resourceID, metricsMetadata); } private boolean isControllerUnderExecution(ResourceState state) { @@ -509,6 +495,7 @@ public void run() { log.debug("Event processor not running skipping resource processing: {}", resourceID); return; } + MDCUtils.addResourceIDInfo(resourceID); log.debug("Running reconcile executor for: {}", executionScope); // change thread name for easier debugging final var thread = Thread.currentThread(); @@ -518,9 +505,7 @@ public void run() { var actualResource = cache.get(resourceID); if (actualResource.isEmpty()) { if (triggerOnAllEvents()) { - log.debug( - "Resource not found in the cache, checking for delete event resource: {}", - resourceID); + log.debug("Resource not found in the cache, checking for delete event resource"); if (executionScope.isDeleteEvent()) { var state = resourceStateManager.get(resourceID); actualResource = @@ -538,19 +523,20 @@ public void run() { return; } } else { - log.debug("Skipping execution; primary resource missing from cache: {}", resourceID); + log.debug("Skipping execution; primary resource missing from cache"); return; } } actualResource.ifPresent(executionScope::setResource); MDCUtils.addResourceInfo(executionScope.getResource()); - metrics.reconciliationExecutionStarted(executionScope.getResource(), metricsMetadata); + metrics.reconciliationStarted(executionScope.getResource(), metricsMetadata); thread.setName("ReconcilerExecutor-" + controllerName() + "-" + thread.getId()); PostExecutionControl

postExecutionControl = reconciliationDispatcher.handleExecution(executionScope); eventProcessingFinished(executionScope, postExecutionControl); } finally { - metrics.reconciliationExecutionFinished(executionScope.getResource(), metricsMetadata); + metrics.reconciliationFinished( + executionScope.getResource(), executionScope.getRetryInfo(), metricsMetadata); // restore original name thread.setName(name); MDCUtils.removeResourceInfo(); diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventSourceManager.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventSourceManager.java index 411fc10e31..441d3cf178 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventSourceManager.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventSourceManager.java @@ -37,9 +37,9 @@ import io.javaoperatorsdk.operator.processing.LifecycleAware; import io.javaoperatorsdk.operator.processing.event.source.EventSource; import io.javaoperatorsdk.operator.processing.event.source.EventSourceStartPriority; +import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; import io.javaoperatorsdk.operator.processing.event.source.ResourceEventAware; import io.javaoperatorsdk.operator.processing.event.source.controller.ControllerEventSource; -import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceAction; import io.javaoperatorsdk.operator.processing.event.source.informer.ManagedInformerEventSource; import io.javaoperatorsdk.operator.processing.event.source.timer.TimerEventSource; @@ -217,7 +217,12 @@ public Set> getRegisteredEventSources() { @SuppressWarnings("rawtypes") public List allEventSources() { - return eventSources.allEventSources().toList(); + return allEventSourcesStream().toList(); + } + + @SuppressWarnings("rawtypes") + public Stream allEventSourcesStream() { + return eventSources.allEventSources(); } @SuppressWarnings("unused") 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 da4ae9835a..6e7ace0447 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 @@ -15,25 +15,16 @@ */ package io.javaoperatorsdk.operator.processing.event; -import java.lang.reflect.InvocationTargetException; import java.net.HttpURLConnection; -import java.util.function.Function; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.event.Level; import io.fabric8.kubernetes.api.model.HasMetadata; -import io.fabric8.kubernetes.api.model.KubernetesResourceList; -import io.fabric8.kubernetes.api.model.Namespaced; -import io.fabric8.kubernetes.api.model.ObjectMeta; import io.fabric8.kubernetes.client.KubernetesClientException; -import io.fabric8.kubernetes.client.dsl.MixedOperation; -import io.fabric8.kubernetes.client.dsl.Resource; -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.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.config.Cloner; import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; import io.javaoperatorsdk.operator.api.reconciler.BaseControl; @@ -49,8 +40,6 @@ /** Handles calls and results of a Reconciler and finalizer related logic */ class ReconciliationDispatcher

{ - public static final int MAX_UPDATE_RETRY = 10; - private static final Logger log = LoggerFactory.getLogger(ReconciliationDispatcher.class); private final Controller

controller; @@ -76,7 +65,6 @@ public ReconciliationDispatcher(Controller

controller) { this( controller, new CustomResourceFacade<>( - controller.getCRClient(), controller.getConfiguration(), controller.getConfiguration().getConfigurationService().getResourceCloner())); } @@ -84,42 +72,40 @@ public ReconciliationDispatcher(Controller

controller) { public PostExecutionControl

handleExecution(ExecutionScope

executionScope) { validateExecutionScope(executionScope); try { - return handleDispatch(executionScope); + return handleDispatch(executionScope, null); } catch (Exception e) { return PostExecutionControl.exceptionDuringExecution(e); } } - private PostExecutionControl

handleDispatch(ExecutionScope

executionScope) + // visible for testing + PostExecutionControl

handleDispatch(ExecutionScope

executionScope, Context

context) throws Exception { P originalResource = executionScope.getResource(); var resourceForExecution = cloneResource(originalResource); - log.debug( - "Handling dispatch for resource name: {} namespace: {}", - getName(originalResource), - originalResource.getMetadata().getNamespace()); + log.debug("Handling dispatch"); final var markedForDeletion = originalResource.isMarkedForDeletion(); if (!triggerOnAllEvents() && markedForDeletion && shouldNotDispatchToCleanupWhenMarkedForDeletion(originalResource)) { - log.debug( - "Skipping cleanup of resource {} because finalizer(s) {} don't allow processing yet", - getName(originalResource), - originalResource.getMetadata().getFinalizers()); + log.debug("Skipping cleanup because finalizer(s) don't allow processing yet"); return PostExecutionControl.defaultDispatch(); } - Context

context = - new DefaultContext<>( - executionScope.getRetryInfo(), - controller, - resourceForExecution, - executionScope.isDeleteEvent(), - executionScope.isDeleteFinalStateUnknown()); + // context can be provided only for testing purposes + context = + context == null + ? new DefaultContext<>( + executionScope.getRetryInfo(), + controller, + resourceForExecution, + executionScope.isDeleteEvent(), + executionScope.isDeleteFinalStateUnknown()) + : context; // checking the cleaner for all-event-mode if (!triggerOnAllEvents() && markedForDeletion) { - return handleCleanup(resourceForExecution, originalResource, context, executionScope); + return handleCleanup(resourceForExecution, context, executionScope); } else { return handleReconcile(executionScope, resourceForExecution, originalResource, context); } @@ -148,11 +134,12 @@ private PostExecutionControl

handleReconcile( */ P updatedResource; if (useSSA) { - updatedResource = addFinalizerWithSSA(originalResource); + updatedResource = context.resourceOperations().addFinalizerWithSSA(); } else { - updatedResource = updateCustomResourceWithFinalizer(resourceForExecution, originalResource); + updatedResource = context.resourceOperations().addFinalizer(); } - return PostExecutionControl.onlyFinalizerAdded(updatedResource); + return PostExecutionControl.onlyFinalizerAdded(updatedResource) + .withReSchedule(BaseControl.INSTANT_RESCHEDULE); } else { try { return reconcileExecution(executionScope, resourceForExecution, originalResource, context); @@ -172,11 +159,7 @@ private PostExecutionControl

reconcileExecution( P originalResource, Context

context) throws Exception { - log.debug( - "Reconciling resource {} with version: {} with execution scope: {}", - getName(resourceForExecution), - getVersion(resourceForExecution), - executionScope); + log.debug("Reconciling resource execution scope: {}", executionScope); UpdateControl

updateControl = controller.reconcile(resourceForExecution, context); @@ -194,7 +177,7 @@ private PostExecutionControl

reconcileExecution( } if (updateControl.isPatchResource()) { - updatedCustomResource = patchResource(toUpdate, originalResource); + updatedCustomResource = patchResource(context, toUpdate, originalResource); if (!useSSA) { toUpdate .getMetadata() @@ -203,7 +186,7 @@ private PostExecutionControl

reconcileExecution( } if (updateControl.isPatchStatus()) { - customResourceFacade.patchStatus(toUpdate, originalResource); + customResourceFacade.patchStatus(context, toUpdate, originalResource); } return createPostExecutionControl(updatedCustomResource, updateControl, executionScope); } @@ -241,7 +224,7 @@ public boolean isLastAttempt() { try { updatedResource = customResourceFacade.patchStatus( - errorStatusUpdateControl.getResource().orElseThrow(), originalResource); + context, errorStatusUpdateControl.getResource().orElseThrow(), originalResource); } catch (Exception ex) { int code = ex instanceof KubernetesClientException kcex ? kcex.getCode() : -1; Level exceptionLevel = Level.ERROR; @@ -253,9 +236,8 @@ public boolean isLastAttempt() { exceptionLevel = Level.DEBUG; failedMessage = " due to conflict"; log.info( - "ErrorStatusUpdateControl.patchStatus of {} failed due to a conflict, but the next" - + " reconciliation is imminent.", - ResourceID.fromResource(originalResource)); + "ErrorStatusUpdateControl.patchStatus failed due to a conflict, but the next" + + " reconciliation is imminent"); } else { exceptionLevel = Level.WARN; failedMessage = ", but will be retried soon,"; @@ -317,15 +299,9 @@ private void updatePostExecutionControlWithReschedule( } private PostExecutionControl

handleCleanup( - P resourceForExecution, - P originalResource, - Context

context, - ExecutionScope

executionScope) { + P resourceForExecution, Context

context, ExecutionScope

executionScope) { if (log.isDebugEnabled()) { - log.debug( - "Executing delete for resource: {} with version: {}", - ResourceID.fromResource(resourceForExecution), - getVersion(resourceForExecution)); + log.debug("Executing delete for resource"); } DeleteControl deleteControl = controller.cleanup(resourceForExecution, context); final var useFinalizer = controller.useFinalizer(); @@ -334,32 +310,12 @@ private PostExecutionControl

handleCleanup( // cleanup is finished, nothing left to be done final var finalizerName = configuration().getFinalizerName(); if (deleteControl.isRemoveFinalizer() && resourceForExecution.hasFinalizer(finalizerName)) { - P customResource = - conflictRetryingPatch( - resourceForExecution, - originalResource, - r -> { - // the operator might not be allowed to retrieve the resource on a retry, e.g. - // when its - // permissions are removed by deleting the namespace concurrently - if (r == null) { - log.warn( - "Could not remove finalizer on null resource: {} with version: {}", - getUID(resourceForExecution), - getVersion(resourceForExecution)); - return false; - } - return r.removeFinalizer(finalizerName); - }, - true); + P customResource = context.resourceOperations().removeFinalizer(); return PostExecutionControl.customResourceFinalizerRemoved(customResource); } } log.debug( - "Skipping finalizer remove for resource: {} with version: {}. delete control: {}, uses" - + " finalizer: {}", - getUID(resourceForExecution), - getVersion(resourceForExecution), + "Skipping finalizer remove for resource. Delete control: {}, uses finalizer: {}", deleteControl, useFinalizer); PostExecutionControl

postExecutionControl = PostExecutionControl.defaultDispatch(); @@ -367,50 +323,10 @@ private PostExecutionControl

handleCleanup( return postExecutionControl; } - @SuppressWarnings("unchecked") - private P addFinalizerWithSSA(P originalResource) { - log.debug( - "Adding finalizer (using SSA) for resource: {} version: {}", - getUID(originalResource), - getVersion(originalResource)); - try { - P resource = (P) originalResource.getClass().getConstructor().newInstance(); - ObjectMeta objectMeta = new ObjectMeta(); - objectMeta.setName(originalResource.getMetadata().getName()); - objectMeta.setNamespace(originalResource.getMetadata().getNamespace()); - resource.setMetadata(objectMeta); - resource.addFinalizer(configuration().getFinalizerName()); - return customResourceFacade.patchResourceWithSSA(resource); - } catch (InstantiationException - | IllegalAccessException - | InvocationTargetException - | NoSuchMethodException e) { - throw new RuntimeException( - "Issue with creating custom resource instance with reflection." - + " Custom Resources must provide a no-arg constructor. Class: " - + originalResource.getClass().getName(), - e); + private P patchResource(Context

context, P resource, P originalResource) { + if (log.isDebugEnabled()) { + log.debug("Updating resource; with SSA: {}", useSSA); } - } - - private P updateCustomResourceWithFinalizer(P resourceForExecution, P originalResource) { - log.debug( - "Adding finalizer for resource: {} version: {}", - getUID(originalResource), - getVersion(originalResource)); - return conflictRetryingPatch( - resourceForExecution, - originalResource, - r -> r.addFinalizer(configuration().getFinalizerName()), - false); - } - - private P patchResource(P resource, P originalResource) { - log.debug( - "Updating resource: {} with version: {}; SSA: {}", - getUID(resource), - getVersion(resource), - useSSA); log.trace("Resource before update: {}", resource); final var finalizerName = configuration().getFinalizerName(); @@ -418,64 +334,13 @@ private P patchResource(P resource, P originalResource) { // addFinalizer already prevents adding an already present finalizer so no need to check resource.addFinalizer(finalizerName); } - return customResourceFacade.patchResource(resource, originalResource); + return customResourceFacade.patchResource(context, resource, originalResource); } ControllerConfiguration

configuration() { return controller.getConfiguration(); } - public P conflictRetryingPatch( - P resource, - P originalResource, - Function modificationFunction, - boolean forceNotUseSSA) { - if (log.isDebugEnabled()) { - log.debug("Conflict retrying update for: {}", ResourceID.fromResource(resource)); - } - int retryIndex = 0; - while (true) { - try { - var modified = modificationFunction.apply(resource); - if (Boolean.FALSE.equals(modified)) { - return resource; - } - if (forceNotUseSSA) { - return customResourceFacade.patchResourceWithoutSSA(resource, originalResource); - } else { - return customResourceFacade.patchResource(resource, originalResource); - } - } catch (KubernetesClientException e) { - log.trace("Exception during patch for resource: {}", resource); - retryIndex++; - // only retry on conflict (409) and unprocessable content (422) which - // can happen if JSON Patch is not a valid request since there was - // a concurrent request which already removed another finalizer: - // List element removal from a list is by index in JSON Patch - // so if addressing a second finalizer but first is meanwhile removed - // it is a wrong request. - if (e.getCode() != 409 && e.getCode() != 422) { - throw e; - } - if (retryIndex >= MAX_UPDATE_RETRY) { - throw new OperatorException( - "Exceeded maximum (" - + MAX_UPDATE_RETRY - + ") retry attempts to patch resource: " - + ResourceID.fromResource(resource)); - } - log.debug( - "Retrying patch for resource name: {}, namespace: {}; HTTP code: {}", - resource.getMetadata().getName(), - resource.getMetadata().getNamespace(), - e.getCode()); - resource = - customResourceFacade.getResource( - resource.getMetadata().getNamespace(), resource.getMetadata().getName()); - } - } - } - private void validateExecutionScope(ExecutionScope

executionScope) { if (!triggerOnAllEvents() && (executionScope.isDeleteEvent() || executionScope.isDeleteFinalStateUnknown())) { @@ -488,70 +353,41 @@ private void validateExecutionScope(ExecutionScope

executionScope) { // created to support unit testing static class CustomResourceFacade { - private final MixedOperation, Resource> resourceOperation; private final boolean useSSA; - private final String fieldManager; private final Cloner cloner; - public CustomResourceFacade( - MixedOperation, Resource> resourceOperation, - ControllerConfiguration configuration, - Cloner cloner) { - this.resourceOperation = resourceOperation; + public CustomResourceFacade(ControllerConfiguration configuration, Cloner cloner) { this.useSSA = configuration.getConfigurationService().useSSAToPatchPrimaryResource(); - this.fieldManager = configuration.fieldManager(); this.cloner = cloner; } - public R getResource(String namespace, String name) { - if (namespace != null) { - return resourceOperation.inNamespace(namespace).withName(name).get(); - } else { - return resourceOperation.withName(name).get(); - } - } - - public R patchResourceWithoutSSA(R resource, R originalResource) { - return resource(originalResource).edit(r -> resource); - } - - public R patchResource(R resource, R originalResource) { + public R patchResource(Context context, R resource, R originalResource) { if (log.isDebugEnabled()) { - log.debug( - "Trying to replace resource {}, version: {}", - ResourceID.fromResource(resource), - resource.getMetadata().getResourceVersion()); + log.debug("Trying to replace resource"); } if (useSSA) { - return patchResourceWithSSA(resource); + return context.resourceOperations().serverSideApplyPrimary(resource); } else { - return resource(originalResource).edit(r -> resource); + return context.resourceOperations().jsonPatchPrimary(originalResource, r -> resource); } } - public R patchStatus(R resource, R originalResource) { + public R patchStatus(Context context, R resource, R originalResource) { log.trace("Patching status for resource: {} with ssa: {}", resource, useSSA); if (useSSA) { var managedFields = resource.getMetadata().getManagedFields(); try { resource.getMetadata().setManagedFields(null); - var res = resource(resource); - return res.subresource("status") - .patch( - new PatchContext.Builder() - .withFieldManager(fieldManager) - .withForce(true) - .withPatchType(PatchType.SERVER_SIDE_APPLY) - .build()); + return context.resourceOperations().serverSideApplyPrimaryStatus(resource); } finally { resource.getMetadata().setManagedFields(managedFields); } } else { - return editStatus(resource, originalResource); + return editStatus(context, resource, originalResource); } } - private R editStatus(R resource, R originalResource) { + private R editStatus(Context context, R resource, R originalResource) { String resourceVersion = resource.getMetadata().getResourceVersion(); // the cached resource should not be changed in any circumstances // that can lead to all kinds of race conditions. @@ -559,34 +395,20 @@ private R editStatus(R resource, R originalResource) { try { clonedOriginal.getMetadata().setResourceVersion(null); resource.getMetadata().setResourceVersion(null); - var res = resource(clonedOriginal); - return res.editStatus( - r -> { - ReconcilerUtils.setStatus(r, ReconcilerUtils.getStatus(resource)); - return r; - }); + return context + .resourceOperations() + .jsonPatchPrimaryStatus( + clonedOriginal, + r -> { + ReconcilerUtilsInternal.setStatus(r, ReconcilerUtilsInternal.getStatus(resource)); + return r; + }); } finally { // restore initial resource version clonedOriginal.getMetadata().setResourceVersion(resourceVersion); resource.getMetadata().setResourceVersion(resourceVersion); } } - - public R patchResourceWithSSA(R resource) { - return resource(resource) - .patch( - new PatchContext.Builder() - .withFieldManager(fieldManager) - .withForce(true) - .withPatchType(PatchType.SERVER_SIDE_APPLY) - .build()); - } - - private Resource resource(R resource) { - return resource instanceof Namespaced - ? resourceOperation.inNamespace(resource.getMetadata().getNamespace()).resource(resource) - : resourceOperation.resource(resource); - } } private boolean triggerOnAllEvents() { diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ResourceID.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ResourceID.java index 9db8c7539f..da408322f1 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ResourceID.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ResourceID.java @@ -63,9 +63,28 @@ public boolean equals(Object o) { } public boolean isSameResource(HasMetadata hasMetadata) { + if (hasMetadata == null) { + return false; + } final var metadata = hasMetadata.getMetadata(); - return getName().equals(metadata.getName()) - && getNamespace().map(ns -> ns.equals(metadata.getNamespace())).orElse(true); + return isSameResource(metadata.getName(), metadata.getNamespace()); + } + + /** + * Whether this ResourceID points to the same resource as the one identified by the specified name + * and namespace. + * + *

Note that this doesn't take API version or Kind into account so this should only be used + * when checking resources that are reasonably expected to be of the same type. + * + * @param name the name of the resource we want to check + * @param namespace the possibly {@code null} namespace of the resource we want to check + * @return {@code true} if this resource points to the same resource as the one pointed to by the + * specified name and namespace, {@code false} otherwise + * @since 5.3.0 + */ + public boolean isSameResource(String name, String namespace) { + return Objects.equals(this.name, name) && Objects.equals(this.namespace, namespace); } @Override diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/CacheKeyMapper.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/CacheKeyMapper.java new file mode 100644 index 0000000000..3e1a4f9b14 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/CacheKeyMapper.java @@ -0,0 +1,15 @@ +/* + * 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. + */ diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ResourceAction.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/ResourceAction.java similarity index 90% rename from operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ResourceAction.java rename to operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/ResourceAction.java index 33c4c5a2d6..fff8680913 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ResourceAction.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/ResourceAction.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.processing.event.source.controller; +package io.javaoperatorsdk.operator.processing.event.source; public enum ResourceAction { ADDED, diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java index b7a6406e20..e0682d5808 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java @@ -28,12 +28,13 @@ import io.javaoperatorsdk.operator.processing.Controller; import io.javaoperatorsdk.operator.processing.MDCUtils; import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; import io.javaoperatorsdk.operator.processing.event.source.filter.OnDeleteFilter; import io.javaoperatorsdk.operator.processing.event.source.filter.OnUpdateFilter; import io.javaoperatorsdk.operator.processing.event.source.informer.ManagedInformerEventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.TemporaryResourceCache.EventHandling; -import static io.javaoperatorsdk.operator.ReconcilerUtils.handleKubernetesClientException; -import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getVersion; +import static io.javaoperatorsdk.operator.ReconcilerUtilsInternal.handleKubernetesClientException; import static io.javaoperatorsdk.operator.processing.event.source.controller.InternalEventFilters.*; public class ControllerEventSource @@ -47,7 +48,11 @@ public class ControllerEventSource @SuppressWarnings({"unchecked", "rawtypes"}) public ControllerEventSource(Controller controller) { - super(NAME, controller.getCRClient(), controller.getConfiguration(), false); + super( + NAME, + controller.getCRClient(), + controller.getConfiguration(), + controller.getConfiguration().getInformerConfig().isComparableResourceVersions()); this.controller = controller; final var config = controller.getConfiguration(); @@ -77,16 +82,12 @@ public synchronized void start() { } } - public void eventReceived( + @Override + protected synchronized void handleEvent( ResourceAction action, T resource, T oldResource, Boolean deletedFinalStateUnknown) { try { if (log.isDebugEnabled()) { - log.debug( - "Event received for resource: {} version: {} uuid: {} action: {}", - ResourceID.fromResource(resource), - getVersion(resource), - resource.getMetadata().getUid(), - action); + log.debug("Event received with action: {}", action); log.trace("Event Old resource: {},\n new resource: {}", oldResource, resource); } MDCUtils.addResourceInfo(resource); @@ -105,7 +106,7 @@ public void eventReceived( .handleEvent(new ResourceEvent(action, ResourceID.fromResource(resource), resource)); } } else { - log.debug("Skipping event handling resource {}", ResourceID.fromResource(resource)); + log.debug("Skipping event handling for resource"); } } finally { MDCUtils.removeResourceInfo(); @@ -117,31 +118,51 @@ private boolean isAcceptedByFilters(ResourceAction action, T resource, T oldReso if (genericFilter != null && !genericFilter.accept(resource)) { return false; } - switch (action) { - case ADDED: - return onAddFilter == null || onAddFilter.accept(resource); - case UPDATED: - return onUpdateFilter.accept(resource, oldResource); - } - return true; + return switch (action) { + case ADDED -> onAddFilter == null || onAddFilter.accept(resource); + case UPDATED -> onUpdateFilter.accept(resource, oldResource); + default -> true; + }; } @Override - public void onAdd(T resource) { - super.onAdd(resource); - eventReceived(ResourceAction.ADDED, resource, null, null); + public synchronized void onAdd(T resource) { + withMDC( + resource, + ResourceAction.ADDED, + () -> handleOnAddOrUpdate(ResourceAction.ADDED, null, resource)); } @Override - public void onUpdate(T oldCustomResource, T newCustomResource) { - super.onUpdate(oldCustomResource, newCustomResource); - eventReceived(ResourceAction.UPDATED, newCustomResource, oldCustomResource, null); + public synchronized void onUpdate(T oldCustomResource, T newCustomResource) { + withMDC( + newCustomResource, + ResourceAction.UPDATED, + () -> handleOnAddOrUpdate(ResourceAction.UPDATED, oldCustomResource, newCustomResource)); + } + + private void handleOnAddOrUpdate( + ResourceAction action, T oldCustomResource, T newCustomResource) { + var handling = + temporaryResourceCache.onAddOrUpdateEvent(action, newCustomResource, oldCustomResource); + if (handling == EventHandling.NEW) { + handleEvent(action, newCustomResource, oldCustomResource, null); + } else if (log.isDebugEnabled()) { + log.debug("{} event propagation for action: {}", handling, action); + } } @Override - public void onDelete(T resource, boolean deletedFinalStateUnknown) { - super.onDelete(resource, deletedFinalStateUnknown); - eventReceived(ResourceAction.DELETED, resource, null, deletedFinalStateUnknown); + public synchronized void onDelete(T resource, boolean deletedFinalStateUnknown) { + withMDC( + resource, + ResourceAction.DELETED, + () -> { + temporaryResourceCache.onDeleteEvent(resource, deletedFinalStateUnknown); + // delete event is quite special here, that requires special care, since we clean up + // caches on delete event. + handleEvent(ResourceAction.DELETED, resource, null, deletedFinalStateUnknown); + }); } @Override diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ResourceDeleteEvent.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ResourceDeleteEvent.java index ac21250051..6219207faf 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ResourceDeleteEvent.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ResourceDeleteEvent.java @@ -19,6 +19,7 @@ import io.fabric8.kubernetes.api.model.HasMetadata; import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; /** * Extends ResourceEvent for informer Delete events, it holds also information if the final state is diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ResourceEvent.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ResourceEvent.java index 395f3755fb..88f9bf8716 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ResourceEvent.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ResourceEvent.java @@ -21,6 +21,7 @@ import io.fabric8.kubernetes.api.model.HasMetadata; import io.javaoperatorsdk.operator.processing.event.Event; import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; public class ResourceEvent extends Event { diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterDetails.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterDetails.java new file mode 100644 index 0000000000..b747c69dff --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterDetails.java @@ -0,0 +1,72 @@ +/* + * 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.event.source.informer; + +import java.util.Optional; +import java.util.function.UnaryOperator; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; +import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEvent; + +class EventFilterDetails { + + private int activeUpdates = 0; + private ResourceEvent lastEvent; + private String lastOwnUpdatedResourceVersion; + + public void increaseActiveUpdates() { + activeUpdates = activeUpdates + 1; + } + + /** + * resourceVersion is needed for case when multiple parallel updates happening inside the + * controller to prevent race condition and send event from {@link + * ManagedInformerEventSource#eventFilteringUpdateAndCacheResource(HasMetadata, UnaryOperator)} + */ + public boolean decreaseActiveUpdates(String updatedResourceVersion) { + if (updatedResourceVersion != null + && (lastOwnUpdatedResourceVersion == null + || ReconcilerUtilsInternal.compareResourceVersions( + updatedResourceVersion, lastOwnUpdatedResourceVersion) + > 0)) { + lastOwnUpdatedResourceVersion = updatedResourceVersion; + } + + activeUpdates = activeUpdates - 1; + return activeUpdates == 0; + } + + public void setLastEvent(ResourceEvent event) { + lastEvent = event; + } + + public Optional getLatestEventAfterLastUpdateEvent() { + if (lastEvent != null + && (lastOwnUpdatedResourceVersion == null + || ReconcilerUtilsInternal.compareResourceVersions( + lastEvent.getResource().orElseThrow().getMetadata().getResourceVersion(), + lastOwnUpdatedResourceVersion) + > 0)) { + return Optional.of(lastEvent); + } + return Optional.empty(); + } + + public int getActiveUpdates() { + return activeUpdates; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ExtendedResourceEvent.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ExtendedResourceEvent.java new file mode 100644 index 0000000000..5d30d1b0e1 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ExtendedResourceEvent.java @@ -0,0 +1,72 @@ +/* + * 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.event.source.informer; + +import java.util.Objects; +import java.util.Optional; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; +import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEvent; + +/** Used only for resource event filtering. */ +public class ExtendedResourceEvent extends ResourceEvent { + + private final HasMetadata previousResource; + + public ExtendedResourceEvent( + ResourceAction action, + ResourceID resourceID, + HasMetadata latestResource, + HasMetadata previousResource) { + super(action, resourceID, latestResource); + this.previousResource = previousResource; + } + + public Optional getPreviousResource() { + return Optional.ofNullable(previousResource); + } + + @Override + public String toString() { + return "ExtendedResourceEvent{" + + getPreviousResource() + .map(r -> "previousResourceVersion=" + r.getMetadata().getResourceVersion()) + .orElse("") + + ", action=" + + getAction() + + getResource() + .map(r -> ", resourceVersion=" + r.getMetadata().getResourceVersion()) + .orElse("") + + ", relatedCustomResourceName=" + + getRelatedCustomResourceID().getName() + + '}'; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; + ExtendedResourceEvent that = (ExtendedResourceEvent) o; + return Objects.equals(previousResource, that.previousResource); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), previousResource); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java index ec11db25f4..fcec8ae68b 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java @@ -17,7 +17,6 @@ import java.util.Optional; import java.util.Set; -import java.util.UUID; import java.util.stream.Collectors; import org.slf4j.Logger; @@ -28,43 +27,21 @@ import io.fabric8.kubernetes.client.dsl.MixedOperation; import io.fabric8.kubernetes.client.informers.ResourceEventHandler; import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; -import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; import io.javaoperatorsdk.operator.processing.event.Event; import io.javaoperatorsdk.operator.processing.event.EventHandler; import io.javaoperatorsdk.operator.processing.event.ResourceID; import io.javaoperatorsdk.operator.processing.event.source.PrimaryToSecondaryMapper; +import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; +import io.javaoperatorsdk.operator.processing.event.source.informer.TemporaryResourceCache.EventHandling; + +import static io.javaoperatorsdk.operator.api.reconciler.Constants.DEFAULT_COMPARABLE_RESOURCE_VERSION; /** * Wraps informer(s) so they are connected to the eventing system of the framework. Note that since * this is built on top of Fabric8 client Informers, it also supports caching resources using - * caching from informer caches as well as additional caches described below. - * - *

InformerEventSource also supports two features to better handle events and caching of - * resources on top of Informers from the Fabric8 Kubernetes client. These two features are related - * to each other as follows: - * - *

    - *
  1. Ensuring the cache contains the fresh resource after an update. This is important for - * {@link io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource} and mainly - * for {@link - * io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource} so - * that {@link - * io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource#getSecondaryResource(HasMetadata, - * Context)} always returns the latest version of the resource after a reconciliation. To - * achieve this {@link #handleRecentResourceUpdate(ResourceID, HasMetadata, HasMetadata)} and - * {@link #handleRecentResourceCreate(ResourceID, HasMetadata)} need to be called explicitly - * after a resource is created or updated using the kubernetes client. These calls are done - * automatically by the KubernetesDependentResource implementation. In the background this - * will store the new resource in a temporary cache {@link TemporaryResourceCache} which does - * additional checks. After a new event is received the cached object is removed from this - * cache, since it is then usually already in the informer cache. - *
  2. Avoiding unneeded reconciliations after resources are created or updated. This filters out - * events that are the results of updates and creates made by the controller itself because we - * typically don't want the associated informer to trigger an event causing a useless - * reconciliation (as the change originates from the reconciler itself). For the details see - * {@link #canSkipEvent(HasMetadata, HasMetadata, ResourceID)} and related usage. - *
+ * caching from informer caches as well as filtering events which are result of the controller's + * update. * * @param resource type being watched * @param

type of the associated primary resource @@ -78,28 +55,24 @@ public class InformerEventSource // we need direct control for the indexer to propagate the just update resource also to the index private final PrimaryToSecondaryIndex primaryToSecondaryIndex; private final PrimaryToSecondaryMapper

primaryToSecondaryMapper; - private final String id = UUID.randomUUID().toString(); public InformerEventSource( InformerEventSourceConfiguration configuration, EventSourceContext

context) { this( configuration, configuration.getKubernetesClient().orElse(context.getClient()), - context - .getControllerConfiguration() - .getConfigurationService() - .parseResourceVersionsForEventFilteringAndCaching()); + configuration.comparableResourceVersion()); } InformerEventSource(InformerEventSourceConfiguration configuration, KubernetesClient client) { - this(configuration, client, false); + this(configuration, client, DEFAULT_COMPARABLE_RESOURCE_VERSION); } @SuppressWarnings({"unchecked", "rawtypes"}) private InformerEventSource( InformerEventSourceConfiguration configuration, KubernetesClient client, - boolean parseResourceVersions) { + boolean comparableResourceVersions) { super( configuration.name(), configuration @@ -107,7 +80,7 @@ private InformerEventSource( .map(gvk -> client.genericKubernetesResources(gvk.apiVersion(), gvk.getKind())) .orElseGet(() -> (MixedOperation) client.resources(configuration.getResourceClass())), configuration, - parseResourceVersions); + comparableResourceVersions); // If there is a primary to secondary mapper there is no need for primary to secondary index. primaryToSecondaryMapper = configuration.getPrimaryToSecondaryMapper(); if (useSecondaryToPrimaryIndex()) { @@ -127,49 +100,54 @@ private InformerEventSource( @Override public void onAdd(R newResource) { - if (log.isDebugEnabled()) { - log.debug( - "On add event received for resource id: {} type: {} version: {}", - ResourceID.fromResource(newResource), - resourceType().getSimpleName(), - newResource.getMetadata().getResourceVersion()); - } - primaryToSecondaryIndex.onAddOrUpdate(newResource); - onAddOrUpdate( - Operation.ADD, newResource, null, () -> InformerEventSource.super.onAdd(newResource)); + withMDC( + newResource, + ResourceAction.ADDED, + () -> { + if (log.isDebugEnabled()) { + log.debug("On add event received"); + } + onAddOrUpdate(ResourceAction.ADDED, newResource, null); + }); } @Override public void onUpdate(R oldObject, R newObject) { - if (log.isDebugEnabled()) { - log.debug( - "On update event received for resource id: {} type: {} version: {} old version: {} ", - ResourceID.fromResource(newObject), - resourceType().getSimpleName(), - newObject.getMetadata().getResourceVersion(), - oldObject.getMetadata().getResourceVersion()); - } - primaryToSecondaryIndex.onAddOrUpdate(newObject); - onAddOrUpdate( - Operation.UPDATE, + withMDC( newObject, - oldObject, - () -> InformerEventSource.super.onUpdate(oldObject, newObject)); + ResourceAction.UPDATED, + () -> { + if (log.isDebugEnabled()) { + log.debug( + "On update event received. Old version: {}", + oldObject.getMetadata().getResourceVersion()); + } + onAddOrUpdate(ResourceAction.UPDATED, newObject, oldObject); + }); } @Override - public void onDelete(R resource, boolean b) { - if (log.isDebugEnabled()) { - log.debug( - "On delete event received for resource id: {} type: {}", - ResourceID.fromResource(resource), - resourceType().getSimpleName()); - } - primaryToSecondaryIndex.onDelete(resource); - super.onDelete(resource, b); - if (acceptedByDeleteFilters(resource, b)) { - propagateEvent(resource); - } + public synchronized void onDelete(R resource, boolean deletedFinalStateUnknown) { + withMDC( + resource, + ResourceAction.DELETED, + () -> { + if (log.isDebugEnabled()) { + log.debug( + "On delete event received. deletedFinalStateUnknown: {}", deletedFinalStateUnknown); + } + primaryToSecondaryIndex.onDelete(resource); + temporaryResourceCache.onDeleteEvent(resource, deletedFinalStateUnknown); + if (acceptedByDeleteFilters(resource, deletedFinalStateUnknown)) { + propagateEvent(resource); + } + }); + } + + @Override + protected void handleEvent( + ResourceAction action, R resource, R oldResource, Boolean deletedFinalStateUnknown) { + propagateEvent(resource); } @Override @@ -180,66 +158,23 @@ public synchronized void start() { manager().list().forEach(primaryToSecondaryIndex::onAddOrUpdate); } - private synchronized void onAddOrUpdate( - Operation operation, R newObject, R oldObject, Runnable superOnOp) { + private synchronized void onAddOrUpdate(ResourceAction action, R newObject, R oldObject) { + primaryToSecondaryIndex.onAddOrUpdate(newObject); var resourceID = ResourceID.fromResource(newObject); - if (canSkipEvent(newObject, oldObject, resourceID)) { + var eventHandling = temporaryResourceCache.onAddOrUpdateEvent(action, newObject, oldObject); + + if (eventHandling != EventHandling.NEW) { + log.debug( + "{} event propagation", eventHandling == EventHandling.DEFER ? "Deferring" : "Skipping"); + } else if (eventAcceptedByFilter(action, newObject, oldObject)) { log.debug( - "Skipping event propagation for {}, since was a result of a reconcile action. Resource" - + " ID: {}", - operation, - ResourceID.fromResource(newObject)); - superOnOp.run(); + "Propagating event for {}, resource with same version not result of a reconciliation.", + action); + propagateEvent(newObject); } else { - superOnOp.run(); - if (eventAcceptedByFilter(operation, newObject, oldObject)) { - log.debug( - "Propagating event for {}, resource with same version not result of a reconciliation." - + " Resource ID: {}", - operation, - resourceID); - propagateEvent(newObject); - } else { - log.debug("Event filtered out for operation: {}, resourceID: {}", operation, resourceID); - } - } - } - - private boolean canSkipEvent(R newObject, R oldObject, ResourceID resourceID) { - var res = temporaryResourceCache.getResourceFromCache(resourceID); - if (res.isEmpty()) { - return isEventKnownFromAnnotation(newObject, oldObject); + log.debug("Event filtered out for operation: {}, resourceID: {}", action, resourceID); } - boolean resVersionsEqual = - newObject - .getMetadata() - .getResourceVersion() - .equals(res.get().getMetadata().getResourceVersion()); - log.debug( - "Resource found in temporal cache for id: {} resource versions equal: {}", - resourceID, - resVersionsEqual); - return resVersionsEqual - || temporaryResourceCache.isLaterResourceVersion(resourceID, res.get(), newObject); - } - - private boolean isEventKnownFromAnnotation(R newObject, R oldObject) { - String previous = newObject.getMetadata().getAnnotations().get(PREVIOUS_ANNOTATION_KEY); - boolean known = false; - if (previous != null) { - String[] parts = previous.split(","); - if (id.equals(parts[0])) { - if (oldObject == null && parts.length == 1) { - known = true; - } else if (oldObject != null - && parts.length == 2 - && oldObject.getMetadata().getResourceVersion().equals(parts[1])) { - known = true; - } - } - } - return known; } private void propagateEvent(R object) { @@ -277,35 +212,31 @@ public Set getSecondaryResources(P primary) { } else { secondaryIDs = primaryToSecondaryMapper.toSecondaryResourceIDs(primary); log.debug( - "Using PrimaryToSecondaryMapper to find secondary resources for primary: {}. Found" + "Using PrimaryToSecondaryMapper to find secondary resources for primary. Found" + " secondary ids: {} ", - primary, secondaryIDs); } return secondaryIDs.stream() .map(this::get) - .flatMap(Optional::stream) + .filter(Optional::isPresent) + .map(Optional::get) .collect(Collectors.toSet()); } @Override - public synchronized void handleRecentResourceUpdate( + public void handleRecentResourceUpdate( ResourceID resourceID, R resource, R previousVersionOfResource) { - handleRecentCreateOrUpdate(Operation.UPDATE, resource, previousVersionOfResource); + handleRecentCreateOrUpdate(resource); } @Override - public synchronized void handleRecentResourceCreate(ResourceID resourceID, R resource) { - handleRecentCreateOrUpdate(Operation.ADD, resource, null); + public void handleRecentResourceCreate(ResourceID resourceID, R resource) { + handleRecentCreateOrUpdate(resource); } - private void handleRecentCreateOrUpdate(Operation operation, R newResource, R oldResource) { + private void handleRecentCreateOrUpdate(R newResource) { primaryToSecondaryIndex.onAddOrUpdate(newResource); - temporaryResourceCache.putResource( - newResource, - Optional.ofNullable(oldResource) - .map(r -> r.getMetadata().getResourceVersion()) - .orElse(null)); + temporaryResourceCache.putResource(newResource); } private boolean useSecondaryToPrimaryIndex() { @@ -317,11 +248,11 @@ public boolean allowsNamespaceChanges() { return configuration().followControllerNamespaceChanges(); } - private boolean eventAcceptedByFilter(Operation operation, R newObject, R oldObject) { + private boolean eventAcceptedByFilter(ResourceAction action, R newObject, R oldObject) { if (genericFilter != null && !genericFilter.accept(newObject)) { return false; } - if (operation == Operation.ADD) { + if (action == ResourceAction.ADDED) { return onAddFilter == null || onAddFilter.accept(newObject); } else { return onUpdateFilter == null || onUpdateFilter.accept(newObject, oldObject); @@ -332,25 +263,4 @@ private boolean acceptedByDeleteFilters(R resource, boolean b) { return (onDeleteFilter == null || onDeleteFilter.accept(resource, b)) && (genericFilter == null || genericFilter.accept(resource)); } - - /** - * Add an annotation to the resource so that the subsequent will be omitted - * - * @param resourceVersion null if there is no prior version - * @param target mutable resource that will be returned - */ - public R addPreviousAnnotation(String resourceVersion, R target) { - target - .getMetadata() - .getAnnotations() - .put( - PREVIOUS_ANNOTATION_KEY, - id + Optional.ofNullable(resourceVersion).map(rv -> "," + rv).orElse("")); - return target; - } - - private enum Operation { - ADD, - UPDATE - } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerManager.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerManager.java index abd2b6a752..42e06c9d9a 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerManager.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerManager.java @@ -32,7 +32,7 @@ import io.fabric8.kubernetes.client.dsl.Resource; import io.fabric8.kubernetes.client.informers.ResourceEventHandler; import io.javaoperatorsdk.operator.OperatorException; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; import io.javaoperatorsdk.operator.api.config.Informable; import io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration; @@ -253,7 +253,7 @@ public String toString() { final var informerConfig = configuration.getInformerConfig(); final var selector = informerConfig.getLabelSelector(); return "InformerManager [" - + ReconcilerUtils.getResourceTypeNameWithVersion(configuration.getResourceClass()) + + ReconcilerUtilsInternal.getResourceTypeNameWithVersion(configuration.getResourceClass()) + "] watching: " + informerConfig.getEffectiveNamespaces(controllerConfiguration) + (selector != null ? " selector: " + selector : ""); diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerWrapper.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerWrapper.java index 2a6c7ef206..60497bc0c9 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerWrapper.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerWrapper.java @@ -35,7 +35,7 @@ import io.fabric8.kubernetes.client.informers.SharedIndexInformer; import io.fabric8.kubernetes.client.informers.cache.Cache; import io.javaoperatorsdk.operator.OperatorException; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.config.ConfigurationService; import io.javaoperatorsdk.operator.health.InformerHealthIndicator; import io.javaoperatorsdk.operator.health.Status; @@ -131,7 +131,7 @@ public void start() throws OperatorException { } } catch (Exception e) { - ReconcilerUtils.handleKubernetesClientException( + ReconcilerUtilsInternal.handleKubernetesClientException( e, HasMetadata.getFullResourceName(informer.getApiTypeClass())); throw new OperatorException( "Couldn't start informer for " + versionedFullResourceName() + " resources", e); @@ -143,7 +143,7 @@ private String versionedFullResourceName() { if (apiTypeClass.isAssignableFrom(GenericKubernetesResource.class)) { return GenericKubernetesResource.class.getSimpleName(); } - return ReconcilerUtils.getResourceTypeNameWithVersion(apiTypeClass); + return ReconcilerUtilsInternal.getResourceTypeNameWithVersion(apiTypeClass); } @Override @@ -156,6 +156,10 @@ public Optional get(ResourceID resourceID) { return Optional.ofNullable(cache.getByKey(getKey(resourceID))); } + public String getLastSyncResourceVersion() { + return this.informer.lastSyncResourceVersion(); + } + private String getKey(ResourceID resourceID) { return Cache.namespaceKeyFunc(resourceID.getNamespace().orElse(null), resourceID.getName()); } 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 2679918b60..2fc67c4892 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 @@ -22,6 +22,7 @@ import java.util.Set; import java.util.function.Function; import java.util.function.Predicate; +import java.util.function.UnaryOperator; import java.util.stream.Stream; import org.slf4j.Logger; @@ -31,6 +32,7 @@ import io.fabric8.kubernetes.client.dsl.MixedOperation; import io.fabric8.kubernetes.client.informers.ResourceEventHandler; import io.javaoperatorsdk.operator.OperatorException; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; import io.javaoperatorsdk.operator.api.config.Informable; import io.javaoperatorsdk.operator.api.config.NamespaceChangeable; @@ -38,8 +40,11 @@ import io.javaoperatorsdk.operator.health.InformerHealthIndicator; import io.javaoperatorsdk.operator.health.InformerWrappingEventSourceHealthIndicator; import io.javaoperatorsdk.operator.health.Status; +import io.javaoperatorsdk.operator.processing.MDCUtils; import io.javaoperatorsdk.operator.processing.event.ResourceID; import io.javaoperatorsdk.operator.processing.event.source.*; +import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; +import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceDeleteEvent; @SuppressWarnings("rawtypes") public abstract class ManagedInformerEventSource< @@ -55,7 +60,7 @@ public abstract class ManagedInformerEventSource< private static final Logger log = LoggerFactory.getLogger(ManagedInformerEventSource.class); private InformerManager cache; - private final boolean parseResourceVersions; + private final boolean comparableResourceVersions; private ControllerConfiguration controllerConfiguration; private final C configuration; private final Map>> indexers = new HashMap<>(); @@ -63,28 +68,13 @@ public abstract class ManagedInformerEventSource< protected MixedOperation client; protected ManagedInformerEventSource( - String name, MixedOperation client, C configuration, boolean parseResourceVersions) { + String name, MixedOperation client, C configuration, boolean comparableResourceVersions) { super(configuration.getResourceClass(), name); - this.parseResourceVersions = parseResourceVersions; + this.comparableResourceVersions = comparableResourceVersions; this.client = client; this.configuration = configuration; } - @Override - public void onAdd(R resource) { - temporaryResourceCache.onAddOrUpdateEvent(resource); - } - - @Override - public void onUpdate(R oldObj, R newObj) { - temporaryResourceCache.onAddOrUpdateEvent(newObj); - } - - @Override - public void onDelete(R obj, boolean deletedFinalStateUnknown) { - temporaryResourceCache.onDeleteEvent(obj, deletedFinalStateUnknown); - } - protected InformerManager manager() { return cache; } @@ -96,13 +86,74 @@ public void changeNamespaces(Set namespaces) { } } + /** + * Updates the resource and makes sure that the response is available for the next reconciliation. + * Also makes sure that the even produced by this update is filtered, thus does not trigger the + * reconciliation. + */ + @SuppressWarnings("unchecked") + public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator updateMethod) { + ResourceID id = ResourceID.fromResource(resourceToUpdate); + log.debug("Starting event filtering and caching update"); + R updatedResource = null; + try { + temporaryResourceCache.startEventFilteringModify(id); + updatedResource = updateMethod.apply(resourceToUpdate); + log.debug("Resource update successful"); + handleRecentResourceUpdate(id, updatedResource, resourceToUpdate); + return updatedResource; + } finally { + var res = + temporaryResourceCache.doneEventFilterModify( + id, + updatedResource == null ? null : updatedResource.getMetadata().getResourceVersion()); + var updatedForLambda = updatedResource; + res.ifPresentOrElse( + r -> { + R latestResource = (R) r.getResource().orElseThrow(); + + // as previous resource version we use the one from successful update, since + // we process new event here only if that is more recent then the event from our update. + // Note that this is equivalent with the scenario when an informer watch connection + // would reconnect and loose some events in between. + // If that update was not successful we still record the previous version from the + // actual event in the ExtendedResourceEvent. + R extendedResourcePrevVersion = + (r instanceof ExtendedResourceEvent) + ? (R) ((ExtendedResourceEvent) r).getPreviousResource().orElse(null) + : null; + R prevVersionOfResource = + updatedForLambda != null ? updatedForLambda : extendedResourcePrevVersion; + if (log.isDebugEnabled()) { + log.debug( + "Previous resource version: {} resource from update present: {}" + + " extendedPrevResource present: {}", + prevVersionOfResource.getMetadata().getResourceVersion(), + updatedForLambda != null, + extendedResourcePrevVersion != null); + } + handleEvent( + r.getAction(), + latestResource, + prevVersionOfResource, + (r instanceof ResourceDeleteEvent) + ? ((ResourceDeleteEvent) r).isDeletedFinalStateUnknown() + : null); + }, + () -> log.debug("No new event present after the filtering update")); + } + } + + protected abstract void handleEvent( + ResourceAction action, R resource, R oldResource, Boolean deletedFinalStateUnknown); + @SuppressWarnings("unchecked") @Override public synchronized void start() { if (isRunning()) { return; } - temporaryResourceCache = new TemporaryResourceCache<>(this, parseResourceVersions); + temporaryResourceCache = new TemporaryResourceCache<>(comparableResourceVersions); this.cache = new InformerManager<>(client, configuration, this); cache.setControllerConfiguration(controllerConfiguration); cache.addIndexers(indexers); @@ -122,32 +173,43 @@ public synchronized void stop() { @Override public void handleRecentResourceUpdate( ResourceID resourceID, R resource, R previousVersionOfResource) { - temporaryResourceCache.putResource( - resource, previousVersionOfResource.getMetadata().getResourceVersion()); + temporaryResourceCache.putResource(resource); } @Override public void handleRecentResourceCreate(ResourceID resourceID, R resource) { - temporaryResourceCache.putAddedResource(resource); + temporaryResourceCache.putResource(resource); } @Override public Optional get(ResourceID resourceID) { + // The order of these two lookups matters. If we queried the informer cache first, + // a race condition could occur: we might not find the resource there yet, then + // process an informer event that evicts the temporary resource cache entry. At that + // point the resource would already be present in the informer cache, but we would + // have missed it in both caches during this call. Optional resource = temporaryResourceCache.getResourceFromCache(resourceID); - if (resource.isPresent()) { - log.debug("Resource found in temporary cache for Resource ID: {}", resourceID); + var res = cache.get(resourceID); + if (comparableResourceVersions + && resource.isPresent() + && res.filter( + r -> ReconcilerUtilsInternal.compareResourceVersions(r, resource.orElseThrow()) > 0) + .isEmpty()) { + log.debug("Latest resource found in temporary cache for Resource ID: {}", resourceID); return resource; - } else { - log.debug( - "Resource not found in temporary cache reading it from informer cache," - + " for Resource ID: {}", - resourceID); - var res = cache.get(resourceID); - log.debug("Resource found in cache: {} for id: {}", res.isPresent(), resourceID); - return res; } + log.debug( + "Resource not found, or older, in temporary cache. Found in informer cache {}, for" + + " Resource ID: {}", + res.isPresent(), + resourceID); + return res; } + /** + * @deprecated Use {@link #get(ResourceID)} instead. + */ + @Deprecated(forRemoval = true) @SuppressWarnings("unused") public Optional getCachedValue(ResourceID resourceID) { return get(resourceID); @@ -212,4 +274,8 @@ public String toString() { public void setControllerConfiguration(ControllerConfiguration controllerConfiguration) { this.controllerConfiguration = controllerConfiguration; } + + protected void withMDC(R resource, ResourceAction action, Runnable runnable) { + MDCUtils.withMDCForEvent(resource, action, runnable, name()); + } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java index 06226ae4ba..43d9dc1fab 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java @@ -15,7 +15,7 @@ */ package io.javaoperatorsdk.operator.processing.event.source.informer; -import java.util.LinkedHashMap; +import java.util.HashMap; import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; @@ -24,166 +24,188 @@ import org.slf4j.LoggerFactory; import io.fabric8.kubernetes.api.model.HasMetadata; -import io.javaoperatorsdk.operator.api.config.ConfigurationService; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource; import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; +import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceDeleteEvent; +import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEvent; /** * Temporal cache is used to solve the problem for {@link KubernetesDependentResource} that is, when * a create or update is executed the subsequent getResource operation might not return the * up-to-date resource from informer cache, since it is not received yet. * - *

The idea of the solution is, that since an update (for create is simpler) was done - * successfully, and optimistic locking is in place, there were no other operations between reading - * the resource from the cache and the actual update. So when the new resource is stored in the - * temporal cache only if the informer still has the previous resource version, from before the - * update. If not, that means there were already updates on the cache (either by the actual update - * from DependentResource or other) so the resource does not needs to be cached. Subsequently if - * event received from the informer, it means that the cache of the informer was updated, so it - * already contains a more fresh version of the resource. + *

Since an update (for create is simpler) was done successfully we can temporarily track that + * resource if its version is later than the events we've processed. We then know that we can skip + * all events that have the same resource version or earlier than the tracked resource. Once we + * process an event that has the same resource version or later, then we know the tracked resource + * can be removed. + * + *

In some cases it is possible for the informer to deliver events prior to the attempt to put + * the resource in the temporal cache. The startModifying/doneModifying methods are used to pause + * event delivery to ensure that temporal cache recognizes the put entry as an event that can be + * skipped. + * + *

If comparable resource versions are disabled, then this cache is effectively disabled. * * @param resource to cache. */ public class TemporaryResourceCache { - static class ExpirationCache { - private final LinkedHashMap cache; - private final int ttlMs; - - public ExpirationCache(int maxEntries, int ttlMs) { - this.ttlMs = ttlMs; - this.cache = - new LinkedHashMap<>() { - @Override - protected boolean removeEldestEntry(Map.Entry eldest) { - return size() > maxEntries; - } - }; - } - - public void add(K key) { - clean(); - cache.putIfAbsent(key, System.currentTimeMillis()); - } - - public boolean contains(K key) { - clean(); - return cache.get(key) != null; - } - - void clean() { - if (!cache.isEmpty()) { - long currentTimeMillis = System.currentTimeMillis(); - var iter = cache.entrySet().iterator(); - // the order will already be from oldest to newest, clean a fixed number of entries to - // amortize the cost amongst multiple calls - for (int i = 0; i < 10 && iter.hasNext(); i++) { - var entry = iter.next(); - if (currentTimeMillis - entry.getValue() > ttlMs) { - iter.remove(); - } - } - } - } - } - private static final Logger log = LoggerFactory.getLogger(TemporaryResourceCache.class); private final Map cache = new ConcurrentHashMap<>(); + private final boolean comparableResourceVersions; + private String latestResourceVersion; - // keep up to the last million deletions for up to 10 minutes - private final ExpirationCache tombstones = new ExpirationCache<>(1000000, 1200000); - private final ManagedInformerEventSource managedInformerEventSource; - private final boolean parseResourceVersions; + private final Map activeUpdates = new HashMap<>(); - public TemporaryResourceCache( - ManagedInformerEventSource managedInformerEventSource, - boolean parseResourceVersions) { - this.managedInformerEventSource = managedInformerEventSource; - this.parseResourceVersions = parseResourceVersions; + public enum EventHandling { + DEFER, + OBSOLETE, + NEW } - public synchronized void onDeleteEvent(T resource, boolean unknownState) { - tombstones.add(resource.getMetadata().getUid()); - onEvent(resource, unknownState); + public TemporaryResourceCache(boolean comparableResourceVersions) { + this.comparableResourceVersions = comparableResourceVersions; + } + + public synchronized void startEventFilteringModify(ResourceID resourceID) { + if (!comparableResourceVersions) { + return; + } + var ed = activeUpdates.computeIfAbsent(resourceID, id -> new EventFilterDetails()); + ed.increaseActiveUpdates(); } - public synchronized void onAddOrUpdateEvent(T resource) { - onEvent(resource, false); + public synchronized Optional doneEventFilterModify( + ResourceID resourceID, String updatedResourceVersion) { + if (!comparableResourceVersions) { + return Optional.empty(); + } + var ed = activeUpdates.get(resourceID); + if (ed == null || !ed.decreaseActiveUpdates(updatedResourceVersion)) { + log.debug( + "Active updates {} for resource id: {}", + ed != null ? ed.getActiveUpdates() : 0, + resourceID); + return Optional.empty(); + } + activeUpdates.remove(resourceID); + var res = ed.getLatestEventAfterLastUpdateEvent(); + log.debug( + "Zero active updates for resource id: {}; event after update event: {}; updated resource" + + " version: {}", + resourceID, + res.isPresent(), + updatedResourceVersion); + return res; } - synchronized void onEvent(T resource, boolean unknownState) { - cache.computeIfPresent( - ResourceID.fromResource(resource), - (id, cached) -> - (unknownState || !isLaterResourceVersion(id, cached, resource)) ? null : cached); + public void onDeleteEvent(T resource, boolean unknownState) { + onEvent(ResourceAction.DELETED, resource, null, unknownState, true); } - public synchronized void putAddedResource(T newResource) { - putResource(newResource, null); + public EventHandling onAddOrUpdateEvent( + ResourceAction action, T resource, T prevResourceVersion) { + return onEvent(action, resource, prevResourceVersion, false, false); } - /** - * put the item into the cache if the previousResourceVersion matches the current state. If not - * the currently cached item is removed. - * - * @param previousResourceVersion null indicates an add - */ - public synchronized void putResource(T newResource, String previousResourceVersion) { - var resourceId = ResourceID.fromResource(newResource); - var cachedResource = managedInformerEventSource.get(resourceId).orElse(null); + private synchronized EventHandling onEvent( + ResourceAction action, + T resource, + T prevResourceVersion, + boolean unknownState, + boolean delete) { + if (!comparableResourceVersions) { + return EventHandling.NEW; + } - boolean moveAhead = false; - if (previousResourceVersion == null && cachedResource == null) { - if (tombstones.contains(newResource.getMetadata().getUid())) { + var resourceId = ResourceID.fromResource(resource); + if (log.isDebugEnabled()) { + log.debug("Processing event"); + } + if (!unknownState) { + latestResourceVersion = resource.getMetadata().getResourceVersion(); + log.debug("Setting latest resource version to: {}", latestResourceVersion); + } + var cached = cache.get(resourceId); + EventHandling result = EventHandling.NEW; + if (cached != null) { + int comp = ReconcilerUtilsInternal.compareResourceVersions(resource, cached); + if (comp >= 0 || unknownState) { log.debug( - "Won't resurrect uid {} for resource id: {}", - newResource.getMetadata().getUid(), - resourceId); - return; + "Removing resource from temp cache. comparison: {} unknown state: {}", + comp, + unknownState); + cache.remove(resourceId); + // we propagate event only for our update or newer other can be discarded since we know we + // will receive + // additional event + result = comp == 0 ? EventHandling.OBSOLETE : EventHandling.NEW; + } else { + result = EventHandling.OBSOLETE; } - // we can skip further checks as this is a simple add and there's no previous entry to - // consider - moveAhead = true; } + var ed = activeUpdates.get(resourceId); + if (ed != null && result != EventHandling.OBSOLETE) { + log.debug("Setting last event for id: {} delete: {}", resourceId, delete); + ed.setLastEvent( + delete + ? new ResourceDeleteEvent(ResourceAction.DELETED, resourceId, resource, unknownState) + : new ExtendedResourceEvent(action, resourceId, resource, prevResourceVersion)); + return EventHandling.DEFER; + } else { + return result; + } + } + + /** put the item into the cache if it's for a later state than what has already been observed. */ + public synchronized void putResource(T newResource) { + if (!comparableResourceVersions) { + return; + } + + var resourceId = ResourceID.fromResource(newResource); - if (moveAhead - || (cachedResource != null - && (cachedResource - .getMetadata() - .getResourceVersion() - .equals(previousResourceVersion)) - || isLaterResourceVersion(resourceId, newResource, cachedResource))) { + if (newResource.getMetadata().getResourceVersion() == null) { + log.warn( + "Resource {}: with no resourceVersion put in temporary cache. This is not the expected" + + " usage pattern, only resources returned from the api server should be put in the" + + " cache.", + resourceId); + return; + } + + // check against the latestResourceVersion processed by the TemporaryResourceCache + // If the resource is older, then we can safely ignore. + // + // this also prevents resurrecting recently deleted entities for which the delete event + // has already been processed + if (latestResourceVersion != null + && ReconcilerUtilsInternal.compareResourceVersions( + latestResourceVersion, newResource.getMetadata().getResourceVersion()) + > 0) { log.debug( - "Temporarily moving ahead to target version {} for resource id: {}", + "Resource {}: resourceVersion {} is not later than latest {}", + resourceId, newResource.getMetadata().getResourceVersion(), - resourceId); - cache.put(resourceId, newResource); - } else if (cache.remove(resourceId) != null) { - log.debug("Removed an obsolete resource from cache for id: {}", resourceId); + latestResourceVersion); + return; } - } - /** - * @return true if {@link ConfigurationService#parseResourceVersionsForEventFilteringAndCaching()} - * is enabled and the resourceVersion of newResource is numerically greater than - * cachedResource, otherwise false - */ - public boolean isLaterResourceVersion(ResourceID resourceId, T newResource, T cachedResource) { - try { - if (parseResourceVersions - && Long.parseLong(newResource.getMetadata().getResourceVersion()) - > Long.parseLong(cachedResource.getMetadata().getResourceVersion())) { - return true; - } - } catch (NumberFormatException e) { + // also make sure that we're later than the existing temporary entry + var cachedResource = getResourceFromCache(resourceId).orElse(null); + + if (cachedResource == null + || ReconcilerUtilsInternal.compareResourceVersions(newResource, cachedResource) > 0) { log.debug( - "Could not compare resourceVersions {} and {} for {}", + "Temporarily moving ahead to target version {} for resource id: {}", newResource.getMetadata().getResourceVersion(), - cachedResource.getMetadata().getResourceVersion(), resourceId); + cache.put(resourceId, newResource); } - return false; } public synchronized Optional getResourceFromCache(ResourceID resourceID) { diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/timer/TimerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/timer/TimerEventSource.java index 2530c661ab..eae9663fe6 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/timer/TimerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/timer/TimerEventSource.java @@ -25,6 +25,7 @@ import org.slf4j.LoggerFactory; import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.BaseControl; import io.javaoperatorsdk.operator.health.Status; import io.javaoperatorsdk.operator.processing.event.Event; import io.javaoperatorsdk.operator.processing.event.ResourceID; @@ -62,8 +63,12 @@ public void scheduleOnce(ResourceID resourceID, long delay) { cancelOnceSchedule(resourceID); } EventProducerTimeTask task = new EventProducerTimeTask(resourceID); - onceTasks.put(resourceID, task); - timer.schedule(task, delay); + if (delay == BaseControl.INSTANT_RESCHEDULE) { + task.run(); + } else { + onceTasks.put(resourceID, task); + timer.schedule(task, delay); + } } @Override diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/OperatorIT.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/OperatorIT.java index c87c986f99..e5dae6ca80 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/OperatorIT.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/OperatorIT.java @@ -45,7 +45,7 @@ void shouldBePossibleToRetrieveNumberOfRegisteredControllers() { void shouldBePossibleToRetrieveRegisteredControllerByName() { final var operator = new Operator(); final var reconciler = new FooReconciler(); - final var name = ReconcilerUtils.getNameFor(reconciler); + final var name = ReconcilerUtilsInternal.getNameFor(reconciler); var registeredControllers = operator.getRegisteredControllers(); assertTrue(operator.getRegisteredController(name).isEmpty()); diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ReconcilerUtilsInternalTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ReconcilerUtilsInternalTest.java new file mode 100644 index 0000000000..129351e8af --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ReconcilerUtilsInternalTest.java @@ -0,0 +1,321 @@ +/* + * 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; + +import java.net.URI; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.*; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.fabric8.kubernetes.api.model.apps.DeploymentBuilder; +import io.fabric8.kubernetes.api.model.apps.DeploymentSpec; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.client.KubernetesClientException; +import io.fabric8.kubernetes.client.http.HttpRequest; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.ShortNames; +import io.fabric8.kubernetes.model.annotation.Version; +import io.javaoperatorsdk.operator.api.reconciler.NonComparableResourceVersionException; +import io.javaoperatorsdk.operator.sample.simple.TestCustomReconciler; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +import static io.javaoperatorsdk.operator.ReconcilerUtilsInternal.getDefaultFinalizerName; +import static io.javaoperatorsdk.operator.ReconcilerUtilsInternal.getDefaultNameFor; +import static io.javaoperatorsdk.operator.ReconcilerUtilsInternal.getDefaultReconcilerName; +import static io.javaoperatorsdk.operator.ReconcilerUtilsInternal.handleKubernetesClientException; +import static io.javaoperatorsdk.operator.ReconcilerUtilsInternal.isFinalizerValid; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ReconcilerUtilsInternalTest { + private static final Logger log = LoggerFactory.getLogger(ReconcilerUtilsInternalTest.class); + public static final String RESOURCE_URI = + "https://kubernetes.docker.internal:6443/apis/tomcatoperator.io/v1/tomcats"; + + @Test + void defaultReconcilerNameShouldWork() { + assertEquals( + "testcustomreconciler", + getDefaultReconcilerName(TestCustomReconciler.class.getCanonicalName())); + assertEquals( + getDefaultNameFor(TestCustomReconciler.class), + getDefaultReconcilerName(TestCustomReconciler.class.getCanonicalName())); + assertEquals( + getDefaultNameFor(TestCustomReconciler.class), + getDefaultReconcilerName(TestCustomReconciler.class.getSimpleName())); + } + + @Test + void defaultFinalizerShouldWork() { + assertTrue(isFinalizerValid(getDefaultFinalizerName(Pod.class))); + assertTrue(isFinalizerValid(getDefaultFinalizerName(TestCustomResource.class))); + } + + @Test + void equalsSpecObject() { + var d1 = createTestDeployment(); + var d2 = createTestDeployment(); + + assertThat(ReconcilerUtilsInternal.specsEqual(d1, d2)).isTrue(); + } + + @Test + void equalArbitraryDifferentSpecsOfObjects() { + var d1 = createTestDeployment(); + var d2 = createTestDeployment(); + d2.getSpec().getTemplate().getSpec().setHostname("otherhost"); + + assertThat(ReconcilerUtilsInternal.specsEqual(d1, d2)).isFalse(); + } + + @Test + void getsSpecWithReflection() { + Deployment deployment = new Deployment(); + deployment.setSpec(new DeploymentSpec()); + deployment.getSpec().setReplicas(5); + + DeploymentSpec spec = (DeploymentSpec) ReconcilerUtilsInternal.getSpec(deployment); + assertThat(spec.getReplicas()).isEqualTo(5); + } + + @Test + void properlyHandlesNullSpec() { + Namespace ns = new Namespace(); + + final var spec = ReconcilerUtilsInternal.getSpec(ns); + assertThat(spec).isNull(); + + ReconcilerUtilsInternal.setSpec(ns, null); + } + + @Test + void setsSpecWithReflection() { + Deployment deployment = new Deployment(); + deployment.setSpec(new DeploymentSpec()); + deployment.getSpec().setReplicas(5); + DeploymentSpec newSpec = new DeploymentSpec(); + newSpec.setReplicas(1); + + ReconcilerUtilsInternal.setSpec(deployment, newSpec); + + assertThat(deployment.getSpec().getReplicas()).isEqualTo(1); + } + + @Test + void setsSpecCustomResourceWithReflection() { + Tomcat tomcat = new Tomcat(); + tomcat.setSpec(new TomcatSpec()); + tomcat.getSpec().setReplicas(5); + TomcatSpec newSpec = new TomcatSpec(); + newSpec.setReplicas(1); + + ReconcilerUtilsInternal.setSpec(tomcat, newSpec); + + assertThat(tomcat.getSpec().getReplicas()).isEqualTo(1); + } + + @Test + void loadYamlAsBuilder() { + DeploymentBuilder builder = + ReconcilerUtilsInternal.loadYaml(DeploymentBuilder.class, getClass(), "deployment.yaml"); + builder.accept(ContainerBuilder.class, c -> c.withImage("my-image")); + + Deployment deployment = builder.editMetadata().withName("my-deployment").and().build(); + assertThat(deployment.getMetadata().getName()).isEqualTo("my-deployment"); + } + + private Deployment createTestDeployment() { + Deployment deployment = new Deployment(); + deployment.setSpec(new DeploymentSpec()); + deployment.getSpec().setReplicas(5); + PodTemplateSpec podTemplateSpec = new PodTemplateSpec(); + deployment.getSpec().setTemplate(podTemplateSpec); + podTemplateSpec.setSpec(new PodSpec()); + podTemplateSpec.getSpec().setHostname("localhost"); + return deployment; + } + + @Test + void handleKubernetesExceptionShouldThrowMissingCRDExceptionWhenAppropriate() { + var request = mock(HttpRequest.class); + when(request.uri()).thenReturn(URI.create(RESOURCE_URI)); + assertThrows( + MissingCRDException.class, + () -> + handleKubernetesClientException( + new KubernetesClientException( + "Failure executing: GET at: " + RESOURCE_URI + ". Message: Not Found.", + null, + 404, + null, + request), + HasMetadata.getFullResourceName(Tomcat.class))); + } + + @Group("tomcatoperator.io") + @Version("v1") + @ShortNames("tc") + private static class Tomcat extends CustomResource implements Namespaced {} + + private static class TomcatSpec { + private Integer replicas; + + public Integer getReplicas() { + return replicas; + } + + public void setReplicas(Integer replicas) { + this.replicas = replicas; + } + } + + // naive performance test that compares the work case scenario for the parsing and non-parsing + // variants + @Test + @Disabled + public void compareResourcePerformanceTest() { + var execNum = 30000000; + var startTime = System.currentTimeMillis(); + for (int i = 0; i < execNum; i++) { + var res = ReconcilerUtilsInternal.compareResourceVersions("123456788" + i, "123456789" + i); + } + var dur1 = System.currentTimeMillis() - startTime; + log.info("Duration without parsing: {}", dur1); + startTime = System.currentTimeMillis(); + for (int i = 0; i < execNum; i++) { + var res = Long.parseLong("123456788" + i) > Long.parseLong("123456789" + i); + } + var dur2 = System.currentTimeMillis() - startTime; + log.info("Duration with parsing: {}", dur2); + + assertThat(dur1).isLessThan(dur2); + } + + @Test + void validateAndCompareResourceVersionsTest() { + assertThat(ReconcilerUtilsInternal.validateAndCompareResourceVersions("11", "22")).isNegative(); + assertThat(ReconcilerUtilsInternal.validateAndCompareResourceVersions("22", "11")).isPositive(); + assertThat(ReconcilerUtilsInternal.validateAndCompareResourceVersions("1", "1")).isZero(); + assertThat(ReconcilerUtilsInternal.validateAndCompareResourceVersions("11", "11")).isZero(); + assertThat(ReconcilerUtilsInternal.validateAndCompareResourceVersions("123", "2")).isPositive(); + assertThat(ReconcilerUtilsInternal.validateAndCompareResourceVersions("3", "211")).isNegative(); + + assertThrows( + NonComparableResourceVersionException.class, + () -> ReconcilerUtilsInternal.validateAndCompareResourceVersions("aa", "22")); + assertThrows( + NonComparableResourceVersionException.class, + () -> ReconcilerUtilsInternal.validateAndCompareResourceVersions("11", "ba")); + assertThrows( + NonComparableResourceVersionException.class, + () -> ReconcilerUtilsInternal.validateAndCompareResourceVersions("", "22")); + assertThrows( + NonComparableResourceVersionException.class, + () -> ReconcilerUtilsInternal.validateAndCompareResourceVersions("11", "")); + assertThrows( + NonComparableResourceVersionException.class, + () -> ReconcilerUtilsInternal.validateAndCompareResourceVersions("01", "123")); + assertThrows( + NonComparableResourceVersionException.class, + () -> ReconcilerUtilsInternal.validateAndCompareResourceVersions("123", "01")); + assertThrows( + NonComparableResourceVersionException.class, + () -> ReconcilerUtilsInternal.validateAndCompareResourceVersions("3213", "123a")); + assertThrows( + NonComparableResourceVersionException.class, + () -> ReconcilerUtilsInternal.validateAndCompareResourceVersions("321", "123a")); + } + + @Test + void compareResourceVersionsWithStrings() { + // Test equal versions + assertThat(ReconcilerUtilsInternal.compareResourceVersions("1", "1")).isZero(); + assertThat(ReconcilerUtilsInternal.compareResourceVersions("123", "123")).isZero(); + + // Test different lengths - shorter version is less than longer version + assertThat(ReconcilerUtilsInternal.compareResourceVersions("1", "12")).isNegative(); + assertThat(ReconcilerUtilsInternal.compareResourceVersions("12", "1")).isPositive(); + assertThat(ReconcilerUtilsInternal.compareResourceVersions("99", "100")).isNegative(); + assertThat(ReconcilerUtilsInternal.compareResourceVersions("100", "99")).isPositive(); + assertThat(ReconcilerUtilsInternal.compareResourceVersions("9", "100")).isNegative(); + assertThat(ReconcilerUtilsInternal.compareResourceVersions("100", "9")).isPositive(); + + // Test same length - lexicographic comparison + assertThat(ReconcilerUtilsInternal.compareResourceVersions("1", "2")).isNegative(); + assertThat(ReconcilerUtilsInternal.compareResourceVersions("2", "1")).isPositive(); + assertThat(ReconcilerUtilsInternal.compareResourceVersions("11", "12")).isNegative(); + assertThat(ReconcilerUtilsInternal.compareResourceVersions("12", "11")).isPositive(); + assertThat(ReconcilerUtilsInternal.compareResourceVersions("99", "100")).isNegative(); + assertThat(ReconcilerUtilsInternal.compareResourceVersions("100", "99")).isPositive(); + assertThat(ReconcilerUtilsInternal.compareResourceVersions("123", "124")).isNegative(); + assertThat(ReconcilerUtilsInternal.compareResourceVersions("124", "123")).isPositive(); + + // Test with non-numeric strings (algorithm should still work character-wise) + assertThat(ReconcilerUtilsInternal.compareResourceVersions("a", "b")).isNegative(); + assertThat(ReconcilerUtilsInternal.compareResourceVersions("b", "a")).isPositive(); + assertThat(ReconcilerUtilsInternal.compareResourceVersions("abc", "abd")).isNegative(); + assertThat(ReconcilerUtilsInternal.compareResourceVersions("abd", "abc")).isPositive(); + + // Test edge cases with larger numbers + assertThat(ReconcilerUtilsInternal.compareResourceVersions("1234567890", "1234567891")) + .isNegative(); + assertThat(ReconcilerUtilsInternal.compareResourceVersions("1234567891", "1234567890")) + .isPositive(); + } + + @Test + void compareResourceVersionsWithHasMetadata() { + // Test equal versions + HasMetadata resource1 = createResourceWithVersion("123"); + HasMetadata resource2 = createResourceWithVersion("123"); + assertThat(ReconcilerUtilsInternal.compareResourceVersions(resource1, resource2)).isZero(); + + // Test different lengths + resource1 = createResourceWithVersion("1"); + resource2 = createResourceWithVersion("12"); + assertThat(ReconcilerUtilsInternal.compareResourceVersions(resource1, resource2)).isNegative(); + assertThat(ReconcilerUtilsInternal.compareResourceVersions(resource2, resource1)).isPositive(); + + // Test same length, different values + resource1 = createResourceWithVersion("100"); + resource2 = createResourceWithVersion("200"); + assertThat(ReconcilerUtilsInternal.compareResourceVersions(resource1, resource2)).isNegative(); + assertThat(ReconcilerUtilsInternal.compareResourceVersions(resource2, resource1)).isPositive(); + + // Test realistic Kubernetes resource versions + resource1 = createResourceWithVersion("12345"); + resource2 = createResourceWithVersion("12346"); + assertThat(ReconcilerUtilsInternal.compareResourceVersions(resource1, resource2)).isNegative(); + assertThat(ReconcilerUtilsInternal.compareResourceVersions(resource2, resource1)).isPositive(); + } + + private HasMetadata createResourceWithVersion(String resourceVersion) { + return new PodBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName("test-pod") + .withNamespace("default") + .withResourceVersion(resourceVersion) + .build()) + .build(); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ReconcilerUtilsTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ReconcilerUtilsTest.java deleted file mode 100644 index 3bbe2a894b..0000000000 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ReconcilerUtilsTest.java +++ /dev/null @@ -1,186 +0,0 @@ -/* - * 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; - -import java.net.URI; - -import org.junit.jupiter.api.Test; - -import io.fabric8.kubernetes.api.model.*; -import io.fabric8.kubernetes.api.model.apps.Deployment; -import io.fabric8.kubernetes.api.model.apps.DeploymentBuilder; -import io.fabric8.kubernetes.api.model.apps.DeploymentSpec; -import io.fabric8.kubernetes.client.CustomResource; -import io.fabric8.kubernetes.client.KubernetesClientException; -import io.fabric8.kubernetes.client.http.HttpRequest; -import io.fabric8.kubernetes.model.annotation.Group; -import io.fabric8.kubernetes.model.annotation.ShortNames; -import io.fabric8.kubernetes.model.annotation.Version; -import io.javaoperatorsdk.operator.sample.simple.TestCustomReconciler; -import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; - -import static io.javaoperatorsdk.operator.ReconcilerUtils.getDefaultFinalizerName; -import static io.javaoperatorsdk.operator.ReconcilerUtils.getDefaultNameFor; -import static io.javaoperatorsdk.operator.ReconcilerUtils.getDefaultReconcilerName; -import static io.javaoperatorsdk.operator.ReconcilerUtils.handleKubernetesClientException; -import static io.javaoperatorsdk.operator.ReconcilerUtils.isFinalizerValid; -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -class ReconcilerUtilsTest { - - public static final String RESOURCE_URI = - "https://kubernetes.docker.internal:6443/apis/tomcatoperator.io/v1/tomcats"; - - @Test - void defaultReconcilerNameShouldWork() { - assertEquals( - "testcustomreconciler", - getDefaultReconcilerName(TestCustomReconciler.class.getCanonicalName())); - assertEquals( - getDefaultNameFor(TestCustomReconciler.class), - getDefaultReconcilerName(TestCustomReconciler.class.getCanonicalName())); - assertEquals( - getDefaultNameFor(TestCustomReconciler.class), - getDefaultReconcilerName(TestCustomReconciler.class.getSimpleName())); - } - - @Test - void defaultFinalizerShouldWork() { - assertTrue(isFinalizerValid(getDefaultFinalizerName(Pod.class))); - assertTrue(isFinalizerValid(getDefaultFinalizerName(TestCustomResource.class))); - } - - @Test - void equalsSpecObject() { - var d1 = createTestDeployment(); - var d2 = createTestDeployment(); - - assertThat(ReconcilerUtils.specsEqual(d1, d2)).isTrue(); - } - - @Test - void equalArbitraryDifferentSpecsOfObjects() { - var d1 = createTestDeployment(); - var d2 = createTestDeployment(); - d2.getSpec().getTemplate().getSpec().setHostname("otherhost"); - - assertThat(ReconcilerUtils.specsEqual(d1, d2)).isFalse(); - } - - @Test - void getsSpecWithReflection() { - Deployment deployment = new Deployment(); - deployment.setSpec(new DeploymentSpec()); - deployment.getSpec().setReplicas(5); - - DeploymentSpec spec = (DeploymentSpec) ReconcilerUtils.getSpec(deployment); - assertThat(spec.getReplicas()).isEqualTo(5); - } - - @Test - void properlyHandlesNullSpec() { - Namespace ns = new Namespace(); - - final var spec = ReconcilerUtils.getSpec(ns); - assertThat(spec).isNull(); - - ReconcilerUtils.setSpec(ns, null); - } - - @Test - void setsSpecWithReflection() { - Deployment deployment = new Deployment(); - deployment.setSpec(new DeploymentSpec()); - deployment.getSpec().setReplicas(5); - DeploymentSpec newSpec = new DeploymentSpec(); - newSpec.setReplicas(1); - - ReconcilerUtils.setSpec(deployment, newSpec); - - assertThat(deployment.getSpec().getReplicas()).isEqualTo(1); - } - - @Test - void setsSpecCustomResourceWithReflection() { - Tomcat tomcat = new Tomcat(); - tomcat.setSpec(new TomcatSpec()); - tomcat.getSpec().setReplicas(5); - TomcatSpec newSpec = new TomcatSpec(); - newSpec.setReplicas(1); - - ReconcilerUtils.setSpec(tomcat, newSpec); - - assertThat(tomcat.getSpec().getReplicas()).isEqualTo(1); - } - - @Test - void loadYamlAsBuilder() { - DeploymentBuilder builder = - ReconcilerUtils.loadYaml(DeploymentBuilder.class, getClass(), "deployment.yaml"); - builder.accept(ContainerBuilder.class, c -> c.withImage("my-image")); - - Deployment deployment = builder.editMetadata().withName("my-deployment").and().build(); - assertThat(deployment.getMetadata().getName()).isEqualTo("my-deployment"); - } - - private Deployment createTestDeployment() { - Deployment deployment = new Deployment(); - deployment.setSpec(new DeploymentSpec()); - deployment.getSpec().setReplicas(5); - PodTemplateSpec podTemplateSpec = new PodTemplateSpec(); - deployment.getSpec().setTemplate(podTemplateSpec); - podTemplateSpec.setSpec(new PodSpec()); - podTemplateSpec.getSpec().setHostname("localhost"); - return deployment; - } - - @Test - void handleKubernetesExceptionShouldThrowMissingCRDExceptionWhenAppropriate() { - var request = mock(HttpRequest.class); - when(request.uri()).thenReturn(URI.create(RESOURCE_URI)); - assertThrows( - MissingCRDException.class, - () -> - handleKubernetesClientException( - new KubernetesClientException( - "Failure executing: GET at: " + RESOURCE_URI + ". Message: Not Found.", - null, - 404, - null, - request), - HasMetadata.getFullResourceName(Tomcat.class))); - } - - @Group("tomcatoperator.io") - @Version("v1") - @ShortNames("tc") - private static class Tomcat extends CustomResource implements Namespaced {} - - private static class TomcatSpec { - private Integer replicas; - - public Integer getReplicas() { - return replicas; - } - - public void setReplicas(Integer replicas) { - this.replicas = replicas; - } - } -} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/TestUtils.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/TestUtils.java index 956b3d9475..24e36cbe33 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/TestUtils.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/TestUtils.java @@ -32,6 +32,10 @@ public static TestCustomResource testCustomResource() { return testCustomResource(new ResourceID(UUID.randomUUID().toString(), "test")); } + public static TestCustomResource testCustomResource1() { + return testCustomResource(new ResourceID("test1", "default")); + } + public static CustomResourceDefinition testCRD(String scope) { return new CustomResourceDefinitionBuilder() .editOrNewSpec() @@ -43,10 +47,6 @@ public static CustomResourceDefinition testCRD(String scope) { .build(); } - public static TestCustomResource testCustomResource1() { - return testCustomResource(new ResourceID("test1", "default")); - } - public static ResourceID testCustomResource1Id() { return new ResourceID("test1", "default"); } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetricsTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetricsTest.java index 68142048b6..4e89e4020a 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetricsTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetricsTest.java @@ -73,82 +73,80 @@ void controllerRegistered_shouldDelegateToAllMetricsInOrder() { } @Test - void receivedEvent_shouldDelegateToAllMetricsInOrder() { - aggregatedMetrics.receivedEvent(event, metadata); + void eventReceived_shouldDelegateToAllMetricsInOrder() { + aggregatedMetrics.eventReceived(event, metadata); final var inOrder = inOrder(metrics1, metrics2, metrics3); - inOrder.verify(metrics1).receivedEvent(event, metadata); - inOrder.verify(metrics2).receivedEvent(event, metadata); - inOrder.verify(metrics3).receivedEvent(event, metadata); + inOrder.verify(metrics1).eventReceived(event, metadata); + inOrder.verify(metrics2).eventReceived(event, metadata); + inOrder.verify(metrics3).eventReceived(event, metadata); verifyNoMoreInteractions(metrics1, metrics2, metrics3); } @Test - void reconcileCustomResource_shouldDelegateToAllMetricsInOrder() { - aggregatedMetrics.reconcileCustomResource(resource, retryInfo, metadata); + void reconciliationSubmitted_shouldDelegateToAllMetricsInOrder() { + aggregatedMetrics.reconciliationSubmitted(resource, retryInfo, metadata); final var inOrder = inOrder(metrics1, metrics2, metrics3); - inOrder.verify(metrics1).reconcileCustomResource(resource, retryInfo, metadata); - inOrder.verify(metrics2).reconcileCustomResource(resource, retryInfo, metadata); - inOrder.verify(metrics3).reconcileCustomResource(resource, retryInfo, metadata); + inOrder.verify(metrics1).reconciliationSubmitted(resource, retryInfo, metadata); + inOrder.verify(metrics2).reconciliationSubmitted(resource, retryInfo, metadata); + inOrder.verify(metrics3).reconciliationSubmitted(resource, retryInfo, metadata); verifyNoMoreInteractions(metrics1, metrics2, metrics3); } @Test - void failedReconciliation_shouldDelegateToAllMetricsInOrder() { + void reconciliationFailed_shouldDelegateToAllMetricsInOrder() { final var exception = new RuntimeException("Test exception"); - - aggregatedMetrics.failedReconciliation(resource, exception, metadata); + aggregatedMetrics.reconciliationFailed(resource, retryInfo, exception, metadata); final var inOrder = inOrder(metrics1, metrics2, metrics3); - inOrder.verify(metrics1).failedReconciliation(resource, exception, metadata); - inOrder.verify(metrics2).failedReconciliation(resource, exception, metadata); - inOrder.verify(metrics3).failedReconciliation(resource, exception, metadata); + inOrder.verify(metrics1).reconciliationFailed(resource, retryInfo, exception, metadata); + inOrder.verify(metrics2).reconciliationFailed(resource, retryInfo, exception, metadata); + inOrder.verify(metrics3).reconciliationFailed(resource, retryInfo, exception, metadata); verifyNoMoreInteractions(metrics1, metrics2, metrics3); } @Test - void reconciliationExecutionStarted_shouldDelegateToAllMetricsInOrder() { - aggregatedMetrics.reconciliationExecutionStarted(resource, metadata); + void reconciliationStarted_shouldDelegateToAllMetricsInOrder() { + aggregatedMetrics.reconciliationStarted(resource, metadata); final var inOrder = inOrder(metrics1, metrics2, metrics3); - inOrder.verify(metrics1).reconciliationExecutionStarted(resource, metadata); - inOrder.verify(metrics2).reconciliationExecutionStarted(resource, metadata); - inOrder.verify(metrics3).reconciliationExecutionStarted(resource, metadata); + inOrder.verify(metrics1).reconciliationStarted(resource, metadata); + inOrder.verify(metrics2).reconciliationStarted(resource, metadata); + inOrder.verify(metrics3).reconciliationStarted(resource, metadata); verifyNoMoreInteractions(metrics1, metrics2, metrics3); } @Test - void reconciliationExecutionFinished_shouldDelegateToAllMetricsInOrder() { - aggregatedMetrics.reconciliationExecutionFinished(resource, metadata); + void reconciliationFinished_shouldDelegateToAllMetricsInOrder() { + aggregatedMetrics.reconciliationFinished(resource, retryInfo, metadata); final var inOrder = inOrder(metrics1, metrics2, metrics3); - inOrder.verify(metrics1).reconciliationExecutionFinished(resource, metadata); - inOrder.verify(metrics2).reconciliationExecutionFinished(resource, metadata); - inOrder.verify(metrics3).reconciliationExecutionFinished(resource, metadata); + inOrder.verify(metrics1).reconciliationFinished(resource, retryInfo, metadata); + inOrder.verify(metrics2).reconciliationFinished(resource, retryInfo, metadata); + inOrder.verify(metrics3).reconciliationFinished(resource, retryInfo, metadata); verifyNoMoreInteractions(metrics1, metrics2, metrics3); } @Test - void cleanupDoneFor_shouldDelegateToAllMetricsInOrder() { - aggregatedMetrics.cleanupDoneFor(resourceID, metadata); + void cleanupDone_shouldDelegateToAllMetricsInOrder() { + aggregatedMetrics.cleanupDone(resourceID, metadata); final var inOrder = inOrder(metrics1, metrics2, metrics3); - inOrder.verify(metrics1).cleanupDoneFor(resourceID, metadata); - inOrder.verify(metrics2).cleanupDoneFor(resourceID, metadata); - inOrder.verify(metrics3).cleanupDoneFor(resourceID, metadata); + inOrder.verify(metrics1).cleanupDone(resourceID, metadata); + inOrder.verify(metrics2).cleanupDone(resourceID, metadata); + inOrder.verify(metrics3).cleanupDone(resourceID, metadata); verifyNoMoreInteractions(metrics1, metrics2, metrics3); } @Test - void finishedReconciliation_shouldDelegateToAllMetricsInOrder() { - aggregatedMetrics.finishedReconciliation(resource, metadata); + void reconciliationSucceeded_shouldDelegateToAllMetricsInOrder() { + aggregatedMetrics.reconciliationSucceeded(resource, metadata); final var inOrder = inOrder(metrics1, metrics2, metrics3); - inOrder.verify(metrics1).finishedReconciliation(resource, metadata); - inOrder.verify(metrics2).finishedReconciliation(resource, metadata); - inOrder.verify(metrics3).finishedReconciliation(resource, metadata); - verifyNoMoreInteractions(metrics1, metrics2, metrics3); + inOrder.verify(metrics1).reconciliationSucceeded(resource, metadata); + inOrder.verify(metrics2).reconciliationSucceeded(resource, metadata); + inOrder.verify(metrics3).reconciliationSucceeded(resource, metadata); } @Test @@ -178,18 +176,4 @@ void timeControllerExecution_shouldPropagateException() throws Exception { verify(metrics3, never()).timeControllerExecution(any()); verifyNoMoreInteractions(metrics1, metrics2, metrics3); } - - @Test - void monitorSizeOf_shouldDelegateToAllMetricsInOrderAndReturnOriginalMap() { - final var testMap = Map.of("key1", "value1"); - final var mapName = "testMap"; - - final var result = aggregatedMetrics.monitorSizeOf(testMap, mapName); - - assertThat(result).isSameAs(testMap); - verify(metrics1).monitorSizeOf(testMap, mapName); - verify(metrics2).monitorSizeOf(testMap, mapName); - verify(metrics3).monitorSizeOf(testMap, mapName); - verifyNoMoreInteractions(metrics1, metrics2, metrics3); - } } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContextTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContextTest.java index 064c73c7f9..4df8df385b 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContextTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContextTest.java @@ -15,13 +15,23 @@ */ package io.javaoperatorsdk.operator.api.reconciler; +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.Pod; +import io.fabric8.kubernetes.api.model.PodBuilder; import io.fabric8.kubernetes.api.model.Secret; import io.javaoperatorsdk.operator.processing.Controller; import io.javaoperatorsdk.operator.processing.event.EventSourceManager; import io.javaoperatorsdk.operator.processing.event.NoEventSourceForClassException; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -30,17 +40,21 @@ class DefaultContextTest { - private final Secret primary = new Secret(); - private final Controller mockController = mock(); + private DefaultContext context; + private Controller mockController; + private EventSourceManager mockManager; - private final DefaultContext context = - new DefaultContext<>(null, mockController, primary, false, false); + @BeforeEach + void setUp() { + mockController = mock(); + mockManager = mock(); + when(mockController.getEventSourceManager()).thenReturn(mockManager); + + context = new DefaultContext<>(null, mockController, new Secret(), false, false); + } @Test - @SuppressWarnings("unchecked") void getSecondaryResourceReturnsEmptyOptionalOnNonActivatedDRType() { - var mockManager = mock(EventSourceManager.class); - when(mockController.getEventSourceManager()).thenReturn(mockManager); when(mockController.workflowContainsDependentForType(ConfigMap.class)).thenReturn(true); when(mockManager.getEventSourceFor(any(), any())) .thenThrow(new NoEventSourceForClassException(ConfigMap.class)); @@ -56,4 +70,101 @@ void setRetryInfo() { assertThat(newContext).isSameAs(context); assertThat(newContext.getRetryInfo()).hasValue(retryInfo); } + + @Test + void latestDistinctKeepsOnlyLatestResourceVersion() { + // Create multiple resources with same name and namespace but different versions + var pod1v1 = podWithNameAndVersion("pod1", "100"); + var pod1v2 = podWithNameAndVersion("pod1", "200"); + var pod1v3 = podWithNameAndVersion("pod1", "150"); + + // Create a resource with different name + var pod2v1 = podWithNameAndVersion("pod2", "100"); + + // Create a resource with same name but different namespace + var pod1OtherNsv1 = podWithNameAndVersion("pod1", "50", "other"); + + setUpEventSourceWith(pod1v1, pod1v2, pod1v3, pod1OtherNsv1, pod2v1); + + var result = context.getSecondaryResourcesAsStream(Pod.class, true).toList(); + + // Should have 3 resources: pod1 in default (latest version 200), pod2 in default, and pod1 in + // other + assertThat(result).hasSize(3); + + // Find pod1 in default namespace - should have version 200 + final var pod1InDefault = + result.stream() + .filter(r -> ResourceID.fromResource(r).isSameResource("pod1", "default")) + .findFirst() + .orElseThrow(); + assertThat(pod1InDefault.getMetadata().getResourceVersion()).isEqualTo("200"); + + // Find pod2 in default namespace - should exist + HasMetadata pod2InDefault = + result.stream() + .filter(r -> ResourceID.fromResource(r).isSameResource("pod2", "default")) + .findFirst() + .orElseThrow(); + assertThat(pod2InDefault.getMetadata().getResourceVersion()).isEqualTo("100"); + + // Find pod1 in other namespace - should exist + HasMetadata pod1InOther = + result.stream() + .filter(r -> ResourceID.fromResource(r).isSameResource("pod1", "other")) + .findFirst() + .orElseThrow(); + assertThat(pod1InOther.getMetadata().getResourceVersion()).isEqualTo("50"); + } + + private void setUpEventSourceWith(Pod... pods) { + EventSource mockEventSource = mock(); + when(mockEventSource.getSecondaryResources(any())).thenReturn(Set.of(pods)); + when(mockManager.getEventSourcesFor(Pod.class)).thenReturn(List.of(mockEventSource)); + } + + private static Pod podWithNameAndVersion( + String name, String resourceVersion, String... namespace) { + final var ns = namespace != null && namespace.length > 0 ? namespace[0] : "default"; + return new PodBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName(name) + .withNamespace(ns) + .withResourceVersion(resourceVersion) + .build()) + .build(); + } + + @Test + void latestDistinctHandlesEmptyStream() { + var result = context.getSecondaryResourcesAsStream(Pod.class, true).toList(); + + assertThat(result).isEmpty(); + } + + @Test + void latestDistinctHandlesSingleResource() { + final var pod = podWithNameAndVersion("pod1", "100"); + setUpEventSourceWith(pod); + + var result = context.getSecondaryResourcesAsStream(Pod.class, true).toList(); + + assertThat(result).hasSize(1); + assertThat(result).contains(pod); + } + + @Test + void latestDistinctComparesNumericVersionsCorrectly() { + // Test that version 1000 is greater than version 999 (not lexicographic) + final var podV999 = podWithNameAndVersion("pod1", "999"); + final var podV1000 = podWithNameAndVersion("pod1", "1000"); + setUpEventSourceWith(podV999, podV1000); + + var result = context.getSecondaryResourcesAsStream(Pod.class, true).toList(); + + assertThat(result).hasSize(1); + HasMetadata resultPod = result.iterator().next(); + assertThat(resultPod.getMetadata().getResourceVersion()).isEqualTo("1000"); + } } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/PrimaryUpdateAndCacheUtilsTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/PrimaryUpdateAndCacheUtilsTest.java index 235dd3cd40..c878a4fc06 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/PrimaryUpdateAndCacheUtilsTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/PrimaryUpdateAndCacheUtilsTest.java @@ -19,6 +19,7 @@ import java.util.function.UnaryOperator; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -39,6 +40,7 @@ import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; import static io.javaoperatorsdk.operator.api.reconciler.PrimaryUpdateAndCacheUtils.DEFAULT_MAX_RETRY; +import static io.javaoperatorsdk.operator.api.reconciler.PrimaryUpdateAndCacheUtils.compareResourceVersions; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; @@ -180,4 +182,53 @@ void cachePollTimeouts() { 10L)); assertThat(ex.getMessage()).contains("Timeout"); } + + @Test + public void compareResourceVersionsTest() { + assertThat(compareResourceVersions("11", "22")).isNegative(); + assertThat(compareResourceVersions("22", "11")).isPositive(); + assertThat(compareResourceVersions("1", "1")).isZero(); + assertThat(compareResourceVersions("11", "11")).isZero(); + assertThat(compareResourceVersions("123", "2")).isPositive(); + assertThat(compareResourceVersions("3", "211")).isNegative(); + + assertThrows( + NonComparableResourceVersionException.class, () -> compareResourceVersions("aa", "22")); + assertThrows( + NonComparableResourceVersionException.class, () -> compareResourceVersions("11", "ba")); + assertThrows( + NonComparableResourceVersionException.class, () -> compareResourceVersions("", "22")); + assertThrows( + NonComparableResourceVersionException.class, () -> compareResourceVersions("11", "")); + assertThrows( + NonComparableResourceVersionException.class, () -> compareResourceVersions("01", "123")); + assertThrows( + NonComparableResourceVersionException.class, () -> compareResourceVersions("123", "01")); + assertThrows( + NonComparableResourceVersionException.class, () -> compareResourceVersions("3213", "123a")); + assertThrows( + NonComparableResourceVersionException.class, () -> compareResourceVersions("321", "123a")); + } + + // naive performance test that compares the work case scenario for the parsing and non-parsing + // variants + @Test + @Disabled + public void compareResourcePerformanceTest() { + var execNum = 30000000; + var startTime = System.currentTimeMillis(); + for (int i = 0; i < execNum; i++) { + var res = compareResourceVersions("123456788", "123456789"); + } + var dur1 = System.currentTimeMillis() - startTime; + log.info("Duration without parsing: {}", dur1); + startTime = System.currentTimeMillis(); + for (int i = 0; i < execNum; i++) { + var res = Long.parseLong("123456788") > Long.parseLong("123456789"); + } + var dur2 = System.currentTimeMillis() - startTime; + log.info("Duration with parsing: {}", dur2); + + assertThat(dur1).isLessThan(dur2); + } } 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 new file mode 100644 index 0000000000..8d0176cd4a --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java @@ -0,0 +1,327 @@ +/* + * 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; + +import java.util.Collections; +import java.util.List; +import java.util.function.UnaryOperator; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +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.Resource; +import io.javaoperatorsdk.operator.TestUtils; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.processing.event.EventSourceRetriever; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.controller.ControllerEventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.ManagedInformerEventSource; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@SuppressWarnings("unchecked") +class ResourceOperationsTest { + + private static final String FINALIZER_NAME = "test.javaoperatorsdk.io/finalizer"; + + private Context context; + + @SuppressWarnings("rawtypes") + private Resource resourceOp; + + private ControllerEventSource controllerEventSource; + private ResourceOperations resourceOperations; + + @BeforeEach + void setupMocks() { + context = mock(Context.class); + final var client = mock(KubernetesClient.class); + final var mixedOperation = mock(MixedOperation.class); + resourceOp = mock(Resource.class); + controllerEventSource = mock(ControllerEventSource.class); + final var controllerConfiguration = mock(ControllerConfiguration.class); + + var eventSourceRetriever = mock(EventSourceRetriever.class); + + when(context.getClient()).thenReturn(client); + when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); + when(context.getControllerConfiguration()).thenReturn(controllerConfiguration); + when(controllerConfiguration.getFinalizerName()).thenReturn(FINALIZER_NAME); + when(eventSourceRetriever.getControllerEventSource()).thenReturn(controllerEventSource); + + when(client.resources(TestCustomResource.class)).thenReturn(mixedOperation); + when(mixedOperation.inNamespace(any())).thenReturn(mixedOperation); + when(mixedOperation.withName(any())).thenReturn(resourceOp); + + resourceOperations = new ResourceOperations<>(context); + } + + @Test + void addsFinalizer() { + var resource = TestUtils.testCustomResource1(); + resource.getMetadata().setResourceVersion("1"); + + when(context.getPrimaryResource()).thenReturn(resource); + + // Mock successful finalizer addition + when(controllerEventSource.eventFilteringUpdateAndCacheResource( + any(), any(UnaryOperator.class))) + .thenAnswer( + invocation -> { + var res = TestUtils.testCustomResource1(); + res.getMetadata().setResourceVersion("2"); + res.addFinalizer(FINALIZER_NAME); + return res; + }); + + var result = resourceOperations.addFinalizer(FINALIZER_NAME); + + assertThat(result).isNotNull(); + assertThat(result.hasFinalizer(FINALIZER_NAME)).isTrue(); + assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); + verify(controllerEventSource, times(1)) + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + } + + @Test + void addsFinalizerWithSSA() { + var resource = TestUtils.testCustomResource1(); + resource.getMetadata().setResourceVersion("1"); + + when(context.getPrimaryResource()).thenReturn(resource); + + // Mock successful SSA finalizer addition + when(controllerEventSource.eventFilteringUpdateAndCacheResource( + any(), any(UnaryOperator.class))) + .thenAnswer( + invocation -> { + var res = TestUtils.testCustomResource1(); + res.getMetadata().setResourceVersion("2"); + res.addFinalizer(FINALIZER_NAME); + return res; + }); + + var result = resourceOperations.addFinalizerWithSSA(FINALIZER_NAME); + + assertThat(result).isNotNull(); + assertThat(result.hasFinalizer(FINALIZER_NAME)).isTrue(); + assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); + verify(controllerEventSource, times(1)) + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + } + + @Test + void removesFinalizer() { + var resource = TestUtils.testCustomResource1(); + resource.getMetadata().setResourceVersion("1"); + resource.addFinalizer(FINALIZER_NAME); + + when(context.getPrimaryResource()).thenReturn(resource); + + // Mock successful finalizer removal + when(controllerEventSource.eventFilteringUpdateAndCacheResource( + any(), any(UnaryOperator.class))) + .thenAnswer( + invocation -> { + var res = TestUtils.testCustomResource1(); + res.getMetadata().setResourceVersion("2"); + // finalizer is removed, so don't add it + return res; + }); + + var result = resourceOperations.removeFinalizer(FINALIZER_NAME); + + assertThat(result).isNotNull(); + assertThat(result.hasFinalizer(FINALIZER_NAME)).isFalse(); + assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); + verify(controllerEventSource, times(1)) + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + } + + @Test + void retriesAddingFinalizerWithoutSSA() { + var resource = TestUtils.testCustomResource1(); + resource.getMetadata().setResourceVersion("1"); + + when(context.getPrimaryResource()).thenReturn(resource); + + // First call throws conflict, second succeeds + when(controllerEventSource.eventFilteringUpdateAndCacheResource( + any(), any(UnaryOperator.class))) + .thenThrow(new KubernetesClientException("Conflict", 409, null)) + .thenAnswer( + invocation -> { + var res = TestUtils.testCustomResource1(); + res.getMetadata().setResourceVersion("2"); + res.addFinalizer(FINALIZER_NAME); + return res; + }); + + // Return fresh resource on retry + when(resourceOp.get()).thenReturn(resource); + + var result = resourceOperations.addFinalizer(FINALIZER_NAME); + + assertThat(result).isNotNull(); + assertThat(result.hasFinalizer(FINALIZER_NAME)).isTrue(); + verify(controllerEventSource, times(2)) + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + verify(resourceOp, times(1)).get(); + } + + @Test + void nullResourceIsGracefullyHandledOnFinalizerRemovalRetry() { + var resource = TestUtils.testCustomResource1(); + resource.getMetadata().setResourceVersion("1"); + resource.addFinalizer(FINALIZER_NAME); + + when(context.getPrimaryResource()).thenReturn(resource); + + // First call throws conflict + when(controllerEventSource.eventFilteringUpdateAndCacheResource( + any(), any(UnaryOperator.class))) + .thenThrow(new KubernetesClientException("Conflict", 409, null)); + + // Return null on retry (resource was deleted) + when(resourceOp.get()).thenReturn(null); + + resourceOperations.removeFinalizer(FINALIZER_NAME); + + verify(controllerEventSource, times(1)) + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + verify(resourceOp, times(1)).get(); + } + + @Test + void retriesFinalizerRemovalWithFreshResource() { + var originalResource = TestUtils.testCustomResource1(); + originalResource.getMetadata().setResourceVersion("1"); + originalResource.addFinalizer(FINALIZER_NAME); + + when(context.getPrimaryResource()).thenReturn(originalResource); + + // First call throws unprocessable (422), second succeeds + when(controllerEventSource.eventFilteringUpdateAndCacheResource( + any(), any(UnaryOperator.class))) + .thenThrow(new KubernetesClientException("Unprocessable", 422, null)) + .thenAnswer( + invocation -> { + var res = TestUtils.testCustomResource1(); + res.getMetadata().setResourceVersion("3"); + // finalizer should be removed + return res; + }); + + // Return fresh resource with newer version on retry + var freshResource = TestUtils.testCustomResource1(); + freshResource.getMetadata().setResourceVersion("2"); + freshResource.addFinalizer(FINALIZER_NAME); + when(resourceOp.get()).thenReturn(freshResource); + + var result = resourceOperations.removeFinalizer(FINALIZER_NAME); + + assertThat(result).isNotNull(); + assertThat(result.getMetadata().getResourceVersion()).isEqualTo("3"); + assertThat(result.hasFinalizer(FINALIZER_NAME)).isFalse(); + verify(controllerEventSource, times(2)) + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + verify(resourceOp, times(1)).get(); + } + + @Test + void resourcePatchWithSingleEventSource() { + var resource = TestUtils.testCustomResource1(); + resource.getMetadata().setResourceVersion("1"); + + var updatedResource = TestUtils.testCustomResource1(); + updatedResource.getMetadata().setResourceVersion("2"); + + var eventSourceRetriever = mock(EventSourceRetriever.class); + var managedEventSource = mock(ManagedInformerEventSource.class); + + when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); + when(eventSourceRetriever.getEventSourcesFor(TestCustomResource.class)) + .thenReturn(List.of(managedEventSource)); + when(managedEventSource.eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class))) + .thenReturn(updatedResource); + + var result = resourceOperations.resourcePatch(resource, UnaryOperator.identity()); + + assertThat(result).isNotNull(); + assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); + verify(managedEventSource, times(1)) + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + } + + @Test + void resourcePatchThrowsWhenNoEventSourceFound() { + var resource = TestUtils.testCustomResource1(); + var eventSourceRetriever = mock(EventSourceRetriever.class); + + when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); + when(eventSourceRetriever.getEventSourcesFor(TestCustomResource.class)) + .thenReturn(Collections.emptyList()); + + var exception = + assertThrows( + IllegalStateException.class, + () -> resourceOperations.resourcePatch(resource, UnaryOperator.identity())); + + assertThat(exception.getMessage()).contains("No event source found for type"); + } + + @Test + void resourcePatchUsesFirstEventSourceIfMultipleEventSourcesPresent() { + var resource = TestUtils.testCustomResource1(); + var eventSourceRetriever = mock(EventSourceRetriever.class); + var eventSource1 = mock(ManagedInformerEventSource.class); + var eventSource2 = mock(ManagedInformerEventSource.class); + + when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); + when(eventSourceRetriever.getEventSourcesFor(TestCustomResource.class)) + .thenReturn(List.of(eventSource1, eventSource2)); + + resourceOperations.resourcePatch(resource, UnaryOperator.identity()); + + verify(eventSource1, times(1)) + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + } + + @Test + void resourcePatchThrowsWhenEventSourceIsNotManagedInformer() { + var resource = TestUtils.testCustomResource1(); + var eventSourceRetriever = mock(EventSourceRetriever.class); + var nonManagedEventSource = mock(EventSource.class); + + when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); + when(eventSourceRetriever.getEventSourcesFor(TestCustomResource.class)) + .thenReturn(List.of(nonManagedEventSource)); + + var exception = + assertThrows( + IllegalStateException.class, + () -> resourceOperations.resourcePatch(resource, UnaryOperator.identity())); + + assertThat(exception.getMessage()).contains("Target event source must be a subclass off"); + assertThat(exception.getMessage()).contains("ManagedInformerEventSource"); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/AbstractDependentResourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/AbstractDependentResourceTest.java index bb9d6cf71e..1db69a1f9e 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/AbstractDependentResourceTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/AbstractDependentResourceTest.java @@ -21,8 +21,10 @@ import org.junit.jupiter.api.Test; import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.DefaultContext; import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; import static org.junit.jupiter.api.Assertions.*; @@ -31,6 +33,13 @@ class AbstractDependentResourceTest { + private static final TestCustomResource PRIMARY = new TestCustomResource(); + private static final DefaultContext CONTEXT = createContext(PRIMARY); + + private static DefaultContext createContext(TestCustomResource primary) { + return new DefaultContext<>(mock(), mock(), primary, false, false); + } + @Test void throwsExceptionIfDesiredIsNullOnCreate() { TestDependentResource testDependentResource = new TestDependentResource(); @@ -38,8 +47,7 @@ void throwsExceptionIfDesiredIsNullOnCreate() { testDependentResource.setDesired(null); assertThrows( - DependentResourceException.class, - () -> testDependentResource.reconcile(new TestCustomResource(), null)); + DependentResourceException.class, () -> testDependentResource.reconcile(PRIMARY, CONTEXT)); } @Test @@ -49,8 +57,7 @@ void throwsExceptionIfDesiredIsNullOnUpdate() { testDependentResource.setDesired(null); assertThrows( - DependentResourceException.class, - () -> testDependentResource.reconcile(new TestCustomResource(), null)); + DependentResourceException.class, () -> testDependentResource.reconcile(PRIMARY, CONTEXT)); } @Test @@ -60,8 +67,7 @@ void throwsExceptionIfCreateReturnsNull() { testDependentResource.setDesired(configMap()); assertThrows( - DependentResourceException.class, - () -> testDependentResource.reconcile(new TestCustomResource(), null)); + DependentResourceException.class, () -> testDependentResource.reconcile(PRIMARY, CONTEXT)); } @Test @@ -71,8 +77,28 @@ void throwsExceptionIfUpdateReturnsNull() { testDependentResource.setDesired(configMap()); assertThrows( - DependentResourceException.class, - () -> testDependentResource.reconcile(new TestCustomResource(), null)); + DependentResourceException.class, () -> testDependentResource.reconcile(PRIMARY, CONTEXT)); + } + + @Test + void checkThatDesiredIsOnlyCalledOnce() { + final var testDependentResource = new DesiredCallCountCheckingDR(); + final var primary = new TestCustomResource(); + final var spec = primary.getSpec(); + spec.setConfigMapName("foo"); + spec.setKey("key"); + spec.setValue("value"); + final var context = createContext(primary); + testDependentResource.reconcile(primary, context); + + spec.setValue("value2"); + testDependentResource.reconcile(primary, context); + + assertEquals(1, testDependentResource.desiredCallCount); + + context.getOrComputeDesiredStateFor( + testDependentResource, p -> testDependentResource.desired(p, context)); + assertEquals(1, testDependentResource.desiredCallCount); } private ConfigMap configMap() { @@ -130,22 +156,12 @@ protected ConfigMap desired(TestCustomResource primary, Context match( return result; } } + + private static class DesiredCallCountCheckingDR extends TestDependentResource { + private short desiredCallCount; + + @Override + public ConfigMap update( + ConfigMap actual, + ConfigMap desired, + TestCustomResource primary, + Context context) { + return desired; + } + + @Override + public ConfigMap create( + ConfigMap desired, TestCustomResource primary, Context context) { + return desired; + } + + @Override + protected ConfigMap desired(TestCustomResource primary, Context context) { + final var spec = primary.getSpec(); + desiredCallCount++; + return new ConfigMapBuilder() + .editOrNewMetadata() + .withName(spec.getConfigMapName()) + .endMetadata() + .addToData(spec.getKey(), spec.getValue()) + .build(); + } + } } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesResourceMatcherTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesResourceMatcherTest.java index 495fe98416..8dd7283fb9 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesResourceMatcherTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesResourceMatcherTest.java @@ -18,37 +18,48 @@ import java.util.Map; import java.util.Optional; -import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import io.fabric8.kubernetes.api.model.*; import io.fabric8.kubernetes.api.model.apps.Deployment; import io.fabric8.kubernetes.api.model.apps.DeploymentBuilder; import io.fabric8.kubernetes.api.model.apps.DeploymentStatusBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; import io.javaoperatorsdk.operator.MockKubernetesClient; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.DefaultContext; import static io.javaoperatorsdk.operator.processing.dependent.kubernetes.GenericKubernetesResourceMatcher.match; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; @SuppressWarnings({"unchecked"}) class GenericKubernetesResourceMatcherTest { - private static final Context context = mock(Context.class); + private static final Context context = new TestContext(); + + private static class TestContext extends DefaultContext { + private final KubernetesClient client = MockKubernetesClient.client(HasMetadata.class); + + public TestContext() { + this(null); + } + + public TestContext(HasMetadata primary) { + super(mock(), mock(), primary, false, false); + } + + @Override + public KubernetesClient getClient() { + return client; + } + } Deployment actual = createDeployment(); Deployment desired = createDeployment(); TestDependentResource dependentResource = new TestDependentResource(desired); - @BeforeAll - static void setUp() { - final var client = MockKubernetesClient.client(HasMetadata.class); - when(context.getClient()).thenReturn(client); - } - @Test void matchesTrivialCases() { assertThat(GenericKubernetesResourceMatcher.match(desired, actual, context).matched()).isTrue(); @@ -77,9 +88,10 @@ void matchesWithStrongSpecEquality() { @Test void doesNotMatchRemovedValues() { actual = createDeployment(); + final var localContext = new TestContext(createPrimary("removed")); assertThat( GenericKubernetesResourceMatcher.match( - dependentResource.desired(createPrimary("removed"), null), actual, context) + dependentResource.getOrComputeDesired(localContext), actual, localContext) .matched()) .withFailMessage("Removing values in metadata should lead to a mismatch") .isFalse(); @@ -186,7 +198,7 @@ ConfigMap createConfigMap() { } Deployment createDeployment() { - return ReconcilerUtils.loadYaml( + return ReconcilerUtilsInternal.loadYaml( Deployment.class, GenericKubernetesResourceMatcherTest.class, "nginx-deployment.yaml"); } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericResourceUpdaterTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericResourceUpdaterTest.java index 3b6580c5d3..70d664f652 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericResourceUpdaterTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericResourceUpdaterTest.java @@ -25,7 +25,7 @@ import io.fabric8.kubernetes.api.model.*; import io.fabric8.kubernetes.api.model.apps.Deployment; import io.javaoperatorsdk.operator.MockKubernetesClient; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.config.ConfigurationService; import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; import io.javaoperatorsdk.operator.api.reconciler.Context; @@ -131,7 +131,7 @@ void checkServiceAccount() { } Deployment createDeployment() { - return ReconcilerUtils.loadYaml( + return ReconcilerUtilsInternal.loadYaml( Deployment.class, GenericResourceUpdaterTest.class, "nginx-deployment.yaml"); } } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/SSABasedGenericKubernetesResourceMatcherTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/SSABasedGenericKubernetesResourceMatcherTest.java index bbcfa704b5..c4d2f2c77d 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/SSABasedGenericKubernetesResourceMatcherTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/SSABasedGenericKubernetesResourceMatcherTest.java @@ -32,7 +32,7 @@ import io.fabric8.kubernetes.api.model.apps.StatefulSet; import io.javaoperatorsdk.operator.MockKubernetesClient; import io.javaoperatorsdk.operator.OperatorException; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.config.ConfigurationService; import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; import io.javaoperatorsdk.operator.api.reconciler.Context; @@ -419,7 +419,7 @@ void testSortListItems() { } private static R loadResource(String fileName, Class clazz) { - return ReconcilerUtils.loadYaml( + return ReconcilerUtilsInternal.loadYaml( clazz, SSABasedGenericKubernetesResourceMatcherTest.class, fileName); } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/NodeExecutorTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/NodeExecutorTest.java index 870bae9c58..65bf258543 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/NodeExecutorTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/NodeExecutorTest.java @@ -21,11 +21,10 @@ import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.*; - class NodeExecutorTest { - private NodeExecutor errorThrowingNodeExecutor = + @SuppressWarnings({"rawtypes", "unchecked"}) + private final NodeExecutor errorThrowingNodeExecutor = new NodeExecutor(null, null) { @Override protected void doRun(DependentResourceNode dependentResourceNode) { diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventProcessorTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventProcessorTest.java index ac187d7eb9..fb8f7c0805 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventProcessorTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventProcessorTest.java @@ -38,8 +38,8 @@ import io.javaoperatorsdk.operator.processing.event.rate.LinearRateLimiter; import io.javaoperatorsdk.operator.processing.event.rate.RateLimiter; import io.javaoperatorsdk.operator.processing.event.rate.RateLimiter.RateLimitState; +import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; import io.javaoperatorsdk.operator.processing.event.source.controller.ControllerEventSource; -import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceAction; import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceDeleteEvent; import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEvent; import io.javaoperatorsdk.operator.processing.event.source.timer.TimerEventSource; @@ -338,7 +338,7 @@ void startProcessedMarkedEventReceivedBefore() { eventProcessor.start(); verify(reconciliationDispatcherMock, timeout(100).times(1)).handleExecution(any()); - verify(metricsMock, times(1)).reconcileCustomResource(any(HasMetadata.class), isNull(), any()); + verify(metricsMock, times(1)).reconciliationSubmitted(any(HasMetadata.class), isNull(), any()); } @Test diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcherTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcherTest.java index cc9df317ae..c7d9458695 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcherTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcherTest.java @@ -26,12 +26,10 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.ArgumentMatchers; -import org.mockito.stubbing.Answer; import io.fabric8.kubernetes.api.model.HasMetadata; import io.fabric8.kubernetes.api.model.ObjectMeta; import io.fabric8.kubernetes.client.CustomResource; -import io.fabric8.kubernetes.client.KubernetesClientException; import io.fabric8.kubernetes.client.utils.KubernetesSerialization; import io.javaoperatorsdk.operator.MockKubernetesClient; import io.javaoperatorsdk.operator.OperatorException; @@ -47,6 +45,7 @@ import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; import io.javaoperatorsdk.operator.api.reconciler.ErrorStatusUpdateControl; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +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; @@ -56,10 +55,8 @@ import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; import static io.javaoperatorsdk.operator.TestUtils.markForDeletion; -import static io.javaoperatorsdk.operator.processing.event.ReconciliationDispatcher.MAX_UPDATE_RETRY; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.*; @SuppressWarnings({"unchecked", "rawtypes"}) @@ -74,6 +71,7 @@ class ReconciliationDispatcherTest { private final CustomResourceFacade customResourceFacade = mock(ReconciliationDispatcher.CustomResourceFacade.class); private static ConfigurationService configurationService; + private ResourceOperations mockResourceOperations; @BeforeEach void setup() { @@ -153,29 +151,25 @@ public boolean useFinalizer() { } @Test - void addFinalizerOnNewResource() { + void addFinalizerOnNewResource() throws Exception { assertFalse(testCustomResource.hasFinalizer(DEFAULT_FINALIZER)); - reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + reconciliationDispatcher.handleDispatch( + executionScopeWithCREvent(testCustomResource), createTestContext()); verify(reconciler, never()).reconcile(ArgumentMatchers.eq(testCustomResource), any()); - verify(customResourceFacade, times(1)) - .patchResourceWithSSA( - argThat(testCustomResource -> testCustomResource.hasFinalizer(DEFAULT_FINALIZER))); + verify(mockResourceOperations, times(1)).addFinalizerWithSSA(); } @Test - void addFinalizerOnNewResourceWithoutSSA() { - initConfigService(false); + void addFinalizerOnNewResourceWithoutSSA() throws Exception { + initConfigService(false, false); final ReconciliationDispatcher dispatcher = init(testCustomResource, reconciler, null, customResourceFacade, true); - assertFalse(testCustomResource.hasFinalizer(DEFAULT_FINALIZER)); - dispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + + dispatcher.handleDispatch(executionScopeWithCREvent(testCustomResource), createTestContext()); + verify(reconciler, never()).reconcile(ArgumentMatchers.eq(testCustomResource), any()); - verify(customResourceFacade, times(1)) - .patchResource( - argThat(testCustomResource -> testCustomResource.hasFinalizer(DEFAULT_FINALIZER)), - any()); - assertThat(testCustomResource.hasFinalizer(DEFAULT_FINALIZER)).isTrue(); + verify(mockResourceOperations, times(1)).addFinalizer(); } @Test @@ -190,13 +184,13 @@ void patchesBothResourceAndStatusIfFinalizerSet() { testCustomResource.addFinalizer(DEFAULT_FINALIZER); reconciler.reconcile = (r, c) -> UpdateControl.patchResourceAndStatus(testCustomResource); - when(customResourceFacade.patchResource(eq(testCustomResource), any())) + when(customResourceFacade.patchResource(any(), eq(testCustomResource), any())) .thenReturn(testCustomResource); reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); - verify(customResourceFacade, times(1)).patchResource(eq(testCustomResource), any()); - verify(customResourceFacade, times(1)).patchStatus(eq(testCustomResource), any()); + verify(customResourceFacade, times(1)).patchResource(any(), eq(testCustomResource), any()); + verify(customResourceFacade, times(1)).patchStatus(any(), eq(testCustomResource), any()); } @Test @@ -207,8 +201,8 @@ void patchesStatus() { reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); - verify(customResourceFacade, times(1)).patchStatus(eq(testCustomResource), any()); - verify(customResourceFacade, never()).patchResource(any(), any()); + verify(customResourceFacade, times(1)).patchStatus(any(), eq(testCustomResource), any()); + verify(customResourceFacade, never()).patchResource(any(), any(), any()); } @Test @@ -231,87 +225,16 @@ void callsDeleteIfObjectHasFinalizerAndMarkedForDelete() { } @Test - void removesDefaultFinalizerOnDeleteIfSet() { + void removesDefaultFinalizerOnDeleteIfSet() throws Exception { testCustomResource.addFinalizer(DEFAULT_FINALIZER); markForDeletion(testCustomResource); var postExecControl = - reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + reconciliationDispatcher.handleDispatch( + executionScopeWithCREvent(testCustomResource), createTestContext()); assertThat(postExecControl.isFinalizerRemoved()).isTrue(); - verify(customResourceFacade, times(1)).patchResourceWithoutSSA(eq(testCustomResource), any()); - } - - @Test - void retriesFinalizerRemovalWithFreshResource() { - testCustomResource.addFinalizer(DEFAULT_FINALIZER); - markForDeletion(testCustomResource); - var resourceWithFinalizer = TestUtils.testCustomResource(); - resourceWithFinalizer.addFinalizer(DEFAULT_FINALIZER); - when(customResourceFacade.patchResourceWithoutSSA(eq(testCustomResource), any())) - .thenThrow(new KubernetesClientException(null, 409, null)) - .thenReturn(testCustomResource); - when(customResourceFacade.getResource(any(), any())).thenReturn(resourceWithFinalizer); - - var postExecControl = - reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); - - assertThat(postExecControl.isFinalizerRemoved()).isTrue(); - verify(customResourceFacade, times(2)).patchResourceWithoutSSA(any(), any()); - verify(customResourceFacade, times(1)).getResource(any(), any()); - } - - @Test - void nullResourceIsGracefullyHandledOnFinalizerRemovalRetry() { - // simulate the operator not able or not be allowed to get the custom resource during the retry - // of the finalizer removal - testCustomResource.addFinalizer(DEFAULT_FINALIZER); - markForDeletion(testCustomResource); - when(customResourceFacade.patchResourceWithoutSSA(any(), any())) - .thenThrow(new KubernetesClientException(null, 409, null)); - when(customResourceFacade.getResource(any(), any())).thenReturn(null); - - var postExecControl = - reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); - - assertThat(postExecControl.isFinalizerRemoved()).isTrue(); - verify(customResourceFacade, times(1)).patchResourceWithoutSSA(eq(testCustomResource), any()); - verify(customResourceFacade, times(1)).getResource(any(), any()); - } - - @Test - void throwsExceptionIfFinalizerRemovalRetryExceeded() { - testCustomResource.addFinalizer(DEFAULT_FINALIZER); - markForDeletion(testCustomResource); - when(customResourceFacade.patchResourceWithoutSSA(any(), any())) - .thenThrow(new KubernetesClientException(null, 409, null)); - when(customResourceFacade.getResource(any(), any())) - .thenAnswer((Answer) invocationOnMock -> createResourceWithFinalizer()); - - var postExecControl = - reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); - - assertThat(postExecControl.isFinalizerRemoved()).isFalse(); - assertThat(postExecControl.getRuntimeException()).isPresent(); - assertThat(postExecControl.getRuntimeException().get()).isInstanceOf(OperatorException.class); - verify(customResourceFacade, times(MAX_UPDATE_RETRY)).patchResourceWithoutSSA(any(), any()); - verify(customResourceFacade, times(MAX_UPDATE_RETRY - 1)).getResource(any(), any()); - } - - @Test - void throwsExceptionIfFinalizerRemovalClientExceptionIsNotConflict() { - testCustomResource.addFinalizer(DEFAULT_FINALIZER); - markForDeletion(testCustomResource); - when(customResourceFacade.patchResourceWithoutSSA(any(), any())) - .thenThrow(new KubernetesClientException(null, 400, null)); - - var res = - reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); - - assertThat(res.getRuntimeException()).isPresent(); - assertThat(res.getRuntimeException().get()).isInstanceOf(KubernetesClientException.class); - verify(customResourceFacade, times(1)).patchResourceWithoutSSA(any(), any()); - verify(customResourceFacade, never()).getResource(any(), any()); + verify(mockResourceOperations, times(1)).removeFinalizer(); } @Test @@ -354,7 +277,7 @@ void doesNotRemovesTheSetFinalizerIfTheDeleteNotMethodInstructsIt() { reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); assertEquals(1, testCustomResource.getMetadata().getFinalizers().size()); - verify(customResourceFacade, never()).patchResource(any(), any()); + verify(customResourceFacade, never()).patchResource(any(), any(), any()); } @Test @@ -364,21 +287,24 @@ void doesNotUpdateTheResourceIfNoUpdateUpdateControlIfFinalizerSet() { reconciler.reconcile = (r, c) -> UpdateControl.noUpdate(); reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); - verify(customResourceFacade, never()).patchResource(any(), any()); - verify(customResourceFacade, never()).patchStatus(eq(testCustomResource), any()); + verify(customResourceFacade, never()).patchResource(any(), any(), any()); + verify(customResourceFacade, never()).patchStatus(any(), eq(testCustomResource), any()); } @Test - void addsFinalizerIfNotMarkedForDeletionAndEmptyCustomResourceReturned() { + void addsFinalizerIfNotMarkedForDeletionAndEmptyCustomResourceReturned() throws Exception { + removeFinalizers(testCustomResource); reconciler.reconcile = (r, c) -> UpdateControl.noUpdate(); - when(customResourceFacade.patchResourceWithSSA(any())).thenReturn(testCustomResource); + var context = createTestContext(); + when(mockResourceOperations.addFinalizerWithSSA()).thenReturn(testCustomResource); var postExecControl = - reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + reconciliationDispatcher.handleDispatch( + executionScopeWithCREvent(testCustomResource), context); + + verify(mockResourceOperations, times(1)).addFinalizerWithSSA(); - verify(customResourceFacade, times(1)) - .patchResourceWithSSA(argThat(a -> !a.getMetadata().getFinalizers().isEmpty())); assertThat(postExecControl.updateIsStatusPatch()).isFalse(); assertThat(postExecControl.getUpdatedCustomResource()).isPresent(); } @@ -390,7 +316,7 @@ void doesNotCallDeleteIfMarkedForDeletionButNotOurFinalizer() { reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); - verify(customResourceFacade, never()).patchResource(any(), any()); + verify(customResourceFacade, never()).patchResource(any(), any(), any()); verify(reconciler, never()).cleanup(eq(testCustomResource), any()); } @@ -471,7 +397,7 @@ void doesNotUpdatesObservedGenerationIfStatusIsNotPatchedWhenUsingSSA() throws E CustomResourceFacade facade = mock(CustomResourceFacade.class); when(config.isGenerationAware()).thenReturn(true); when(reconciler.reconcile(any(), any())).thenReturn(UpdateControl.noUpdate()); - when(facade.patchStatus(any(), any())).thenReturn(observedGenResource); + when(facade.patchStatus(any(), any(), any())).thenReturn(observedGenResource); var dispatcher = init(observedGenResource, reconciler, config, facade, true); PostExecutionControl control = @@ -489,12 +415,12 @@ void doesNotPatchObservedGenerationOnCustomResourcePatch() throws Exception { when(config.isGenerationAware()).thenReturn(true); when(reconciler.reconcile(any(), any())) .thenReturn(UpdateControl.patchResource(observedGenResource)); - when(facade.patchResource(any(), any())).thenReturn(observedGenResource); + when(facade.patchResource(any(), any(), any())).thenReturn(observedGenResource); var dispatcher = init(observedGenResource, reconciler, config, facade, false); dispatcher.handleExecution(executionScopeWithCREvent(observedGenResource)); - verify(facade, never()).patchStatus(any(), any()); + verify(facade, never()).patchStatus(any(), any(), any()); } @Test @@ -529,7 +455,7 @@ public boolean isLastAttempt() { false) .setResource(testCustomResource)); - verify(customResourceFacade, times(1)).patchStatus(eq(testCustomResource), any()); + verify(customResourceFacade, times(1)).patchStatus(any(), eq(testCustomResource), any()); verify(reconciler, times(1)).updateErrorStatus(eq(testCustomResource), any(), any()); } @@ -550,7 +476,7 @@ void callErrorStatusHandlerEvenOnFirstError() { var postExecControl = reconciliationDispatcher.handleExecution( new ExecutionScope(null, null, false, false).setResource(testCustomResource)); - verify(customResourceFacade, times(1)).patchStatus(eq(testCustomResource), any()); + verify(customResourceFacade, times(1)).patchStatus(any(), eq(testCustomResource), any()); verify(reconciler, times(1)).updateErrorStatus(eq(testCustomResource), any(), any()); assertThat(postExecControl.exceptionDuringExecution()).isTrue(); } @@ -573,7 +499,7 @@ void errorHandlerCanInstructNoRetryWithUpdate() { new ExecutionScope(null, null, false, false).setResource(testCustomResource)); verify(reconciler, times(1)).updateErrorStatus(eq(testCustomResource), any(), any()); - verify(customResourceFacade, times(1)).patchStatus(eq(testCustomResource), any()); + verify(customResourceFacade, times(1)).patchStatus(any(), eq(testCustomResource), any()); assertThat(postExecControl.exceptionDuringExecution()).isFalse(); } @@ -595,7 +521,7 @@ void errorHandlerCanInstructNoRetryNoUpdate() { new ExecutionScope(null, null, false, false).setResource(testCustomResource)); verify(reconciler, times(1)).updateErrorStatus(eq(testCustomResource), any(), any()); - verify(customResourceFacade, times(0)).patchStatus(eq(testCustomResource), any()); + verify(customResourceFacade, times(0)).patchStatus(any(), eq(testCustomResource), any()); assertThat(postExecControl.exceptionDuringExecution()).isFalse(); } @@ -611,7 +537,7 @@ void errorStatusHandlerCanPatchResource() { reconciliationDispatcher.handleExecution( new ExecutionScope(null, null, false, false).setResource(testCustomResource)); - verify(customResourceFacade, times(1)).patchStatus(eq(testCustomResource), any()); + verify(customResourceFacade, times(1)).patchStatus(any(), eq(testCustomResource), any()); verify(reconciler, times(1)).updateErrorStatus(eq(testCustomResource), any(), any()); } @@ -659,30 +585,6 @@ void canSkipSchedulingMaxDelayIf() { assertThat(control.getReScheduleDelay()).isNotPresent(); } - @Test - void retriesAddingFinalizerWithoutSSA() { - initConfigService(false); - reconciliationDispatcher = - init(testCustomResource, reconciler, null, customResourceFacade, true); - - removeFinalizers(testCustomResource); - reconciler.reconcile = (r, c) -> UpdateControl.noUpdate(); - when(customResourceFacade.patchResource(any(), any())) - .thenThrow(new KubernetesClientException(null, 409, null)) - .thenReturn(testCustomResource); - when(customResourceFacade.getResource(any(), any())) - .then( - (Answer) - invocationOnMock -> { - testCustomResource.getFinalizers().clear(); - return testCustomResource; - }); - - reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); - - verify(customResourceFacade, times(2)).patchResource(any(), any()); - } - @Test void reSchedulesFromErrorHandler() { var delay = 1000L; @@ -742,6 +644,13 @@ void reconcilerContextUsesTheSameInstanceOfResourceAsParam() { .isNotSameAs(testCustomResource); } + private Context createTestContext() { + var mockContext = mock(Context.class); + mockResourceOperations = mock(ResourceOperations.class); + when(mockContext.resourceOperations()).thenReturn(mockResourceOperations); + return mockContext; + } + private ObservedGenCustomResource createObservedGenCustomResource() { ObservedGenCustomResource observedGenCustomResource = new ObservedGenCustomResource(); observedGenCustomResource.setMetadata(new ObjectMeta()); @@ -751,12 +660,6 @@ private ObservedGenCustomResource createObservedGenCustomResource() { return observedGenCustomResource; } - TestCustomResource createResourceWithFinalizer() { - var resourceWithFinalizer = TestUtils.testCustomResource(); - resourceWithFinalizer.addFinalizer(DEFAULT_FINALIZER); - return resourceWithFinalizer; - } - private void removeFinalizers(CustomResource customResource) { customResource.getMetadata().getFinalizers().clear(); } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/ResourceStateManagerTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/ResourceStateManagerTest.java index 25e93a813c..d480dd06f8 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/ResourceStateManagerTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/ResourceStateManagerTest.java @@ -20,7 +20,7 @@ import org.junit.jupiter.api.Test; import io.javaoperatorsdk.operator.TestUtils; -import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceAction; +import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEvent; import static org.assertj.core.api.Assertions.assertThat; diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/EventFilterTestUtils.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/EventFilterTestUtils.java new file mode 100644 index 0000000000..72bcac0f54 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/EventFilterTestUtils.java @@ -0,0 +1,64 @@ +/* + * 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.event.source; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.UnaryOperator; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.processing.event.source.informer.ManagedInformerEventSource; + +public class EventFilterTestUtils { + + static ExecutorService executorService = Executors.newCachedThreadPool(); + + public static CountDownLatch sendForEventFilteringUpdate( + ManagedInformerEventSource eventSource, R resource, UnaryOperator updateMethod) { + try { + CountDownLatch latch = new CountDownLatch(1); + CountDownLatch sendOnGoingLatch = new CountDownLatch(1); + executorService.submit( + () -> + eventSource.eventFilteringUpdateAndCacheResource( + resource, + r -> { + try { + sendOnGoingLatch.countDown(); + latch.await(); + var resp = updateMethod.apply(r); + return resp; + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + })); + sendOnGoingLatch.await(); + return latch; + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + public static R withResourceVersion(R resource, int resourceVersion) { + var v = resource.getMetadata().getResourceVersion(); + if (v == null) { + throw new IllegalArgumentException("Resource version is null"); + } + resource.getMetadata().setResourceVersion("" + resourceVersion); + return resource; + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java index dcd10b4225..df450b29a6 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java @@ -17,12 +17,14 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.concurrent.CountDownLatch; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import io.fabric8.kubernetes.client.KubernetesClientException; import io.javaoperatorsdk.operator.MockKubernetesClient; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.TestUtils; import io.javaoperatorsdk.operator.api.config.BaseConfigurationService; import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; @@ -34,11 +36,16 @@ import io.javaoperatorsdk.operator.processing.event.EventHandler; import io.javaoperatorsdk.operator.processing.event.EventSourceManager; import io.javaoperatorsdk.operator.processing.event.source.AbstractEventSourceTestBase; +import io.javaoperatorsdk.operator.processing.event.source.EventFilterTestUtils; +import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; import io.javaoperatorsdk.operator.processing.event.source.filter.GenericFilter; import io.javaoperatorsdk.operator.processing.event.source.filter.OnAddFilter; import io.javaoperatorsdk.operator.processing.event.source.filter.OnUpdateFilter; import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; +import static io.javaoperatorsdk.operator.processing.event.source.EventFilterTestUtils.withResourceVersion; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; @@ -46,7 +53,7 @@ class ControllerEventSourceTest extends AbstractEventSourceTestBase, EventHandler> { public static final String FINALIZER = - ReconcilerUtils.getDefaultFinalizerName(TestCustomResource.class); + ReconcilerUtilsInternal.getDefaultFinalizerName(TestCustomResource.class); private final TestController testController = new TestController(true); private final ControllerConfiguration controllerConfig = mock(ControllerConfiguration.class); @@ -68,10 +75,10 @@ void skipsEventHandlingIfGenerationNotIncreased() { TestCustomResource oldCustomResource = TestUtils.testCustomResource(); oldCustomResource.getMetadata().setFinalizers(List.of(FINALIZER)); - source.eventReceived(ResourceAction.UPDATED, customResource, oldCustomResource, null); + source.handleEvent(ResourceAction.UPDATED, customResource, oldCustomResource, null); verify(eventHandler, times(1)).handleEvent(any()); - source.eventReceived(ResourceAction.UPDATED, customResource, customResource, null); + source.handleEvent(ResourceAction.UPDATED, customResource, customResource, null); verify(eventHandler, times(1)).handleEvent(any()); } @@ -79,12 +86,12 @@ void skipsEventHandlingIfGenerationNotIncreased() { void dontSkipEventHandlingIfMarkedForDeletion() { TestCustomResource customResource1 = TestUtils.testCustomResource(); - source.eventReceived(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); verify(eventHandler, times(1)).handleEvent(any()); // mark for deletion customResource1.getMetadata().setDeletionTimestamp(LocalDateTime.now().toString()); - source.eventReceived(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); verify(eventHandler, times(2)).handleEvent(any()); } @@ -92,11 +99,11 @@ void dontSkipEventHandlingIfMarkedForDeletion() { void normalExecutionIfGenerationChanges() { TestCustomResource customResource1 = TestUtils.testCustomResource(); - source.eventReceived(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); verify(eventHandler, times(1)).handleEvent(any()); customResource1.getMetadata().setGeneration(2L); - source.eventReceived(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); verify(eventHandler, times(2)).handleEvent(any()); } @@ -107,10 +114,10 @@ void handlesAllEventIfNotGenerationAware() { TestCustomResource customResource1 = TestUtils.testCustomResource(); - source.eventReceived(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); verify(eventHandler, times(1)).handleEvent(any()); - source.eventReceived(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); verify(eventHandler, times(2)).handleEvent(any()); } @@ -118,7 +125,7 @@ void handlesAllEventIfNotGenerationAware() { void eventWithNoGenerationProcessedIfNoFinalizer() { TestCustomResource customResource1 = TestUtils.testCustomResource(); - source.eventReceived(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); verify(eventHandler, times(1)).handleEvent(any()); } @@ -127,7 +134,7 @@ void eventWithNoGenerationProcessedIfNoFinalizer() { void callsBroadcastsOnResourceEvents() { TestCustomResource customResource1 = TestUtils.testCustomResource(); - source.eventReceived(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); verify(testController.getEventSourceManager(), times(1)) .broadcastOnResourceEvent( @@ -143,8 +150,8 @@ void filtersOutEventsOnAddAndUpdate() { source = new ControllerEventSource<>(new TestController(onAddFilter, onUpdatePredicate, null)); setUpSource(source, true, controllerConfig); - source.eventReceived(ResourceAction.ADDED, cr, null, null); - source.eventReceived(ResourceAction.UPDATED, cr, cr, null); + source.handleEvent(ResourceAction.ADDED, cr, null, null); + source.handleEvent(ResourceAction.UPDATED, cr, cr, null); verify(eventHandler, never()).handleEvent(any()); } @@ -156,13 +163,107 @@ void genericFilterFiltersOutAddUpdateAndDeleteEvents() { source = new ControllerEventSource<>(new TestController(null, null, res -> false)); setUpSource(source, true, controllerConfig); - source.eventReceived(ResourceAction.ADDED, cr, null, null); - source.eventReceived(ResourceAction.UPDATED, cr, cr, null); - source.eventReceived(ResourceAction.DELETED, cr, cr, true); + source.handleEvent(ResourceAction.ADDED, cr, null, null); + source.handleEvent(ResourceAction.UPDATED, cr, cr, null); + source.handleEvent(ResourceAction.DELETED, cr, cr, true); verify(eventHandler, never()).handleEvent(any()); } + @Test + void testEventFilteringBasicScenario() throws InterruptedException { + source = spy(new ControllerEventSource<>(new TestController(null, null, null))); + setUpSource(source, true, controllerConfig); + + var latch = sendForEventFilteringUpdate(2); + source.onUpdate(testResourceWithVersion(1), testResourceWithVersion(2)); + latch.countDown(); + + Thread.sleep(100); + verify(eventHandler, never()).handleEvent(any()); + } + + @Test + void eventFilteringNewEventDuringUpdate() { + source = spy(new ControllerEventSource<>(new TestController(null, null, null))); + setUpSource(source, true, controllerConfig); + + var latch = sendForEventFilteringUpdate(2); + source.onUpdate(testResourceWithVersion(2), testResourceWithVersion(3)); + latch.countDown(); + + await().untilAsserted(() -> expectHandleEvent(3, 2)); + } + + @Test + void eventFilteringMoreNewEventsDuringUpdate() { + source = spy(new ControllerEventSource<>(new TestController(null, null, null))); + setUpSource(source, true, controllerConfig); + + var latch = sendForEventFilteringUpdate(2); + source.onUpdate(testResourceWithVersion(2), testResourceWithVersion(3)); + source.onUpdate(testResourceWithVersion(3), testResourceWithVersion(4)); + latch.countDown(); + + await().untilAsserted(() -> expectHandleEvent(4, 2)); + } + + @Test + void eventFilteringExceptionDuringUpdate() { + source = spy(new ControllerEventSource<>(new TestController(null, null, null))); + setUpSource(source, true, controllerConfig); + + var latch = + EventFilterTestUtils.sendForEventFilteringUpdate( + source, + TestUtils.testCustomResource1(), + r -> { + throw new KubernetesClientException("fake"); + }); + source.onUpdate(testResourceWithVersion(1), testResourceWithVersion(2)); + latch.countDown(); + + expectHandleEvent(2, 1); + } + + private void expectHandleEvent(int newResourceVersion, int oldResourceVersion) { + await() + .untilAsserted( + () -> { + verify(eventHandler, times(1)).handleEvent(any()); + verify(source, times(1)) + .handleEvent( + eq(ResourceAction.UPDATED), + argThat( + r -> { + assertThat(r.getMetadata().getResourceVersion()) + .isEqualTo("" + newResourceVersion); + return true; + }), + argThat( + r -> { + assertThat(r.getMetadata().getResourceVersion()) + .isEqualTo("" + oldResourceVersion); + return true; + }), + isNull()); + }); + } + + private TestCustomResource testResourceWithVersion(int v) { + return withResourceVersion(TestUtils.testCustomResource1(), v); + } + + private CountDownLatch sendForEventFilteringUpdate(int v) { + return sendForEventFilteringUpdate(TestUtils.testCustomResource1(), v); + } + + private CountDownLatch sendForEventFilteringUpdate( + TestCustomResource testResource, int resourceVersion) { + return EventFilterTestUtils.sendForEventFilteringUpdate( + source, testResource, r -> withResourceVersion(testResource, resourceVersion)); + } + @SuppressWarnings("unchecked") private static class TestController extends Controller { @@ -223,6 +324,7 @@ public TestConfiguration( .withOnAddFilter(onAddFilter) .withOnUpdateFilter(onUpdateFilter) .withGenericFilter(genericFilter) + .withComparableResourceVersions(true) .buildForController(), false); } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java index 208d6aeaaa..12c85ee342 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java @@ -15,8 +15,10 @@ */ package io.javaoperatorsdk.operator.processing.event.source.informer; +import java.time.Duration; import java.util.Optional; import java.util.Set; +import java.util.concurrent.CountDownLatch; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -25,6 +27,7 @@ import io.fabric8.kubernetes.api.model.apps.Deployment; import io.fabric8.kubernetes.api.model.apps.DeploymentBuilder; import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientException; import io.javaoperatorsdk.operator.MockKubernetesClient; import io.javaoperatorsdk.operator.OperatorException; import io.javaoperatorsdk.operator.api.config.BaseConfigurationService; @@ -35,16 +38,25 @@ import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; import io.javaoperatorsdk.operator.processing.event.EventHandler; import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.EventFilterTestUtils; +import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; import io.javaoperatorsdk.operator.processing.event.source.SecondaryToPrimaryMapper; +import io.javaoperatorsdk.operator.processing.event.source.informer.TemporaryResourceCache.EventHandling; import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; import static io.javaoperatorsdk.operator.api.reconciler.Constants.DEFAULT_NAMESPACES_SET; +import static io.javaoperatorsdk.operator.processing.event.source.EventFilterTestUtils.withResourceVersion; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -57,7 +69,7 @@ class InformerEventSourceTest { private InformerEventSource informerEventSource; private final KubernetesClient clientMock = MockKubernetesClient.client(Deployment.class); - private final TemporaryResourceCache temporaryResourceCacheMock = + private TemporaryResourceCache temporaryResourceCache = mock(TemporaryResourceCache.class); private final EventHandler eventHandlerMock = mock(EventHandler.class); private final InformerEventSourceConfiguration informerEventSourceConfiguration = @@ -66,61 +78,49 @@ class InformerEventSourceTest { @BeforeEach void setup() { final var informerConfig = mock(InformerConfiguration.class); + SecondaryToPrimaryMapper secondaryToPrimaryMapper = mock(SecondaryToPrimaryMapper.class); + when(informerEventSourceConfiguration.getSecondaryToPrimaryMapper()) + .thenReturn(secondaryToPrimaryMapper); + when(secondaryToPrimaryMapper.toPrimaryResourceIDs(any())) + .thenReturn(Set.of(ResourceID.fromResource(testDeployment()))); when(informerEventSourceConfiguration.getInformerConfig()).thenReturn(informerConfig); when(informerConfig.getEffectiveNamespaces(any())).thenReturn(DEFAULT_NAMESPACES_SET); - when(informerEventSourceConfiguration.getSecondaryToPrimaryMapper()) - .thenReturn(mock(SecondaryToPrimaryMapper.class)); when(informerEventSourceConfiguration.getResourceClass()).thenReturn(Deployment.class); - informerEventSource = - new InformerEventSource<>(informerEventSourceConfiguration, clientMock) { - // mocking start - @Override - public synchronized void start() {} - }; + spy( + new InformerEventSource<>(informerEventSourceConfiguration, clientMock) { + // mocking start + @Override + public synchronized void start() {} + }); var mockControllerConfig = mock(ControllerConfiguration.class); when(mockControllerConfig.getConfigurationService()).thenReturn(new BaseConfigurationService()); informerEventSource.setEventHandler(eventHandlerMock); informerEventSource.setControllerConfiguration(mockControllerConfig); - SecondaryToPrimaryMapper secondaryToPrimaryMapper = mock(SecondaryToPrimaryMapper.class); - when(informerEventSourceConfiguration.getSecondaryToPrimaryMapper()) - .thenReturn(secondaryToPrimaryMapper); - when(secondaryToPrimaryMapper.toPrimaryResourceIDs(any())) - .thenReturn(Set.of(ResourceID.fromResource(testDeployment()))); informerEventSource.start(); - informerEventSource.setTemporalResourceCache(temporaryResourceCacheMock); + informerEventSource.setTemporalResourceCache(temporaryResourceCache); } @Test - void skipsEventPropagationIfResourceWithSameVersionInResourceCache() { - when(temporaryResourceCacheMock.getResourceFromCache(any())) + void skipsEventPropagation() { + when(temporaryResourceCache.getResourceFromCache(any())) .thenReturn(Optional.of(testDeployment())); + when(temporaryResourceCache.onAddOrUpdateEvent(any(), any(), any())) + .thenReturn(EventHandling.OBSOLETE); + informerEventSource.onAdd(testDeployment()); informerEventSource.onUpdate(testDeployment(), testDeployment()); verify(eventHandlerMock, never()).handleEvent(any()); } - @Test - void skipsAddEventPropagationViaAnnotation() { - informerEventSource.onAdd(informerEventSource.addPreviousAnnotation(null, testDeployment())); - - verify(eventHandlerMock, never()).handleEvent(any()); - } - - @Test - void skipsUpdateEventPropagationViaAnnotation() { - informerEventSource.onUpdate( - testDeployment(), informerEventSource.addPreviousAnnotation("1", testDeployment())); - - verify(eventHandlerMock, never()).handleEvent(any()); - } - @Test void processEventPropagationWithoutAnnotation() { + when(temporaryResourceCache.onAddOrUpdateEvent(any(), any(), any())) + .thenReturn(EventHandling.NEW); informerEventSource.onUpdate(testDeployment(), testDeployment()); verify(eventHandlerMock, times(1)).handleEvent(any()); @@ -128,6 +128,8 @@ void processEventPropagationWithoutAnnotation() { @Test void processEventPropagationWithIncorrectAnnotation() { + when(temporaryResourceCache.onAddOrUpdateEvent(any(), any(), any())) + .thenReturn(EventHandling.NEW); informerEventSource.onAdd( new DeploymentBuilder(testDeployment()) .editMetadata() @@ -140,21 +142,22 @@ void processEventPropagationWithIncorrectAnnotation() { @Test void propagateEventAndRemoveResourceFromTempCacheIfResourceVersionMismatch() { + withRealTemporaryResourceCache(); + Deployment cachedDeployment = testDeployment(); cachedDeployment.getMetadata().setResourceVersion(PREV_RESOURCE_VERSION); - when(temporaryResourceCacheMock.getResourceFromCache(any())) - .thenReturn(Optional.of(cachedDeployment)); + temporaryResourceCache.putResource(cachedDeployment); informerEventSource.onUpdate(cachedDeployment, testDeployment()); verify(eventHandlerMock, times(1)).handleEvent(any()); - verify(temporaryResourceCacheMock, times(1)).onAddOrUpdateEvent(testDeployment()); + verify(temporaryResourceCache, times(1)).onAddOrUpdateEvent(any(), eq(testDeployment()), any()); } @Test void genericFilterForEvents() { informerEventSource.setGenericFilter(r -> false); - when(temporaryResourceCacheMock.getResourceFromCache(any())).thenReturn(Optional.empty()); + when(temporaryResourceCache.getResourceFromCache(any())).thenReturn(Optional.empty()); informerEventSource.onAdd(testDeployment()); informerEventSource.onUpdate(testDeployment(), testDeployment()); @@ -166,7 +169,7 @@ void genericFilterForEvents() { @Test void filtersOnAddEvents() { informerEventSource.setOnAddFilter(r -> false); - when(temporaryResourceCacheMock.getResourceFromCache(any())).thenReturn(Optional.empty()); + when(temporaryResourceCache.getResourceFromCache(any())).thenReturn(Optional.empty()); informerEventSource.onAdd(testDeployment()); @@ -176,7 +179,7 @@ void filtersOnAddEvents() { @Test void filtersOnUpdateEvents() { informerEventSource.setOnUpdateFilter((r1, r2) -> false); - when(temporaryResourceCacheMock.getResourceFromCache(any())).thenReturn(Optional.empty()); + when(temporaryResourceCache.getResourceFromCache(any())).thenReturn(Optional.empty()); informerEventSource.onUpdate(testDeployment(), testDeployment()); @@ -186,13 +189,201 @@ void filtersOnUpdateEvents() { @Test void filtersOnDeleteEvents() { informerEventSource.setOnDeleteFilter((r, b) -> false); - when(temporaryResourceCacheMock.getResourceFromCache(any())).thenReturn(Optional.empty()); + when(temporaryResourceCache.getResourceFromCache(any())).thenReturn(Optional.empty()); informerEventSource.onDelete(testDeployment(), true); verify(eventHandlerMock, never()).handleEvent(any()); } + @Test + void handlesPrevResourceVersionForUpdate() { + withRealTemporaryResourceCache(); + + CountDownLatch latch = sendForEventFilteringUpdate(2); + informerEventSource.onUpdate( + deploymentWithResourceVersion(2), deploymentWithResourceVersion(3)); + latch.countDown(); + + expectHandleEvent(3, 2); + } + + @Test + void handlesPrevResourceVersionForUpdateInCaseOfException() { + withRealTemporaryResourceCache(); + + CountDownLatch latch = + EventFilterTestUtils.sendForEventFilteringUpdate( + informerEventSource, + testDeployment(), + r -> { + throw new KubernetesClientException("fake"); + }); + informerEventSource.onUpdate( + deploymentWithResourceVersion(1), deploymentWithResourceVersion(2)); + latch.countDown(); + + expectHandleEvent(2, 1); + } + + @Test + void handlesPrevResourceVersionForUpdateInCaseOfMultipleUpdates() { + withRealTemporaryResourceCache(); + + var deployment = testDeployment(); + CountDownLatch latch = sendForEventFilteringUpdate(deployment, 2); + informerEventSource.onUpdate( + withResourceVersion(testDeployment(), 2), withResourceVersion(testDeployment(), 3)); + informerEventSource.onUpdate( + withResourceVersion(testDeployment(), 3), withResourceVersion(testDeployment(), 4)); + latch.countDown(); + + expectHandleEvent(4, 2); + } + + @Test + void doesNotPropagateEventIfReceivedBeforeUpdate() { + withRealTemporaryResourceCache(); + + CountDownLatch latch = sendForEventFilteringUpdate(2); + informerEventSource.onUpdate( + deploymentWithResourceVersion(1), deploymentWithResourceVersion(2)); + latch.countDown(); + + assertNoEventProduced(); + } + + @Test + void filterAddEventBeforeUpdate() { + withRealTemporaryResourceCache(); + + CountDownLatch latch = sendForEventFilteringUpdate(2); + informerEventSource.onAdd(deploymentWithResourceVersion(1)); + latch.countDown(); + + assertNoEventProduced(); + } + + @Test + void multipleCachingFilteringUpdates() { + withRealTemporaryResourceCache(); + CountDownLatch latch = sendForEventFilteringUpdate(2); + CountDownLatch latch2 = + sendForEventFilteringUpdate(withResourceVersion(testDeployment(), 2), 3); + + informerEventSource.onUpdate( + deploymentWithResourceVersion(1), deploymentWithResourceVersion(2)); + latch.countDown(); + latch2.countDown(); + informerEventSource.onUpdate( + deploymentWithResourceVersion(2), deploymentWithResourceVersion(3)); + + assertNoEventProduced(); + } + + @Test + void multipleCachingFilteringUpdates_variant2() { + withRealTemporaryResourceCache(); + + CountDownLatch latch = sendForEventFilteringUpdate(2); + CountDownLatch latch2 = + sendForEventFilteringUpdate(withResourceVersion(testDeployment(), 2), 3); + + informerEventSource.onUpdate( + deploymentWithResourceVersion(1), deploymentWithResourceVersion(2)); + latch.countDown(); + informerEventSource.onUpdate( + deploymentWithResourceVersion(2), deploymentWithResourceVersion(3)); + latch2.countDown(); + + assertNoEventProduced(); + } + + @Test + void multipleCachingFilteringUpdates_variant3() { + withRealTemporaryResourceCache(); + + CountDownLatch latch = sendForEventFilteringUpdate(2); + CountDownLatch latch2 = + sendForEventFilteringUpdate(withResourceVersion(testDeployment(), 2), 3); + + latch.countDown(); + informerEventSource.onUpdate( + deploymentWithResourceVersion(1), deploymentWithResourceVersion(2)); + informerEventSource.onUpdate( + deploymentWithResourceVersion(2), deploymentWithResourceVersion(3)); + latch2.countDown(); + + assertNoEventProduced(); + } + + @Test + void multipleCachingFilteringUpdates_variant4() { + withRealTemporaryResourceCache(); + + CountDownLatch latch = sendForEventFilteringUpdate(2); + CountDownLatch latch2 = + sendForEventFilteringUpdate(withResourceVersion(testDeployment(), 2), 3); + + informerEventSource.onUpdate( + deploymentWithResourceVersion(1), deploymentWithResourceVersion(2)); + informerEventSource.onUpdate( + deploymentWithResourceVersion(2), deploymentWithResourceVersion(3)); + latch.countDown(); + latch2.countDown(); + + assertNoEventProduced(); + } + + private void assertNoEventProduced() { + await() + .pollDelay(Duration.ofMillis(50)) + .timeout(Duration.ofMillis(51)) + .untilAsserted( + () -> verify(informerEventSource, never()).handleEvent(any(), any(), any(), any())); + } + + private void expectHandleEvent(int newResourceVersion, int oldResourceVersion) { + await() + .untilAsserted( + () -> { + verify(informerEventSource, times(1)) + .handleEvent( + eq(ResourceAction.UPDATED), + argThat( + newResource -> { + assertThat(newResource.getMetadata().getResourceVersion()) + .isEqualTo("" + newResourceVersion); + return true; + }), + argThat( + newResource -> { + assertThat(newResource.getMetadata().getResourceVersion()) + .isEqualTo("" + oldResourceVersion); + return true; + }), + isNull()); + }); + } + + private CountDownLatch sendForEventFilteringUpdate(int resourceVersion) { + return sendForEventFilteringUpdate(testDeployment(), resourceVersion); + } + + private CountDownLatch sendForEventFilteringUpdate(Deployment deployment, int resourceVersion) { + return EventFilterTestUtils.sendForEventFilteringUpdate( + informerEventSource, deployment, r -> withResourceVersion(deployment, resourceVersion)); + } + + private void withRealTemporaryResourceCache() { + temporaryResourceCache = spy(new TemporaryResourceCache<>(true)); + informerEventSource.setTemporalResourceCache(temporaryResourceCache); + } + + Deployment deploymentWithResourceVersion(int resourceVersion) { + return withResourceVersion(testDeployment(), resourceVersion); + } + @Test void informerStoppedHandlerShouldBeCalledWhenInformerStops() { final var exception = new RuntimeException("Informer stopped exceptionally!"); diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryPrimaryResourceCacheTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryPrimaryResourceCacheTest.java index e3dc2c82e4..592a552433 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryPrimaryResourceCacheTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryPrimaryResourceCacheTest.java @@ -16,10 +16,7 @@ package io.javaoperatorsdk.operator.processing.event.source.informer; import java.util.Map; -import java.util.Optional; -import java.util.concurrent.TimeUnit; -import org.awaitility.Awaitility; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -27,49 +24,45 @@ import io.fabric8.kubernetes.api.model.ConfigMapBuilder; import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; import io.javaoperatorsdk.operator.processing.event.ResourceID; -import io.javaoperatorsdk.operator.processing.event.source.informer.TemporaryResourceCache.ExpirationCache; +import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; +import io.javaoperatorsdk.operator.processing.event.source.informer.TemporaryResourceCache.EventHandling; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; +import static org.junit.jupiter.api.Assertions.assertTrue; class TemporaryPrimaryResourceCacheTest { public static final String RESOURCE_VERSION = "2"; - @SuppressWarnings("unchecked") - private InformerEventSource informerEventSource; - private TemporaryResourceCache temporaryResourceCache; @BeforeEach void setup() { - informerEventSource = mock(InformerEventSource.class); - temporaryResourceCache = new TemporaryResourceCache<>(informerEventSource, false); + temporaryResourceCache = new TemporaryResourceCache<>(true); } @Test void updateAddsTheResourceIntoCacheIfTheInformerHasThePreviousResourceVersion() { var testResource = testResource(); var prevTestResource = testResource(); - prevTestResource.getMetadata().setResourceVersion("0"); - when(informerEventSource.get(any())).thenReturn(Optional.of(prevTestResource)); + prevTestResource.getMetadata().setResourceVersion("1"); - temporaryResourceCache.putResource(testResource, "0"); + temporaryResourceCache.putResource(testResource); var cached = temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource)); assertThat(cached).isPresent(); } @Test - void updateNotAddsTheResourceIntoCacheIfTheInformerHasOtherVersion() { + void updateNotAddsTheResourceIntoCacheIfLaterVersionKnown() { var testResource = testResource(); - var informerCachedResource = testResource(); - informerCachedResource.getMetadata().setResourceVersion("x"); - when(informerEventSource.get(any())).thenReturn(Optional.of(informerCachedResource)); - temporaryResourceCache.putResource(testResource, "0"); + temporaryResourceCache.onAddOrUpdateEvent( + ResourceAction.ADDED, + testResource.toBuilder().editMetadata().withResourceVersion("3").endMetadata().build(), + null); + + temporaryResourceCache.putResource(testResource); var cached = temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource)); assertThat(cached).isNotPresent(); @@ -78,9 +71,8 @@ void updateNotAddsTheResourceIntoCacheIfTheInformerHasOtherVersion() { @Test void addOperationAddsTheResourceIfInformerCacheStillEmpty() { var testResource = testResource(); - when(informerEventSource.get(any())).thenReturn(Optional.empty()); - temporaryResourceCache.putAddedResource(testResource); + temporaryResourceCache.putResource(testResource); var cached = temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource)); assertThat(cached).isPresent(); @@ -89,99 +81,222 @@ void addOperationAddsTheResourceIfInformerCacheStillEmpty() { @Test void addOperationNotAddsTheResourceIfInformerCacheNotEmpty() { var testResource = testResource(); - when(informerEventSource.get(any())).thenReturn(Optional.of(testResource())); - temporaryResourceCache.putAddedResource(testResource); + temporaryResourceCache.putResource(testResource); + + temporaryResourceCache.putResource( + new ConfigMapBuilder(testResource) + .editMetadata() + .withResourceVersion("1") + .endMetadata() + .build()); var cached = temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource)); - assertThat(cached).isNotPresent(); + assertThat(cached.orElseThrow().getMetadata().getResourceVersion()).isEqualTo(RESOURCE_VERSION); } @Test void removesResourceFromCache() { ConfigMap testResource = propagateTestResourceToCache(); - temporaryResourceCache.onAddOrUpdateEvent(testResource()); + temporaryResourceCache.onAddOrUpdateEvent( + ResourceAction.ADDED, + new ConfigMapBuilder(testResource) + .editMetadata() + .withResourceVersion("3") + .endMetadata() + .build(), + null); assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) .isNotPresent(); } @Test - void resourceVersionParsing() { - this.temporaryResourceCache = new TemporaryResourceCache<>(informerEventSource, true); + void nonComparableResourceVersionsDisables() { + this.temporaryResourceCache = new TemporaryResourceCache<>(false); - ConfigMap testResource = propagateTestResourceToCache(); + this.temporaryResourceCache.putResource(testResource()); - // an event with a newer version will not remove - temporaryResourceCache.onAddOrUpdateEvent( - new ConfigMapBuilder(testResource) - .editMetadata() - .withResourceVersion("1") - .endMetadata() - .build()); + assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource()))) + .isEmpty(); + } + @Test + void eventReceivedDuringFiltering() throws Exception { + var testResource = testResource(); + + temporaryResourceCache.startEventFilteringModify(ResourceID.fromResource(testResource)); + + temporaryResourceCache.putResource(testResource); assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) .isPresent(); - // anything else will remove - temporaryResourceCache.onAddOrUpdateEvent(testResource()); + temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.ADDED, testResource, null); + assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) + .isEmpty(); + + var doneRes = + temporaryResourceCache.doneEventFilterModify(ResourceID.fromResource(testResource), "2"); + assertThat(doneRes).isEmpty(); assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) - .isNotPresent(); + .isEmpty(); } @Test - void rapidDeletion() { + void newerEventDuringFiltering() { var testResource = testResource(); - temporaryResourceCache.onAddOrUpdateEvent(testResource); - temporaryResourceCache.onDeleteEvent( - new ConfigMapBuilder(testResource) - .editMetadata() - .withResourceVersion("3") - .endMetadata() - .build(), - false); - temporaryResourceCache.putAddedResource(testResource); + temporaryResourceCache.startEventFilteringModify(ResourceID.fromResource(testResource)); + + temporaryResourceCache.putResource(testResource); + assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) + .isPresent(); + + var testResource2 = testResource(); + testResource2.getMetadata().setResourceVersion("3"); + temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.UPDATED, testResource2, testResource); + assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) + .isEmpty(); + + var doneRes = + temporaryResourceCache.doneEventFilterModify(ResourceID.fromResource(testResource), "2"); + assertThat(doneRes).isPresent(); assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) .isEmpty(); } @Test - void expirationCacheMax() { - ExpirationCache cache = new ExpirationCache<>(2, Integer.MAX_VALUE); + void eventAfterFiltering() { + var testResource = testResource(); + + temporaryResourceCache.startEventFilteringModify(ResourceID.fromResource(testResource)); + + temporaryResourceCache.putResource(testResource); + assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) + .isPresent(); + + var doneRes = + temporaryResourceCache.doneEventFilterModify(ResourceID.fromResource(testResource), "2"); + + assertThat(doneRes).isEmpty(); + assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) + .isPresent(); + + temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.ADDED, testResource, null); + assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) + .isEmpty(); + } + + @Test + void putBeforeEvent() { + var testResource = testResource(); + + // first ensure an event is not known + var result = + temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.ADDED, testResource, null); + assertThat(result).isEqualTo(EventHandling.NEW); + + var nextResource = testResource(); + nextResource.getMetadata().setResourceVersion("3"); + temporaryResourceCache.putResource(nextResource); + + // the result is obsolete + result = temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.UPDATED, nextResource, null); + assertThat(result).isEqualTo(EventHandling.OBSOLETE); + } + + @Test + void putBeforeEventWithEventFiltering() { + var testResource = testResource(); + + // first ensure an event is not known + var result = + temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.ADDED, testResource, null); + assertThat(result).isEqualTo(EventHandling.NEW); - cache.add(1); - cache.add(2); - cache.add(3); + var nextResource = testResource(); + nextResource.getMetadata().setResourceVersion("3"); + var resourceId = ResourceID.fromResource(testResource); - assertThat(cache.contains(1)).isFalse(); - assertThat(cache.contains(2)).isTrue(); - assertThat(cache.contains(3)).isTrue(); + temporaryResourceCache.startEventFilteringModify(resourceId); + temporaryResourceCache.putResource(nextResource); + temporaryResourceCache.doneEventFilterModify(resourceId, "3"); + + // the result is obsolete + result = temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.UPDATED, nextResource, null); + assertThat(result).isEqualTo(EventHandling.OBSOLETE); + } + + @Test + void putAfterEventWithEventFilteringNoPost() { + var testResource = testResource(); + + // first ensure an event is not known + var result = + temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.ADDED, testResource, null); + assertThat(result).isEqualTo(EventHandling.NEW); + + var nextResource = testResource(); + nextResource.getMetadata().setResourceVersion("3"); + var resourceId = ResourceID.fromResource(testResource); + + temporaryResourceCache.startEventFilteringModify(resourceId); + result = + temporaryResourceCache.onAddOrUpdateEvent( + ResourceAction.UPDATED, nextResource, testResource); + // the result is deferred + assertThat(result).isEqualTo(EventHandling.DEFER); + temporaryResourceCache.putResource(nextResource); + var postEvent = temporaryResourceCache.doneEventFilterModify(resourceId, "3"); + + // there is no post event because the done call claimed responsibility for rv 3 + assertTrue(postEvent.isEmpty()); + } + + @Test + void putAfterEventWithEventFilteringWithPost() { + var testResource = testResource(); + var resourceId = ResourceID.fromResource(testResource); + temporaryResourceCache.startEventFilteringModify(resourceId); + + // this should be a corner case - watch had a hard reset since the start of the + // of the update operation, such that 4 rv event is seen prior to the update + // completing with the 3 rv. + var nextResource = testResource(); + nextResource.getMetadata().setResourceVersion("4"); + var result = + temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.ADDED, nextResource, null); + assertThat(result).isEqualTo(EventHandling.DEFER); + + var postEvent = temporaryResourceCache.doneEventFilterModify(resourceId, "3"); + + assertTrue(postEvent.isPresent()); } @Test - void expirationCacheTtl() { - ExpirationCache cache = new ExpirationCache<>(2, 1); + void rapidDeletion() { + var testResource = testResource(); - cache.add(1); - cache.add(2); + temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.ADDED, testResource, null); + temporaryResourceCache.onDeleteEvent( + new ConfigMapBuilder(testResource) + .editMetadata() + .withResourceVersion("3") + .endMetadata() + .build(), + false); + temporaryResourceCache.putResource(testResource); - Awaitility.await() - .atMost(1, TimeUnit.SECONDS) - .untilAsserted( - () -> { - assertThat(cache.contains(1)).isFalse(); - assertThat(cache.contains(2)).isFalse(); - }); + assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) + .isEmpty(); } private ConfigMap propagateTestResourceToCache() { var testResource = testResource(); - when(informerEventSource.get(any())).thenReturn(Optional.empty()); - temporaryResourceCache.putAddedResource(testResource); + temporaryResourceCache.putResource(testResource); assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) .isPresent(); return testResource; diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/timer/TimerEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/timer/TimerEventSourceTest.java index f444a5e2ba..3a4e1cb80d 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/timer/TimerEventSourceTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/timer/TimerEventSourceTest.java @@ -26,6 +26,7 @@ import org.junit.jupiter.api.Test; import io.javaoperatorsdk.operator.TestUtils; +import io.javaoperatorsdk.operator.api.reconciler.BaseControl; import io.javaoperatorsdk.operator.health.Status; import io.javaoperatorsdk.operator.processing.event.Event; import io.javaoperatorsdk.operator.processing.event.EventHandler; @@ -115,6 +116,15 @@ public void eventNotFiredIfStopped() { assertThat(source.getStatus()).isEqualTo(Status.UNHEALTHY); } + @Test + public void handlesInstanceReschedule() { + var resourceID = ResourceID.fromResource(TestUtils.testCustomResource()); + + source.scheduleOnce(resourceID, BaseControl.INSTANT_RESCHEDULE); + + assertThat(eventHandler.events).hasSize(1); + } + private void untilAsserted(ThrowingRunnable assertion) { untilAsserted(INITIAL_DELAY, PERIOD, assertion); } diff --git a/operator-framework-core/src/test/resources/log4j2.xml b/operator-framework-core/src/test/resources/log4j2.xml index be03b531ac..6c2aa05616 100644 --- a/operator-framework-core/src/test/resources/log4j2.xml +++ b/operator-framework-core/src/test/resources/log4j2.xml @@ -19,7 +19,7 @@ - + diff --git a/operator-framework-junit5/pom.xml b/operator-framework-junit/pom.xml similarity index 92% rename from operator-framework-junit5/pom.xml rename to operator-framework-junit/pom.xml index 9696bea8fc..aa18d5c778 100644 --- a/operator-framework-junit5/pom.xml +++ b/operator-framework-junit/pom.xml @@ -21,11 +21,11 @@ io.javaoperatorsdk java-operator-sdk - 5.2.4-SNAPSHOT + 999-SNAPSHOT - operator-framework-junit-5 - Operator SDK - Framework - JUnit 5 extension + operator-framework-junit + Operator SDK - Framework - JUnit extension diff --git a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/AbstractOperatorExtension.java b/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/AbstractOperatorExtension.java similarity index 89% rename from operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/AbstractOperatorExtension.java rename to operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/AbstractOperatorExtension.java index eceb6d9d76..0609850713 100644 --- a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/AbstractOperatorExtension.java +++ b/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/AbstractOperatorExtension.java @@ -58,6 +58,7 @@ public abstract class AbstractOperatorExtension protected Duration infrastructureTimeout; protected final boolean oneNamespacePerClass; protected final boolean preserveNamespaceOnError; + protected final boolean skipNamespaceDeletion; protected final boolean waitForNamespaceDeletion; protected final int namespaceDeleteTimeout = DEFAULT_NAMESPACE_DELETE_TIMEOUT; protected final Function namespaceNameSupplier; @@ -70,6 +71,7 @@ protected AbstractOperatorExtension( Duration infrastructureTimeout, boolean oneNamespacePerClass, boolean preserveNamespaceOnError, + boolean skipNamespaceDeletion, boolean waitForNamespaceDeletion, KubernetesClient kubernetesClient, KubernetesClient infrastructureKubernetesClient, @@ -85,6 +87,7 @@ protected AbstractOperatorExtension( this.infrastructureTimeout = infrastructureTimeout; this.oneNamespacePerClass = oneNamespacePerClass; this.preserveNamespaceOnError = preserveNamespaceOnError; + this.skipNamespaceDeletion = skipNamespaceDeletion; this.waitForNamespaceDeletion = waitForNamespaceDeletion; this.namespaceNameSupplier = namespaceNameSupplier; this.perClassNamespaceNameSupplier = perClassNamespaceNameSupplier; @@ -202,19 +205,22 @@ protected void after(ExtensionContext context) { if (preserveNamespaceOnError && context.getExecutionException().isPresent()) { LOGGER.info("Preserving namespace {}", namespace); } else { + LOGGER.info("Deleting infrastructure resources and operator in namespace {}", namespace); infrastructureKubernetesClient.resourceList(infrastructure).delete(); deleteOperator(); - LOGGER.info("Deleting namespace {} and stopping operator", namespace); - infrastructureKubernetesClient.namespaces().withName(namespace).delete(); - if (waitForNamespaceDeletion) { - LOGGER.info("Waiting for namespace {} to be deleted", namespace); - Awaitility.await("namespace deleted") - .pollInterval(50, TimeUnit.MILLISECONDS) - .atMost(namespaceDeleteTimeout, TimeUnit.SECONDS) - .until( - () -> - infrastructureKubernetesClient.namespaces().withName(namespace).get() - == null); + if (!skipNamespaceDeletion) { + LOGGER.info("Deleting namespace {}", namespace); + infrastructureKubernetesClient.namespaces().withName(namespace).delete(); + if (waitForNamespaceDeletion) { + LOGGER.info("Waiting for namespace {} to be deleted", namespace); + Awaitility.await("namespace deleted") + .pollInterval(50, TimeUnit.MILLISECONDS) + .atMost(namespaceDeleteTimeout, TimeUnit.SECONDS) + .until( + () -> + infrastructureKubernetesClient.namespaces().withName(namespace).get() + == null); + } } } } @@ -229,6 +235,7 @@ public abstract static class AbstractBuilder> { protected final List infrastructure; protected Duration infrastructureTimeout; protected boolean preserveNamespaceOnError; + protected boolean skipNamespaceDeletion; protected boolean waitForNamespaceDeletion; protected boolean oneNamespacePerClass; protected int namespaceDeleteTimeout; @@ -245,6 +252,9 @@ protected AbstractBuilder() { this.preserveNamespaceOnError = Utils.getSystemPropertyOrEnvVar("josdk.it.preserveNamespaceOnError", false); + this.skipNamespaceDeletion = + Utils.getSystemPropertyOrEnvVar("josdk.it.skipNamespaceDeletion", false); + this.waitForNamespaceDeletion = Utils.getSystemPropertyOrEnvVar("josdk.it.waitForNamespaceDeletion", true); @@ -261,6 +271,11 @@ public T preserveNamespaceOnError(boolean value) { return (T) this; } + public T skipNamespaceDeletion(boolean value) { + this.skipNamespaceDeletion = value; + return (T) this; + } + public T waitForNamespaceDeletion(boolean value) { this.waitForNamespaceDeletion = value; return (T) this; diff --git a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/ClusterDeployedOperatorExtension.java b/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/ClusterDeployedOperatorExtension.java similarity index 98% rename from operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/ClusterDeployedOperatorExtension.java rename to operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/ClusterDeployedOperatorExtension.java index 2f134fa5ff..bcca851afe 100644 --- a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/ClusterDeployedOperatorExtension.java +++ b/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/ClusterDeployedOperatorExtension.java @@ -51,6 +51,7 @@ private ClusterDeployedOperatorExtension( List infrastructure, Duration infrastructureTimeout, boolean preserveNamespaceOnError, + boolean skipNamespaceDeletion, boolean waitForNamespaceDeletion, boolean oneNamespacePerClass, KubernetesClient kubernetesClient, @@ -62,6 +63,7 @@ private ClusterDeployedOperatorExtension( infrastructureTimeout, oneNamespacePerClass, preserveNamespaceOnError, + skipNamespaceDeletion, waitForNamespaceDeletion, kubernetesClient, infrastructureKubernetesClient, @@ -189,6 +191,7 @@ public ClusterDeployedOperatorExtension build() { infrastructure, infrastructureTimeout, preserveNamespaceOnError, + skipNamespaceDeletion, waitForNamespaceDeletion, oneNamespacePerClass, kubernetesClient, diff --git a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/DefaultNamespaceNameSupplier.java b/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/DefaultNamespaceNameSupplier.java similarity index 100% rename from operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/DefaultNamespaceNameSupplier.java rename to operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/DefaultNamespaceNameSupplier.java diff --git a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/DefaultPerClassNamespaceNameSupplier.java b/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/DefaultPerClassNamespaceNameSupplier.java similarity index 100% rename from operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/DefaultPerClassNamespaceNameSupplier.java rename to operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/DefaultPerClassNamespaceNameSupplier.java diff --git a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/HasKubernetesClient.java b/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/HasKubernetesClient.java similarity index 100% rename from operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/HasKubernetesClient.java rename to operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/HasKubernetesClient.java diff --git a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/InClusterCurl.java b/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/InClusterCurl.java similarity index 100% rename from operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/InClusterCurl.java rename to operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/InClusterCurl.java diff --git a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java b/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java similarity index 96% rename from operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java rename to operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java index 5ea82026c3..cd26234054 100644 --- a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java +++ b/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java @@ -44,7 +44,7 @@ import io.fabric8.kubernetes.client.KubernetesClient; import io.fabric8.kubernetes.client.LocalPortForward; import io.javaoperatorsdk.operator.Operator; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.RegisteredController; import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; import io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider; @@ -80,6 +80,7 @@ private LocallyRunOperatorExtension( List additionalCustomResourceDefinitionInstances, Duration infrastructureTimeout, boolean preserveNamespaceOnError, + boolean skipNamespaceDeletion, boolean waitForNamespaceDeletion, boolean oneNamespacePerClass, KubernetesClient kubernetesClient, @@ -94,6 +95,7 @@ private LocallyRunOperatorExtension( infrastructureTimeout, oneNamespacePerClass, preserveNamespaceOnError, + skipNamespaceDeletion, waitForNamespaceDeletion, kubernetesClient, infrastructureKubernetesClient, @@ -143,7 +145,7 @@ public static Builder builder() { } public static void applyCrd(Class resourceClass, KubernetesClient client) { - applyCrd(ReconcilerUtils.getResourceTypeName(resourceClass), client); + applyCrd(ReconcilerUtilsInternal.getResourceTypeName(resourceClass), client); } /** @@ -195,7 +197,7 @@ private static void applyCrd(String crdString, String path, KubernetesClient cli * @param crClass the custom resource class for which we want to apply the CRD */ public void applyCrd(Class crClass) { - applyCrd(ReconcilerUtils.getResourceTypeName(crClass)); + applyCrd(ReconcilerUtilsInternal.getResourceTypeName(crClass)); } public void applyCrd(CustomResourceDefinition customResourceDefinition) { @@ -233,7 +235,7 @@ private void applyCrdFromMappings(String pathAsString, String resourceTypeName) * * @param resourceTypeName the resource type name associated with the CRD to be applied, * typically, given a resource type, its name would be obtained using {@link - * ReconcilerUtils#getResourceTypeName(Class)} + * ReconcilerUtilsInternal#getResourceTypeName(Class)} */ public void applyCrd(String resourceTypeName) { // first attempt to use a manually defined CRD @@ -321,7 +323,7 @@ protected void before(ExtensionContext context) { ref.controllerConfigurationOverrider.accept(oconfig); } - final var resourceTypeName = ReconcilerUtils.getResourceTypeName(resourceClass); + final var resourceTypeName = ReconcilerUtilsInternal.getResourceTypeName(resourceClass); // only try to apply a CRD for the reconciler if it is associated to a CR if (CustomResource.class.isAssignableFrom(resourceClass)) { applyCrd(resourceTypeName); @@ -363,7 +365,11 @@ protected void after(ExtensionContext context) { iterator.remove(); } - kubernetesClient.close(); + // if the client is used for infra client, we should not close it + // either test or operator should close this client + if (getKubernetesClient() != getInfrastructureKubernetesClient()) { + kubernetesClient.close(); + } try { this.operator.stop(); @@ -541,6 +547,7 @@ public LocallyRunOperatorExtension build() { additionalCustomResourceDefinitionInstances, infrastructureTimeout, preserveNamespaceOnError, + skipNamespaceDeletion, waitForNamespaceDeletion, oneNamespacePerClass, kubernetesClient, diff --git a/operator-framework-junit5/src/test/crd/test.crd b/operator-framework-junit/src/test/crd/test.crd similarity index 100% rename from operator-framework-junit5/src/test/crd/test.crd rename to operator-framework-junit/src/test/crd/test.crd diff --git a/operator-framework-junit5/src/test/java/io/javaoperatorsdk/operator/junit/DefaultNamespaceNameSupplierTest.java b/operator-framework-junit/src/test/java/io/javaoperatorsdk/operator/junit/DefaultNamespaceNameSupplierTest.java similarity index 100% rename from operator-framework-junit5/src/test/java/io/javaoperatorsdk/operator/junit/DefaultNamespaceNameSupplierTest.java rename to operator-framework-junit/src/test/java/io/javaoperatorsdk/operator/junit/DefaultNamespaceNameSupplierTest.java diff --git a/operator-framework-junit5/src/test/java/io/javaoperatorsdk/operator/junit/DefaultPerClassNamespaceNameSupplierTest.java b/operator-framework-junit/src/test/java/io/javaoperatorsdk/operator/junit/DefaultPerClassNamespaceNameSupplierTest.java similarity index 100% rename from operator-framework-junit5/src/test/java/io/javaoperatorsdk/operator/junit/DefaultPerClassNamespaceNameSupplierTest.java rename to operator-framework-junit/src/test/java/io/javaoperatorsdk/operator/junit/DefaultPerClassNamespaceNameSupplierTest.java diff --git a/operator-framework-junit/src/test/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtensionIT.java b/operator-framework-junit/src/test/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtensionIT.java new file mode 100644 index 0000000000..3e1a4f9b14 --- /dev/null +++ b/operator-framework-junit/src/test/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtensionIT.java @@ -0,0 +1,15 @@ +/* + * 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. + */ diff --git a/operator-framework-junit/src/test/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtensionTest.java b/operator-framework-junit/src/test/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtensionTest.java new file mode 100644 index 0000000000..3e1a4f9b14 --- /dev/null +++ b/operator-framework-junit/src/test/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtensionTest.java @@ -0,0 +1,15 @@ +/* + * 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. + */ diff --git a/operator-framework-junit5/src/test/java/io/javaoperatorsdk/operator/junit/NamespaceNamingTestUtils.java b/operator-framework-junit/src/test/java/io/javaoperatorsdk/operator/junit/NamespaceNamingTestUtils.java similarity index 100% rename from operator-framework-junit5/src/test/java/io/javaoperatorsdk/operator/junit/NamespaceNamingTestUtils.java rename to operator-framework-junit/src/test/java/io/javaoperatorsdk/operator/junit/NamespaceNamingTestUtils.java diff --git a/operator-framework-junit5/src/test/resources/crd/test.crd b/operator-framework-junit/src/test/resources/crd/test.crd similarity index 100% rename from operator-framework-junit5/src/test/resources/crd/test.crd rename to operator-framework-junit/src/test/resources/crd/test.crd diff --git a/operator-framework-junit5/src/test/resources/log4j2.xml b/operator-framework-junit/src/test/resources/log4j2.xml similarity index 100% rename from operator-framework-junit5/src/test/resources/log4j2.xml rename to operator-framework-junit/src/test/resources/log4j2.xml diff --git a/operator-framework-junit5/src/test/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtensionTest.java b/operator-framework-junit5/src/test/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtensionTest.java deleted file mode 100644 index 9491dedf6e..0000000000 --- a/operator-framework-junit5/src/test/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtensionTest.java +++ /dev/null @@ -1,84 +0,0 @@ -/* - * 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.junit; - -import java.nio.file.Path; -import java.util.List; - -import org.junit.jupiter.api.Test; - -import io.fabric8.kubernetes.client.KubernetesClientBuilder; - -import static org.junit.jupiter.api.Assertions.*; - -class LocallyRunOperatorExtensionTest { - - @Test - void getAdditionalCRDsFromFiles() { - System.out.println(Path.of("").toAbsolutePath()); - System.out.println(Path.of("src/test/crd/test.crd").toAbsolutePath()); - final var crds = - LocallyRunOperatorExtension.getAdditionalCRDsFromFiles( - List.of("src/test/resources/crd/test.crd", "src/test/crd/test.crd"), - new KubernetesClientBuilder().build()); - assertNotNull(crds); - assertEquals(2, crds.size()); - assertEquals("src/test/crd/test.crd", crds.get("externals.crd.example")); - assertEquals("src/test/resources/crd/test.crd", crds.get("tests.crd.example")); - } - - @Test - void overrideInfrastructureAndUserKubernetesClient() { - var infrastructureClient = new KubernetesClientBuilder().build(); - var userKubernetesClient = new KubernetesClientBuilder().build(); - - LocallyRunOperatorExtension extension = - LocallyRunOperatorExtension.builder() - .withInfrastructureKubernetesClient(infrastructureClient) - .withKubernetesClient(userKubernetesClient) - .build(); - - assertEquals(infrastructureClient, extension.getInfrastructureKubernetesClient()); - assertEquals(userKubernetesClient, extension.getKubernetesClient()); - assertNotEquals(extension.getInfrastructureKubernetesClient(), extension.getKubernetesClient()); - } - - @Test - void overrideInfrastructureAndVerifyUserKubernetesClientIsTheSame() { - var infrastructureClient = new KubernetesClientBuilder().build(); - - LocallyRunOperatorExtension extension = - LocallyRunOperatorExtension.builder() - .withInfrastructureKubernetesClient(infrastructureClient) - .build(); - - assertEquals(infrastructureClient, extension.getInfrastructureKubernetesClient()); - assertEquals(infrastructureClient, extension.getKubernetesClient()); - assertEquals(extension.getInfrastructureKubernetesClient(), extension.getKubernetesClient()); - } - - @Test - void overrideKubernetesClientAndVerifyInfrastructureClientIsTheSame() { - var userKubernetesClient = new KubernetesClientBuilder().build(); - - LocallyRunOperatorExtension extension = - LocallyRunOperatorExtension.builder().withKubernetesClient(userKubernetesClient).build(); - - assertEquals(userKubernetesClient, extension.getKubernetesClient()); - assertEquals(userKubernetesClient, extension.getInfrastructureKubernetesClient()); - assertEquals(extension.getKubernetesClient(), extension.getInfrastructureKubernetesClient()); - } -} diff --git a/operator-framework/pom.xml b/operator-framework/pom.xml index bef52336b0..f94dfa757d 100644 --- a/operator-framework/pom.xml +++ b/operator-framework/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.2.4-SNAPSHOT + 999-SNAPSHOT operator-framework @@ -92,7 +92,7 @@ io.javaoperatorsdk - operator-framework-junit-5 + operator-framework-junit ${project.version} test diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigBinding.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigBinding.java new file mode 100644 index 0000000000..7cb508b2f1 --- /dev/null +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigBinding.java @@ -0,0 +1,50 @@ +/* + * 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.config.loader; + +import java.util.function.BiConsumer; + +/** + * Associates a configuration key and its expected type with the setter that should be called on an + * overrider when the {@link ConfigProvider} returns a value for that key. + * + * @param the overrider type (e.g. {@code ConfigurationServiceOverrider}) + * @param the value type expected for this key + */ +public class ConfigBinding { + + private final String key; + private final Class type; + private final BiConsumer setter; + + public ConfigBinding(String key, Class type, BiConsumer setter) { + this.key = key; + this.type = type; + this.setter = setter; + } + + public String key() { + return key; + } + + public Class type() { + return type; + } + + public BiConsumer setter() { + return setter; + } +} diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java new file mode 100644 index 0000000000..d46a6116d7 --- /dev/null +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java @@ -0,0 +1,383 @@ +/* + * 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.config.loader; + +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; +import io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider; +import io.javaoperatorsdk.operator.api.config.LeaderElectionConfigurationBuilder; +import io.javaoperatorsdk.operator.config.loader.provider.AgregatePriorityListConfigProvider; +import io.javaoperatorsdk.operator.config.loader.provider.EnvVarConfigProvider; +import io.javaoperatorsdk.operator.config.loader.provider.PropertiesConfigProvider; +import io.javaoperatorsdk.operator.processing.event.rate.LinearRateLimiter; +import io.javaoperatorsdk.operator.processing.retry.GenericRetry; + +public class ConfigLoader { + + private static final Logger log = LoggerFactory.getLogger(ConfigLoader.class); + + private static final ConfigLoader DEFAULT = new ConfigLoader(); + + public static ConfigLoader getDefault() { + return DEFAULT; + } + + public static final String DEFAULT_OPERATOR_KEY_PREFIX = "josdk."; + public static final String DEFAULT_CONTROLLER_KEY_PREFIX = "josdk.controller."; + + /** + * Key prefix for controller-level properties. The controller name is inserted between this prefix + * and the property name, e.g. {@code josdk.controller.my-controller.finalizer}. + */ + private final String controllerKeyPrefix; + + private final String operatorKeyPrefix; + + // --------------------------------------------------------------------------- + // Operator-level (ConfigurationServiceOverrider) bindings + // Only scalar / value types that a key-value ConfigProvider can supply are + // included. Complex objects (KubernetesClient, ExecutorService, …) must be + // configured programmatically and are intentionally omitted. + // --------------------------------------------------------------------------- + static final List> OPERATOR_BINDINGS = + List.of( + new ConfigBinding<>( + "check-crd", + Boolean.class, + ConfigurationServiceOverrider::checkingCRDAndValidateLocalModel), + new ConfigBinding<>( + "reconciliation.termination-timeout", + Duration.class, + ConfigurationServiceOverrider::withReconciliationTerminationTimeout), + new ConfigBinding<>( + "reconciliation.concurrent-threads", + Integer.class, + ConfigurationServiceOverrider::withConcurrentReconciliationThreads), + new ConfigBinding<>( + "workflow.executor-threads", + Integer.class, + ConfigurationServiceOverrider::withConcurrentWorkflowExecutorThreads), + new ConfigBinding<>( + "close-client-on-stop", + Boolean.class, + ConfigurationServiceOverrider::withCloseClientOnStop), + new ConfigBinding<>( + "informer.stop-on-error-during-startup", + Boolean.class, + ConfigurationServiceOverrider::withStopOnInformerErrorDuringStartup), + new ConfigBinding<>( + "informer.cache-sync-timeout", + Duration.class, + ConfigurationServiceOverrider::withCacheSyncTimeout), + new ConfigBinding<>( + "dependent-resources.ssa-based-create-update-match", + Boolean.class, + ConfigurationServiceOverrider::withSSABasedCreateUpdateMatchForDependentResources), + new ConfigBinding<>( + "use-ssa-to-patch-primary-resource", + Boolean.class, + ConfigurationServiceOverrider::withUseSSAToPatchPrimaryResource), + new ConfigBinding<>( + "clone-secondary-resources-when-getting-from-cache", + Boolean.class, + ConfigurationServiceOverrider::withCloneSecondaryResourcesWhenGettingFromCache)); + + // --------------------------------------------------------------------------- + // Operator-level leader-election property keys + // --------------------------------------------------------------------------- + static final String LEADER_ELECTION_ENABLED_KEY = "leader-election.enabled"; + static final String LEADER_ELECTION_LEASE_NAME_KEY = "leader-election.lease-name"; + static final String LEADER_ELECTION_LEASE_NAMESPACE_KEY = "leader-election.lease-namespace"; + static final String LEADER_ELECTION_IDENTITY_KEY = "leader-election.identity"; + static final String LEADER_ELECTION_LEASE_DURATION_KEY = "leader-election.lease-duration"; + static final String LEADER_ELECTION_RENEW_DEADLINE_KEY = "leader-election.renew-deadline"; + static final String LEADER_ELECTION_RETRY_PERIOD_KEY = "leader-election.retry-period"; + + // --------------------------------------------------------------------------- + // Controller-level retry property suffixes + // --------------------------------------------------------------------------- + static final String RETRY_MAX_ATTEMPTS_SUFFIX = "retry.max-attempts"; + static final String RETRY_INITIAL_INTERVAL_SUFFIX = "retry.initial-interval"; + static final String RETRY_INTERVAL_MULTIPLIER_SUFFIX = "retry.interval-multiplier"; + static final String RETRY_MAX_INTERVAL_SUFFIX = "retry.max-interval"; + + // --------------------------------------------------------------------------- + // Controller-level rate-limiter property suffixes + // --------------------------------------------------------------------------- + static final String RATE_LIMITER_REFRESH_PERIOD_SUFFIX = "rate-limiter.refresh-period"; + static final String RATE_LIMITER_LIMIT_FOR_PERIOD_SUFFIX = "rate-limiter.limit-for-period"; + + // --------------------------------------------------------------------------- + // Controller-level (ControllerConfigurationOverrider) bindings + // The key used at runtime is built as: + // CONTROLLER_KEY_PREFIX + controllerName + "." + + // --------------------------------------------------------------------------- + static final List, ?>> CONTROLLER_BINDINGS = + List.of( + new ConfigBinding<>( + "finalizer", String.class, ControllerConfigurationOverrider::withFinalizer), + new ConfigBinding<>( + "generation-aware", + Boolean.class, + ControllerConfigurationOverrider::withGenerationAware), + new ConfigBinding<>( + "label-selector", String.class, ControllerConfigurationOverrider::withLabelSelector), + new ConfigBinding<>( + "max-reconciliation-interval", + Duration.class, + ControllerConfigurationOverrider::withReconciliationMaxInterval), + new ConfigBinding<>( + "field-manager", String.class, ControllerConfigurationOverrider::withFieldManager), + new ConfigBinding<>( + "trigger-reconciler-on-all-events", + Boolean.class, + ControllerConfigurationOverrider::withTriggerReconcilerOnAllEvents), + new ConfigBinding<>( + "informer.label-selector", + String.class, + ControllerConfigurationOverrider::withLabelSelector), + new ConfigBinding<>( + "informer.list-limit", + Long.class, + ControllerConfigurationOverrider::withInformerListLimit)); + + private final ConfigProvider configProvider; + + public ConfigLoader() { + this( + new AgregatePriorityListConfigProvider( + List.of(new EnvVarConfigProvider(), PropertiesConfigProvider.systemProperties())), + DEFAULT_CONTROLLER_KEY_PREFIX, + DEFAULT_OPERATOR_KEY_PREFIX); + } + + public ConfigLoader(ConfigProvider configProvider) { + this(configProvider, DEFAULT_CONTROLLER_KEY_PREFIX, DEFAULT_OPERATOR_KEY_PREFIX); + } + + public ConfigLoader( + ConfigProvider configProvider, String controllerKeyPrefix, String operatorKeyPrefix) { + this.configProvider = configProvider; + this.controllerKeyPrefix = controllerKeyPrefix; + this.operatorKeyPrefix = operatorKeyPrefix; + } + + /** + * Returns a {@link Consumer} that applies every operator-level property found in the {@link + * ConfigProvider} to the given {@link ConfigurationServiceOverrider}. Returns no-op consumer when + * no binding has a matching value, preserving the previous behavior. + */ + public Consumer applyConfigs() { + Consumer consumer = + buildConsumer(OPERATOR_BINDINGS, operatorKeyPrefix); + + Consumer leaderElectionStep = + buildLeaderElectionConsumer(operatorKeyPrefix); + if (leaderElectionStep != null) { + consumer = consumer.andThen(leaderElectionStep); + } + return consumer; + } + + /** + * Returns a {@link Consumer} that applies every controller-level property found in the {@link + * ConfigProvider} to the given {@link ControllerConfigurationOverrider}. The keys are looked up + * as {@code josdk.controller..}. + */ + @SuppressWarnings("unchecked") + public + Consumer> applyControllerConfigs(String controllerName) { + String prefix = controllerKeyPrefix + controllerName + "."; + // Cast is safe: the setter BiConsumer, T> is covariant in + // its first parameter for our usage – we only ever call it with + // ControllerConfigurationOverrider. + List, ?>> bindings = + (List, ?>>) (List) CONTROLLER_BINDINGS; + Consumer> consumer = buildConsumer(bindings, prefix); + + Consumer> retryStep = buildRetryConsumer(prefix); + if (retryStep != null) { + consumer = consumer == null ? retryStep : consumer.andThen(retryStep); + } + Consumer> rateLimiterStep = + buildRateLimiterConsumer(prefix); + if (rateLimiterStep != null) { + consumer = consumer.andThen(rateLimiterStep); + } + return consumer; + } + + /** + * If at least one retry property is present for the given prefix, returns a {@link Consumer} that + * builds a {@link GenericRetry} starting from {@link GenericRetry#defaultLimitedExponentialRetry} + * and overrides only the properties that are explicitly set. + */ + private Consumer> buildRetryConsumer( + String prefix) { + Optional maxAttempts = + configProvider.getValue(prefix + RETRY_MAX_ATTEMPTS_SUFFIX, Integer.class); + Optional initialInterval = + configProvider.getValue(prefix + RETRY_INITIAL_INTERVAL_SUFFIX, Long.class); + Optional intervalMultiplier = + configProvider.getValue(prefix + RETRY_INTERVAL_MULTIPLIER_SUFFIX, Double.class); + Optional maxInterval = + configProvider.getValue(prefix + RETRY_MAX_INTERVAL_SUFFIX, Long.class); + + if (maxAttempts.isEmpty() + && initialInterval.isEmpty() + && intervalMultiplier.isEmpty() + && maxInterval.isEmpty()) { + return null; + } + + return overrider -> { + GenericRetry retry = GenericRetry.defaultLimitedExponentialRetry(); + maxAttempts.ifPresent(retry::setMaxAttempts); + initialInterval.ifPresent(retry::setInitialInterval); + intervalMultiplier.ifPresent(retry::setIntervalMultiplier); + maxInterval.ifPresent(retry::setMaxInterval); + overrider.withRetry(retry); + }; + } + + /** + * Returns a {@link Consumer} that builds a {@link LinearRateLimiter} only if {@code + * rate-limiter.limit-for-period} is present and positive (a non-positive value would deactivate + * the limiter and is therefore treated as absent). {@code rate-limiter.refresh-period} is applied + * when also present; otherwise the default refresh period is used. Returns {@code null} when no + * effective rate-limiter configuration is found. + */ + private + Consumer> buildRateLimiterConsumer(String prefix) { + Optional refreshPeriod = + configProvider.getValue(prefix + RATE_LIMITER_REFRESH_PERIOD_SUFFIX, Duration.class); + Optional limitForPeriod = + configProvider.getValue(prefix + RATE_LIMITER_LIMIT_FOR_PERIOD_SUFFIX, Integer.class); + + if (limitForPeriod.isEmpty() || limitForPeriod.get() <= 0) { + return null; + } + + return overrider -> { + var rateLimiter = + new LinearRateLimiter( + refreshPeriod.orElse(LinearRateLimiter.DEFAULT_REFRESH_PERIOD), limitForPeriod.get()); + overrider.withRateLimiter(rateLimiter); + }; + } + + /** + * If leader election is explicitly disabled via {@code leader-election.enabled=false}, returns + * {@code null}. Otherwise, if at least one leader-election property is present (with {@code + * leader-election.lease-name} being required), returns a {@link Consumer} that builds a {@link + * io.javaoperatorsdk.operator.api.config.LeaderElectionConfiguration} via {@link + * LeaderElectionConfigurationBuilder} and applies it to the overrider. Returns {@code null} when + * no leader-election properties are present at all. + */ + private Consumer buildLeaderElectionConsumer(String prefix) { + Optional enabled = + configProvider.getValue(prefix + LEADER_ELECTION_ENABLED_KEY, Boolean.class); + if (enabled.isPresent() && !enabled.get()) { + return null; + } + + Optional leaseName = + configProvider.getValue(prefix + LEADER_ELECTION_LEASE_NAME_KEY, String.class); + Optional leaseNamespace = + configProvider.getValue(prefix + LEADER_ELECTION_LEASE_NAMESPACE_KEY, String.class); + Optional identity = + configProvider.getValue(prefix + LEADER_ELECTION_IDENTITY_KEY, String.class); + Optional leaseDuration = + configProvider.getValue(prefix + LEADER_ELECTION_LEASE_DURATION_KEY, Duration.class); + Optional renewDeadline = + configProvider.getValue(prefix + LEADER_ELECTION_RENEW_DEADLINE_KEY, Duration.class); + Optional retryPeriod = + configProvider.getValue(prefix + LEADER_ELECTION_RETRY_PERIOD_KEY, Duration.class); + + if (leaseName.isEmpty() + && leaseNamespace.isEmpty() + && identity.isEmpty() + && leaseDuration.isEmpty() + && renewDeadline.isEmpty() + && retryPeriod.isEmpty()) { + return null; + } + + return overrider -> { + var builder = + LeaderElectionConfigurationBuilder.aLeaderElectionConfiguration( + leaseName.orElseThrow( + () -> + new IllegalStateException( + "leader-election.lease-name must be set when configuring leader" + + " election"))); + leaseNamespace.ifPresent(builder::withLeaseNamespace); + identity.ifPresent(builder::withIdentity); + leaseDuration.ifPresent(builder::withLeaseDuration); + renewDeadline.ifPresent(builder::withRenewDeadline); + retryPeriod.ifPresent(builder::withRetryPeriod); + overrider.withLeaderElectionConfiguration(builder.build()); + }; + } + + /** + * Iterates {@code bindings} and, for each one whose key (optionally prefixed by {@code + * keyPrefix}) is present in the {@link ConfigProvider}, accumulates a call to the binding's + * setter. + * + * @param bindings the predefined bindings to check + * @param keyPrefix when non-null the key stored in the binding is treated as a suffix and this + * prefix is prepended before the lookup + * @return a consumer that applies all found values, or a no-op consumer if none were found + */ + private Consumer buildConsumer(List> bindings, String keyPrefix) { + Consumer consumer = null; + for (var binding : bindings) { + String lookupKey = keyPrefix == null ? binding.key() : keyPrefix + binding.key(); + Consumer step = resolveStep(binding, lookupKey); + if (step != null) { + consumer = consumer == null ? step : consumer.andThen(step); + } + } + return consumer == null ? o -> {} : consumer; + } + + /** + * Queries the {@link ConfigProvider} for {@code key} with the binding's type. If a value is + * present, returns a {@link Consumer} that calls the binding's setter; otherwise returns {@code + * null}. + */ + private Consumer resolveStep(ConfigBinding binding, String key) { + return configProvider + .getValue(key, binding.type()) + .map( + value -> + (Consumer) + overrider -> { + log.debug("Found config property: {} = {}", key, value); + binding.setter().accept(overrider, value); + }) + .orElse(null); + } +} diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigProvider.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigProvider.java new file mode 100644 index 0000000000..000131ff3b --- /dev/null +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigProvider.java @@ -0,0 +1,33 @@ +/* + * 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.config.loader; + +import java.util.Optional; + +public interface ConfigProvider { + + /** + * Returns the value associated with {@code key}, converted to {@code type}, or an empty {@link + * Optional} if the key is not set. + * + * @param key the dot-separated configuration key, e.g. {@code josdk.cache.sync.timeout} + * @param type the expected type of the value; supported types depend on the implementation + * @param the value type + * @return an {@link Optional} containing the typed value, or empty if the key is absent + * @throws IllegalArgumentException if {@code type} is not supported by the implementation + */ + Optional getValue(String key, Class type); +} diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/AgregatePriorityListConfigProvider.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/AgregatePriorityListConfigProvider.java new file mode 100644 index 0000000000..5190156ce5 --- /dev/null +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/AgregatePriorityListConfigProvider.java @@ -0,0 +1,45 @@ +/* + * 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.config.loader.provider; + +import java.util.List; +import java.util.Optional; + +import io.javaoperatorsdk.operator.config.loader.ConfigProvider; + +/** + * A {@link ConfigProvider} that delegates to an ordered list of providers. Providers are queried in + * list order; the first non-empty result wins. + */ +public class AgregatePriorityListConfigProvider implements ConfigProvider { + + private final List providers; + + public AgregatePriorityListConfigProvider(List providers) { + this.providers = List.copyOf(providers); + } + + @Override + public Optional getValue(String key, Class type) { + for (ConfigProvider provider : providers) { + Optional value = provider.getValue(key, type); + if (value.isPresent()) { + return value; + } + } + return Optional.empty(); + } +} diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/ConfigValueConverter.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/ConfigValueConverter.java new file mode 100644 index 0000000000..09c5c3fcf2 --- /dev/null +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/ConfigValueConverter.java @@ -0,0 +1,51 @@ +/* + * 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.config.loader.provider; + +import java.time.Duration; + +/** Utility for converting raw string config values to typed instances. */ +final class ConfigValueConverter { + + private ConfigValueConverter() {} + + /** + * Converts {@code raw} to an instance of {@code type}. Supported types: {@link String}, {@link + * Boolean}, {@link Integer}, {@link Long}, {@link Double}, and {@link Duration} (ISO-8601 format, + * e.g. {@code PT30S}). + * + * @throws IllegalArgumentException if {@code type} is not supported + */ + public static T convert(String raw, Class type) { + final Object converted; + if (type == String.class) { + converted = raw; + } else if (type == Boolean.class) { + converted = Boolean.parseBoolean(raw); + } else if (type == Integer.class) { + converted = Integer.parseInt(raw); + } else if (type == Long.class) { + converted = Long.parseLong(raw); + } else if (type == Double.class) { + converted = Double.parseDouble(raw); + } else if (type == Duration.class) { + converted = Duration.parse(raw); + } else { + throw new IllegalArgumentException("Unsupported config type: " + type.getName()); + } + return type.cast(converted); + } +} diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/EnvVarConfigProvider.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/EnvVarConfigProvider.java new file mode 100644 index 0000000000..916ee6391d --- /dev/null +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/EnvVarConfigProvider.java @@ -0,0 +1,60 @@ +/* + * 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.config.loader.provider; + +import java.util.Optional; +import java.util.function.Function; + +import io.javaoperatorsdk.operator.config.loader.ConfigProvider; + +/** + * A {@link ConfigProvider} that resolves configuration values from environment variables. + * + *

The key is converted to an environment variable name by replacing dots and hyphens with + * underscores and converting to upper case (e.g. {@code josdk.cache-sync.timeout} → {@code + * JOSDK_CACHE_SYNC_TIMEOUT}). + * + *

Supported value types are: {@link String}, {@link Boolean}, {@link Integer}, {@link Long}, + * {@link Double}, and {@link java.time.Duration} (ISO-8601 format, e.g. {@code PT30S}). + */ +public class EnvVarConfigProvider implements ConfigProvider { + + private final Function envLookup; + + public EnvVarConfigProvider() { + this(System::getenv); + } + + EnvVarConfigProvider(Function envLookup) { + this.envLookup = envLookup; + } + + @Override + public Optional getValue(String key, Class type) { + if (key == null) { + return Optional.empty(); + } + String raw = envLookup.apply(toEnvKey(key)); + if (raw == null) { + return Optional.empty(); + } + return Optional.of(ConfigValueConverter.convert(raw, type)); + } + + static String toEnvKey(String key) { + return key.trim().replace('.', '_').replace('-', '_').toUpperCase(); + } +} diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/PropertiesConfigProvider.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/PropertiesConfigProvider.java new file mode 100644 index 0000000000..35dd38f406 --- /dev/null +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/PropertiesConfigProvider.java @@ -0,0 +1,79 @@ +/* + * 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.config.loader.provider; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.Properties; + +import io.javaoperatorsdk.operator.config.loader.ConfigProvider; + +/** + * A {@link ConfigProvider} that resolves configuration values from a {@link Properties} file. + * + *

Keys are looked up as-is against the loaded properties. Supported value types are: {@link + * String}, {@link Boolean}, {@link Integer}, {@link Long}, {@link Double}, and {@link + * java.time.Duration} (ISO-8601 format, e.g. {@code PT30S}). + */ +public class PropertiesConfigProvider implements ConfigProvider { + + private final Properties properties; + + /** Returns a {@link PropertiesConfigProvider} backed by {@link System#getProperties()}. */ + public static PropertiesConfigProvider systemProperties() { + return new PropertiesConfigProvider(System.getProperties()); + } + + /** + * Loads properties from the given file path. + * + * @throws UncheckedIOException if the file cannot be read + */ + public PropertiesConfigProvider(Path path) { + this.properties = load(path); + } + + /** Uses the supplied {@link Properties} instance directly. */ + public PropertiesConfigProvider(Properties properties) { + this.properties = properties; + } + + @Override + public Optional getValue(String key, Class type) { + if (key == null) { + return Optional.empty(); + } + String raw = properties.getProperty(key); + if (raw == null) { + return Optional.empty(); + } + return Optional.of(ConfigValueConverter.convert(raw, type)); + } + + private static Properties load(Path path) { + try (InputStream in = Files.newInputStream(path)) { + Properties props = new Properties(); + props.load(in); + return props; + } catch (IOException e) { + throw new UncheckedIOException("Failed to load config properties from " + path, e); + } + } +} diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/YamlConfigProvider.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/YamlConfigProvider.java new file mode 100644 index 0000000000..52b07b011d --- /dev/null +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/YamlConfigProvider.java @@ -0,0 +1,89 @@ +/* + * 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.config.loader.provider; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.Optional; + +import io.javaoperatorsdk.operator.config.loader.ConfigProvider; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; + +/** + * A {@link ConfigProvider} that resolves configuration values from a YAML file. + * + *

Keys use dot-separated notation to address nested YAML mappings (e.g. {@code + * josdk.cache-sync.timeout} maps to {@code josdk → cache-sync → timeout} in the YAML document). + * Leaf values are converted to the requested type via {@link ConfigValueConverter}. Supported value + * types are: {@link String}, {@link Boolean}, {@link Integer}, {@link Long}, {@link Double}, and + * {@link java.time.Duration} (ISO-8601 format, e.g. {@code PT30S}). + */ +public class YamlConfigProvider implements ConfigProvider { + + private static final ObjectMapper MAPPER = new ObjectMapper(new YAMLFactory()); + + private final Map data; + + /** + * Loads YAML from the given file path. + * + * @throws UncheckedIOException if the file cannot be read + */ + public YamlConfigProvider(Path path) { + this.data = load(path); + } + + /** Uses the supplied map directly (useful for testing). */ + public YamlConfigProvider(Map data) { + this.data = data; + } + + @Override + @SuppressWarnings("unchecked") + public Optional getValue(String key, Class type) { + if (key == null) { + return Optional.empty(); + } + String[] parts = key.split("\\.", -1); + Object current = data; + for (String part : parts) { + if (!(current instanceof Map)) { + return Optional.empty(); + } + current = ((Map) current).get(part); + if (current == null) { + return Optional.empty(); + } + } + return Optional.of(ConfigValueConverter.convert(current.toString(), type)); + } + + @SuppressWarnings("unchecked") + private static Map load(Path path) { + try (InputStream in = Files.newInputStream(path)) { + Map result = MAPPER.readValue(in, Map.class); + return result != null ? result : Map.of(); + } catch (IOException e) { + throw new UncheckedIOException("Failed to load config YAML from " + path, e); + } + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/builtinresourcecleaner/BuiltInResourceCleanerIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/builtinresourcecleaner/BuiltInResourceCleanerIT.java index 9667c22486..18e076e2bf 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/builtinresourcecleaner/BuiltInResourceCleanerIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/builtinresourcecleaner/BuiltInResourceCleanerIT.java @@ -24,7 +24,7 @@ import io.fabric8.kubernetes.api.model.Service; import io.javaoperatorsdk.annotation.Sample; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.dependent.standalonedependent.StandaloneDependentResourceIT; import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; @@ -85,7 +85,7 @@ void cleanerIsCalledOnBuiltInResource() { Service testService() { Service service = - ReconcilerUtils.loadYaml( + ReconcilerUtilsInternal.loadYaml( Service.class, StandaloneDependentResourceIT.class, "/io/javaoperatorsdk/operator/service-template.yaml"); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cachingfilteringupdate/CachingFilteringUpdateCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cachingfilteringupdate/CachingFilteringUpdateCustomResource.java new file mode 100644 index 0000000000..0d25bbfdd4 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cachingfilteringupdate/CachingFilteringUpdateCustomResource.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.baseapi.cachingfilteringupdate; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.ShortNames; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk") +@Version("v1") +@ShortNames("cfu") +public class CachingFilteringUpdateCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cachingfilteringupdate/CachingFilteringUpdateIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cachingfilteringupdate/CachingFilteringUpdateIT.java new file mode 100644 index 0000000000..c62c8ca186 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cachingfilteringupdate/CachingFilteringUpdateIT.java @@ -0,0 +1,82 @@ +/* + * 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.baseapi.cachingfilteringupdate; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class CachingFilteringUpdateIT { + + public static final int RESOURCE_NUMBER = 250; + CachingFilteringUpdateReconciler reconciler = new CachingFilteringUpdateReconciler(); + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder().withReconciler(reconciler).build(); + + @Test + void testResourceAccessAfterUpdate() { + for (int i = 0; i < RESOURCE_NUMBER; i++) { + operator.create(createCustomResource(i)); + } + await() + .pollDelay(Duration.ofSeconds(5)) + .atMost(Duration.ofMinutes(1)) + .until( + () -> { + if (reconciler.isIssueFound()) { + // Stop waiting as soon as an issue is detected. + return true; + } + // Use a single representative resource to detect that updates have completed. + var res = + operator.get( + CachingFilteringUpdateCustomResource.class, + "resource" + (RESOURCE_NUMBER - 1)); + return res != null + && res.getStatus() != null + && Boolean.TRUE.equals(res.getStatus().getUpdated()); + }); + + if (operator.getReconcilerOfType(CachingFilteringUpdateReconciler.class).isIssueFound()) { + throw new IllegalStateException("Error already found."); + } + + for (int i = 0; i < RESOURCE_NUMBER; i++) { + var res = operator.get(CachingFilteringUpdateCustomResource.class, "resource" + i); + assertThat(res.getStatus()).isNotNull(); + assertThat(res.getStatus().getUpdated()).isTrue(); + } + } + + public CachingFilteringUpdateCustomResource createCustomResource(int i) { + CachingFilteringUpdateCustomResource resource = new CachingFilteringUpdateCustomResource(); + resource.setMetadata( + new ObjectMetaBuilder() + .withName("resource" + i) + .withNamespace(operator.getNamespace()) + .build()); + return resource; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cachingfilteringupdate/CachingFilteringUpdateReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cachingfilteringupdate/CachingFilteringUpdateReconciler.java new file mode 100644 index 0000000000..1bd60eb2c9 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cachingfilteringupdate/CachingFilteringUpdateReconciler.java @@ -0,0 +1,95 @@ +/* + * 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.baseapi.cachingfilteringupdate; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; + +@ControllerConfiguration +public class CachingFilteringUpdateReconciler + implements Reconciler { + + private final AtomicBoolean issueFound = new AtomicBoolean(false); + + @Override + public UpdateControl reconcile( + CachingFilteringUpdateCustomResource resource, + Context context) { + + context.resourceOperations().serverSideApply(prepareCM(resource)); + var cachedCM = context.getSecondaryResource(ConfigMap.class); + if (cachedCM.isEmpty()) { + issueFound.set(true); + throw new IllegalStateException("Error for resource: " + ResourceID.fromResource(resource)); + } + + ensureStatusExists(resource); + resource.getStatus().setUpdated(true); + return UpdateControl.patchStatus(resource); + } + + private static ConfigMap prepareCM(CachingFilteringUpdateCustomResource p) { + var cm = + new ConfigMapBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName(p.getMetadata().getName()) + .withNamespace(p.getMetadata().getNamespace()) + .build()) + .withData(Map.of("name", p.getMetadata().getName())) + .build(); + cm.addOwnerReference(p); + return cm; + } + + @Override + public List> prepareEventSources( + EventSourceContext context) { + InformerEventSource cmES = + new InformerEventSource<>( + InformerEventSourceConfiguration.from( + ConfigMap.class, CachingFilteringUpdateCustomResource.class) + .build(), + context); + return List.of(cmES); + } + + private void ensureStatusExists(CachingFilteringUpdateCustomResource resource) { + CachingFilteringUpdateStatus status = resource.getStatus(); + if (status == null) { + status = new CachingFilteringUpdateStatus(); + resource.setStatus(status); + } + } + + public boolean isIssueFound() { + return issueFound.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cachingfilteringupdate/CachingFilteringUpdateStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cachingfilteringupdate/CachingFilteringUpdateStatus.java new file mode 100644 index 0000000000..80b6c4ba54 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cachingfilteringupdate/CachingFilteringUpdateStatus.java @@ -0,0 +1,29 @@ +/* + * 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.baseapi.cachingfilteringupdate; + +public class CachingFilteringUpdateStatus { + + private Boolean updated; + + public Boolean getUpdated() { + return updated; + } + + public void setUpdated(Boolean updated) { + this.updated = updated; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceIT.java index 592e40100e..4a32d97252 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceIT.java @@ -131,7 +131,7 @@ private static void assertReconciled( assertThat( reconciler.numberOfResourceReconciliations( resourceInAdditionalTestNamespace)) - .isEqualTo(2)); + .isEqualTo(1)); } private static void assertNotReconciled( diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java index 64a80ff4a8..d05364fc44 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java @@ -53,15 +53,7 @@ public UpdateControl reconcile( ChangeNamespaceTestCustomResource primary, Context context) { - var actualConfigMap = context.getSecondaryResource(ConfigMap.class); - if (actualConfigMap.isEmpty()) { - context - .getClient() - .configMaps() - .inNamespace(primary.getMetadata().getNamespace()) - .resource(configMap(primary)) - .create(); - } + context.resourceOperations().serverSideApply(configMap(primary)); if (primary.getStatus() == null) { primary.setStatus(new ChangeNamespaceTestCustomResourceStatus()); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderIT.java new file mode 100644 index 0000000000..d1ee0afa59 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderIT.java @@ -0,0 +1,146 @@ +/* + * 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.baseapi.configloader; + +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider; +import io.javaoperatorsdk.operator.config.loader.ConfigLoader; +import io.javaoperatorsdk.operator.config.loader.ConfigProvider; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.support.TestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * Integration tests that verify {@link ConfigLoader} property overrides take effect when wiring up + * a real operator instance via {@link LocallyRunOperatorExtension}. + * + *

Each nested class exercises a distinct group of properties so that failures are easy to + * pinpoint. + */ +class ConfigLoaderIT { + + /** Builds a {@link ConfigProvider} backed by a plain map. */ + private static ConfigProvider mapProvider(Map values) { + return new ConfigProvider() { + @Override + @SuppressWarnings("unchecked") + public Optional getValue(String key, Class type) { + return Optional.ofNullable((T) values.get(key)); + } + }; + } + + // --------------------------------------------------------------------------- + // Operator-level properties + // --------------------------------------------------------------------------- + + @Nested + class OperatorLevelProperties { + + /** + * Verifies that {@code josdk.reconciliation.concurrent-threads} loaded via {@link ConfigLoader} + * and applied through {@code withConfigurationService} actually changes the operator's thread + * pool size. + */ + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(new ConfigLoaderTestReconciler(0)) + .withConfigurationService( + new ConfigLoader(mapProvider(Map.of("josdk.reconciliation.concurrent-threads", 2))) + .applyConfigs()) + .build(); + + @Test + void concurrentReconciliationThreadsIsAppliedFromConfigLoader() { + assertThat(operator.getOperator().getConfigurationService().concurrentReconciliationThreads()) + .isEqualTo(2); + } + } + + // --------------------------------------------------------------------------- + // Controller-level retry + // --------------------------------------------------------------------------- + + @Nested + class ControllerRetryProperties { + + static final int FAILS = 2; + // controller name is the lower-cased simple class name by default + static final String CTRL_NAME = ConfigLoaderTestReconciler.class.getSimpleName().toLowerCase(); + + /** + * Verifies that retry properties read by {@link ConfigLoader} for a specific controller name + * are applied when registering the reconciler via a {@code configurationOverrider} consumer, + * and that the resulting operator actually retries and eventually succeeds. + */ + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler( + new ConfigLoaderTestReconciler(FAILS), + // applyControllerConfigs returns Consumer>; + // withReconciler takes the raw Consumer + (Consumer) + (Consumer) + new ConfigLoader( + mapProvider( + Map.of( + "josdk.controller." + CTRL_NAME + ".retry.max-attempts", + 5, + "josdk.controller." + CTRL_NAME + ".retry.initial-interval", + 100L))) + .applyControllerConfigs(CTRL_NAME)) + .build(); + + @Test + void retryConfigFromConfigLoaderIsAppliedAndReconcilerEventuallySucceeds() { + var resource = createResource("1"); + operator.create(resource); + + await("reconciler succeeds after retries") + .atMost(10, TimeUnit.SECONDS) + .pollInterval(100, TimeUnit.MILLISECONDS) + .untilAsserted( + () -> { + assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(FAILS + 1); + var updated = + operator.get( + ConfigLoaderTestCustomResource.class, resource.getMetadata().getName()); + assertThat(updated.getStatus()).isNotNull(); + assertThat(updated.getStatus().getState()) + .isEqualTo(ConfigLoaderTestCustomResourceStatus.State.SUCCESS); + }); + } + + private ConfigLoaderTestCustomResource createResource(String id) { + var resource = new ConfigLoaderTestCustomResource(); + resource.setMetadata(new ObjectMetaBuilder().withName("cfgloader-retry-" + id).build()); + return resource; + } + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderTestCustomResource.java new file mode 100644 index 0000000000..a892b2391d --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderTestCustomResource.java @@ -0,0 +1,30 @@ +/* + * 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.baseapi.configloader; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Kind; +import io.fabric8.kubernetes.model.annotation.ShortNames; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk") +@Version("v1") +@Kind("ConfigLoaderSample") +@ShortNames("cls") +public class ConfigLoaderTestCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderTestCustomResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderTestCustomResourceStatus.java new file mode 100644 index 0000000000..c70202bb73 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderTestCustomResourceStatus.java @@ -0,0 +1,35 @@ +/* + * 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.baseapi.configloader; + +public class ConfigLoaderTestCustomResourceStatus { + + public enum State { + SUCCESS, + ERROR + } + + private State state; + + public State getState() { + return state; + } + + public ConfigLoaderTestCustomResourceStatus setState(State state) { + this.state = state; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderTestReconciler.java new file mode 100644 index 0000000000..dbadfd4414 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderTestReconciler.java @@ -0,0 +1,58 @@ +/* + * 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.baseapi.configloader; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +/** + * A reconciler that fails for the first {@code numberOfFailures} invocations and then succeeds, + * setting the status to {@link ConfigLoaderTestCustomResourceStatus.State#SUCCESS}. + */ +@ControllerConfiguration +public class ConfigLoaderTestReconciler + implements Reconciler, TestExecutionInfoProvider { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + private final int numberOfFailures; + + public ConfigLoaderTestReconciler(int numberOfFailures) { + this.numberOfFailures = numberOfFailures; + } + + @Override + public UpdateControl reconcile( + ConfigLoaderTestCustomResource resource, Context context) { + int execution = numberOfExecutions.incrementAndGet(); + if (execution <= numberOfFailures) { + throw new RuntimeException("Simulated failure on execution " + execution); + } + var status = new ConfigLoaderTestCustomResourceStatus(); + status.setState(ConfigLoaderTestCustomResourceStatus.State.SUCCESS); + resource.setStatus(status); + return UpdateControl.patchStatus(resource); + } + + @Override + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/CreateUpdateEventFilterTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/CreateUpdateEventFilterTestReconciler.java index 40bf2cc350..4344356ff9 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/CreateUpdateEventFilterTestReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/CreateUpdateEventFilterTestReconciler.java @@ -41,6 +41,16 @@ public class CreateUpdateEventFilterTestReconciler private final DirectConfigMapDependentResource configMapDR = new DirectConfigMapDependentResource(ConfigMap.class); + private final boolean comparableResourceVersion; + + public CreateUpdateEventFilterTestReconciler(boolean comparableResourceVersion) { + this.comparableResourceVersion = comparableResourceVersion; + } + + public CreateUpdateEventFilterTestReconciler() { + this(true); + } + @Override public UpdateControl reconcile( CreateUpdateEventFilterTestCustomResource resource, @@ -89,6 +99,7 @@ public List> prepareEv InformerEventSourceConfiguration.from( ConfigMap.class, CreateUpdateEventFilterTestCustomResource.class) .withLabelSelector("integrationtest = " + this.getClass().getSimpleName()) + .withComparableResourceVersion(comparableResourceVersion) .build(); final var informerEventSource = new InformerEventSource<>(informerConfiguration, context); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/PreviousAnnotationDisabledIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/PreviousAnnotationDisabledIT.java index 17fe6b7125..6577d4ca59 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/PreviousAnnotationDisabledIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/PreviousAnnotationDisabledIT.java @@ -34,9 +34,7 @@ class PreviousAnnotationDisabledIT { @RegisterExtension LocallyRunOperatorExtension operator = LocallyRunOperatorExtension.builder() - .withReconciler(new CreateUpdateEventFilterTestReconciler()) - .withConfigurationService( - overrider -> overrider.withPreviousAnnotationForDependentResources(false)) + .withReconciler(new CreateUpdateEventFilterTestReconciler(false)) .build(); @Test diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventIT.java new file mode 100644 index 0000000000..6f27925e21 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventIT.java @@ -0,0 +1,108 @@ +/* + * 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.baseapi.filterpatchevent; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMeta; +import io.javaoperatorsdk.annotation.Sample; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@Sample( + tldr = "Controlling patch event filtering in UpdateControl", + description = + """ + Demonstrates how to use the filterPatchEvent parameter in UpdateControl to control \ + whether patch operations trigger subsequent reconciliation events. When filterPatchEvent \ + is true (default), patch events are filtered out to prevent reconciliation loops. When \ + false, patch events trigger reconciliation, allowing for controlled event propagation. + """) +class FilterPatchEventIT { + + public static final int POLL_DELAY = 150; + public static final String NAME = "test1"; + public static final String UPDATED = "updated"; + + FilterPatchEventTestReconciler reconciler = new FilterPatchEventTestReconciler(); + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder().withReconciler(reconciler).build(); + + @Test + void patchEventFilteredWhenFlagIsTrue() { + reconciler.setFilterPatchEvent(true); + var resource = createTestResource(); + extension.create(resource); + + // Wait for the reconciliation to complete and the resource to be updated + await() + .pollDelay(Duration.ofMillis(POLL_DELAY)) + .untilAsserted( + () -> { + var updated = extension.get(FilterPatchEventTestCustomResource.class, NAME); + assertThat(updated.getStatus()).isNotNull(); + assertThat(updated.getStatus().getValue()).isEqualTo(UPDATED); + }); + + // With filterPatchEvent=true, reconciliation should only run once + // (triggered by the initial create, but not by the patch operation) + int executions = reconciler.getNumberOfExecutions(); + assertThat(executions).isEqualTo(1); + } + + @Test + void patchEventNotFilteredWhenFlagIsFalse() { + reconciler.setFilterPatchEvent(false); + var resource = createTestResource(); + extension.create(resource); + + // Wait for the reconciliation to complete and the resource to be updated + await() + .atMost(Duration.ofSeconds(5)) + .untilAsserted( + () -> { + var updated = extension.get(FilterPatchEventTestCustomResource.class, NAME); + assertThat(updated.getStatus()).isNotNull(); + assertThat(updated.getStatus().getValue()).isEqualTo(UPDATED); + }); + + // Wait for potential additional reconciliations + await() + .pollDelay(Duration.ofMillis(POLL_DELAY)) + .atMost(Duration.ofSeconds(5)) + .untilAsserted( + () -> { + int executions = reconciler.getNumberOfExecutions(); + // With filterPatchEvent=false, reconciliation should run at least twice + // (once for create and at least once for the patch event) + assertThat(executions).isGreaterThanOrEqualTo(2); + }); + } + + private FilterPatchEventTestCustomResource createTestResource() { + FilterPatchEventTestCustomResource resource = new FilterPatchEventTestCustomResource(); + resource.setMetadata(new ObjectMeta()); + resource.getMetadata().setName(NAME); + return resource; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventTestCustomResource.java new file mode 100644 index 0000000000..7f8b4838de --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventTestCustomResource.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.baseapi.filterpatchevent; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.ShortNames; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk") +@Version("v1") +@ShortNames("fpe") +public class FilterPatchEventTestCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventTestCustomResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventTestCustomResourceStatus.java new file mode 100644 index 0000000000..1c7aeafadd --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventTestCustomResourceStatus.java @@ -0,0 +1,30 @@ +/* + * 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.baseapi.filterpatchevent; + +public class FilterPatchEventTestCustomResourceStatus { + + private String value; + + public String getValue() { + return value; + } + + public FilterPatchEventTestCustomResourceStatus setValue(String value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventTestReconciler.java new file mode 100644 index 0000000000..e7599a2881 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventTestReconciler.java @@ -0,0 +1,59 @@ +/* + * 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.baseapi.filterpatchevent; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; + +import static io.javaoperatorsdk.operator.baseapi.filterpatchevent.FilterPatchEventIT.UPDATED; + +@ControllerConfiguration(generationAwareEventProcessing = false) +public class FilterPatchEventTestReconciler + implements Reconciler { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + private final AtomicBoolean filterPatchEvent = new AtomicBoolean(false); + + @Override + public UpdateControl reconcile( + FilterPatchEventTestCustomResource resource, + Context context) { + numberOfExecutions.incrementAndGet(); + + // Update the spec value to trigger a patch operation + resource.setStatus(new FilterPatchEventTestCustomResourceStatus()); + resource.getStatus().setValue(UPDATED); + + var uc = UpdateControl.patchStatus(resource); + if (!filterPatchEvent.get()) { + uc = uc.reschedule(); + } + return uc; + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } + + public void setFilterPatchEvent(boolean b) { + filterPatchEvent.set(b); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/generickubernetesresourcehandling/GenericKubernetesResourceHandlingReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/generickubernetesresourcehandling/GenericKubernetesResourceHandlingReconciler.java index 039faf056c..7efa8a0ad6 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/generickubernetesresourcehandling/GenericKubernetesResourceHandlingReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/generickubernetesresourcehandling/GenericKubernetesResourceHandlingReconciler.java @@ -17,7 +17,6 @@ import java.io.IOException; import java.io.InputStream; -import java.util.HashMap; import java.util.List; import java.util.Map; @@ -41,36 +40,11 @@ public UpdateControl reconcile( GenericKubernetesResourceHandlingCustomResource primary, Context context) { - var secondary = context.getSecondaryResource(GenericKubernetesResource.class); - - secondary.ifPresentOrElse( - r -> { - var desired = desiredConfigMap(primary, context); - if (!matches(r, desired)) { - context - .getClient() - .genericKubernetesResources(VERSION, KIND) - .resource(desired) - .update(); - } - }, - () -> - context - .getClient() - .genericKubernetesResources(VERSION, KIND) - .resource(desiredConfigMap(primary, context)) - .create()); + context.resourceOperations().serverSideApply(desiredConfigMap(primary, context)); return UpdateControl.noUpdate(); } - @SuppressWarnings("unchecked") - private boolean matches(GenericKubernetesResource actual, GenericKubernetesResource desired) { - var actualData = (HashMap) actual.getAdditionalProperties().get("data"); - var desiredData = (HashMap) desired.getAdditionalProperties().get("data"); - return actualData.equals(desiredData); - } - GenericKubernetesResource desiredConfigMap( GenericKubernetesResourceHandlingCustomResource primary, Context context) { diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/infrastructureclient/InfrastructureClientIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/infrastructureclient/InfrastructureClientIT.java index 59faaae90b..eb39fa0657 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/infrastructureclient/InfrastructureClientIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/infrastructureclient/InfrastructureClientIT.java @@ -28,7 +28,7 @@ import io.fabric8.kubernetes.client.ConfigBuilder; import io.fabric8.kubernetes.client.KubernetesClientBuilder; import io.fabric8.kubernetes.client.KubernetesClientException; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; import static org.assertj.core.api.Assertions.assertThat; @@ -127,23 +127,25 @@ void shouldNotAccessNotPermittedResources() { private void applyClusterRoleBinding(String filename) { var clusterRoleBinding = - ReconcilerUtils.loadYaml(ClusterRoleBinding.class, this.getClass(), filename); + ReconcilerUtilsInternal.loadYaml(ClusterRoleBinding.class, this.getClass(), filename); operator.getInfrastructureKubernetesClient().resource(clusterRoleBinding).serverSideApply(); } private void applyClusterRole(String filename) { - var clusterRole = ReconcilerUtils.loadYaml(ClusterRole.class, this.getClass(), filename); + var clusterRole = + ReconcilerUtilsInternal.loadYaml(ClusterRole.class, this.getClass(), filename); operator.getInfrastructureKubernetesClient().resource(clusterRole).serverSideApply(); } private void removeClusterRoleBinding(String filename) { var clusterRoleBinding = - ReconcilerUtils.loadYaml(ClusterRoleBinding.class, this.getClass(), filename); + ReconcilerUtilsInternal.loadYaml(ClusterRoleBinding.class, this.getClass(), filename); operator.getInfrastructureKubernetesClient().resource(clusterRoleBinding).delete(); } private void removeClusterRole(String filename) { - var clusterRole = ReconcilerUtils.loadYaml(ClusterRole.class, this.getClass(), filename); + var clusterRole = + ReconcilerUtilsInternal.loadYaml(ClusterRole.class, this.getClass(), filename); operator.getInfrastructureKubernetesClient().resource(clusterRole).delete(); } } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctIT.java new file mode 100644 index 0000000000..24cee17f04 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctIT.java @@ -0,0 +1,125 @@ +/* + * 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.baseapi.latestdistinct; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.annotation.Sample; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.baseapi.latestdistinct.LatestDistinctTestReconciler.LABEL_KEY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@Sample( + tldr = "Latest Distinct with Multiple InformerEventSources", + description = + """ + Demonstrates using two separate InformerEventSource instances for ConfigMaps with \ + overlapping watches, combined with latestDistinctList() to deduplicate resources by \ + keeping the latest version. Also tests ReconcileUtils methods for patching resources \ + with proper cache updates. + """) +class LatestDistinctIT { + + public static final String TEST_RESOURCE_NAME = "test-resource"; + public static final String CONFIG_MAP_1 = "config-map-1"; + public static final String DEFAULT_VALUE = "defaultValue"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(LatestDistinctTestReconciler.class) + .build(); + + @Test + void testLatestDistinctListWithTwoInformerEventSources() { + // Create the custom resource + var resource = createTestCustomResource(); + resource = extension.create(resource); + + // Create ConfigMaps with type1 label (watched by first event source) + var cm1 = createConfigMap(CONFIG_MAP_1, resource); + extension.create(cm1); + + // Wait for reconciliation + var reconciler = extension.getReconcilerOfType(LatestDistinctTestReconciler.class); + await() + .atMost(Duration.ofSeconds(5)) + .pollDelay(Duration.ofMillis(300)) + .untilAsserted( + () -> { + var updatedResource = + extension.get(LatestDistinctTestResource.class, TEST_RESOURCE_NAME); + assertThat(updatedResource.getStatus()).isNotNull(); + // Should see 1 distinct ConfigMaps + assertThat(updatedResource.getStatus().getConfigMapCount()).isEqualTo(1); + assertThat(reconciler.isErrorOccurred()).isFalse(); + // note that since there are two event source, and we do the update through one event + // source + // the other will still propagate an event + assertThat(reconciler.getNumberOfExecutions()).isEqualTo(2); + }); + } + + private LatestDistinctTestResource createTestCustomResource() { + var resource = new LatestDistinctTestResource(); + resource.setMetadata( + new ObjectMetaBuilder() + .withName(TEST_RESOURCE_NAME) + .withNamespace(extension.getNamespace()) + .build()); + resource.setSpec(new LatestDistinctTestResourceSpec()); + return resource; + } + + private ConfigMap createConfigMap(String name, LatestDistinctTestResource owner) { + Map labels = new HashMap<>(); + labels.put(LABEL_KEY, "val"); + + Map data = new HashMap<>(); + data.put("key", DEFAULT_VALUE); + + return new ConfigMapBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName(name) + .withNamespace(extension.getNamespace()) + .withLabels(labels) + .build()) + .withData(data) + .withNewMetadata() + .withName(name) + .withNamespace(extension.getNamespace()) + .withLabels(labels) + .addNewOwnerReference() + .withApiVersion(owner.getApiVersion()) + .withKind(owner.getKind()) + .withName(owner.getMetadata().getName()) + .withUid(owner.getMetadata().getUid()) + .endOwnerReference() + .endMetadata() + .build(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestReconciler.java new file mode 100644 index 0000000000..d53ed738db --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestReconciler.java @@ -0,0 +1,140 @@ +/* + * 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.baseapi.latestdistinct; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.ErrorStatusUpdateControl; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; + +@ControllerConfiguration +public class LatestDistinctTestReconciler implements Reconciler { + + public static final String EVENT_SOURCE_1_NAME = "configmap-es-1"; + public static final String EVENT_SOURCE_2_NAME = "configmap-es-2"; + public static final String LABEL_KEY = "configmap-type"; + public static final String KEY_2 = "key2"; + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + private volatile boolean errorOccurred = false; + + @Override + public UpdateControl reconcile( + LatestDistinctTestResource resource, Context context) { + + // Update status with information from ConfigMaps + if (resource.getStatus() == null) { + resource.setStatus(new LatestDistinctTestResourceStatus()); + } + var allConfigMaps = context.getSecondaryResourcesAsStream(ConfigMap.class).toList(); + if (allConfigMaps.size() < 2) { + // wait until both informers see the config map + return UpdateControl.noUpdate(); + } + // makes sure that distinct config maps returned + var distinctConfigMaps = context.getSecondaryResourcesAsStream(ConfigMap.class, true).toList(); + if (distinctConfigMaps.size() != 1) { + errorOccurred = true; + throw new IllegalStateException(); + } + + resource.getStatus().setConfigMapCount(distinctConfigMaps.size()); + var configMap = distinctConfigMaps.get(0); + configMap.setData(Map.of(KEY_2, "val2")); + var updated = context.resourceOperations().update(configMap); + + // makes sure that distinct config maps returned + distinctConfigMaps = context.getSecondaryResourcesAsStream(ConfigMap.class, true).toList(); + if (distinctConfigMaps.size() != 1) { + errorOccurred = true; + throw new IllegalStateException(); + } + configMap = distinctConfigMaps.get(0); + if (!configMap.getData().containsKey(KEY_2) + || !configMap + .getMetadata() + .getResourceVersion() + .equals(updated.getMetadata().getResourceVersion())) { + errorOccurred = true; + throw new IllegalStateException(); + } + numberOfExecutions.incrementAndGet(); + return UpdateControl.patchStatus(resource); + } + + @Override + public List> prepareEventSources( + EventSourceContext context) { + var configEs1 = + InformerEventSourceConfiguration.from(ConfigMap.class, LatestDistinctTestResource.class) + .withName(EVENT_SOURCE_1_NAME) + .withLabelSelector(LABEL_KEY) + .withNamespacesInheritedFromController() + .withSecondaryToPrimaryMapper( + cm -> + Set.of( + new ResourceID( + cm.getMetadata().getOwnerReferences().get(0).getName(), + cm.getMetadata().getNamespace()))) + .build(); + + var configEs2 = + InformerEventSourceConfiguration.from(ConfigMap.class, LatestDistinctTestResource.class) + .withName(EVENT_SOURCE_2_NAME) + .withLabelSelector(LABEL_KEY) + .withNamespacesInheritedFromController() + .withSecondaryToPrimaryMapper( + cm -> + Set.of( + new ResourceID( + cm.getMetadata().getOwnerReferences().get(0).getName(), + cm.getMetadata().getNamespace()))) + .build(); + + return List.of( + new InformerEventSource<>(configEs1, context), + new InformerEventSource<>(configEs2, context)); + } + + @Override + public ErrorStatusUpdateControl updateErrorStatus( + LatestDistinctTestResource resource, + Context context, + Exception e) { + errorOccurred = true; + return ErrorStatusUpdateControl.noStatusUpdate(); + } + + public boolean isErrorOccurred() { + return errorOccurred; + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestResource.java new file mode 100644 index 0000000000..546e349b0a --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestResource.java @@ -0,0 +1,40 @@ +/* + * 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.baseapi.latestdistinct; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.ShortNames; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk") +@Version("v1") +@ShortNames("ldt") +public class LatestDistinctTestResource + extends CustomResource + implements Namespaced { + + @Override + protected LatestDistinctTestResourceSpec initSpec() { + return new LatestDistinctTestResourceSpec(); + } + + @Override + protected LatestDistinctTestResourceStatus initStatus() { + return new LatestDistinctTestResourceStatus(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestResourceSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestResourceSpec.java new file mode 100644 index 0000000000..acfefab85e --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestResourceSpec.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.baseapi.latestdistinct; + +public class LatestDistinctTestResourceSpec { + private String value; + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestResourceStatus.java new file mode 100644 index 0000000000..fd5ff82df5 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestResourceStatus.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.baseapi.latestdistinct; + +public class LatestDistinctTestResourceStatus { + private int configMapCount; + + public int getConfigMapCount() { + return configMapCount; + } + + public void setConfigMapCount(int configMapCount) { + this.configMapCount = configMapCount; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/leaderelectionpermission/LeaderElectionPermissionIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/leaderelectionpermission/LeaderElectionPermissionIT.java index 0180e3b8b8..6b5cbcc812 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/leaderelectionpermission/LeaderElectionPermissionIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/leaderelectionpermission/LeaderElectionPermissionIT.java @@ -26,7 +26,7 @@ import io.javaoperatorsdk.annotation.Sample; import io.javaoperatorsdk.operator.Operator; import io.javaoperatorsdk.operator.OperatorException; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.config.LeaderElectionConfiguration; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; @@ -87,14 +87,14 @@ public UpdateControl reconcile(ConfigMap resource, Context private void applyRoleBinding() { var clusterRoleBinding = - ReconcilerUtils.loadYaml( + ReconcilerUtilsInternal.loadYaml( RoleBinding.class, this.getClass(), "leader-elector-stop-noaccess-role-binding.yaml"); adminClient.resource(clusterRoleBinding).createOrReplace(); } private void applyRole() { var role = - ReconcilerUtils.loadYaml( + ReconcilerUtilsInternal.loadYaml( Role.class, this.getClass(), "leader-elector-stop-role-noaccess.yaml"); adminClient.resource(role).createOrReplace(); } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplesecondaryeventsource/MultipleSecondaryEventSourceReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplesecondaryeventsource/MultipleSecondaryEventSourceReconciler.java index 7409d5a5e4..2a11be1faf 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplesecondaryeventsource/MultipleSecondaryEventSourceReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplesecondaryeventsource/MultipleSecondaryEventSourceReconciler.java @@ -53,31 +53,8 @@ public UpdateControl reconcile( Context context) { numberOfExecutions.addAndGet(1); - final var client = context.getClient(); - if (client - .configMaps() - .inNamespace(resource.getMetadata().getNamespace()) - .withName(getName1(resource)) - .get() - == null) { - client - .configMaps() - .inNamespace(resource.getMetadata().getNamespace()) - .resource(configMap(getName1(resource), resource)) - .createOrReplace(); - } - if (client - .configMaps() - .inNamespace(resource.getMetadata().getNamespace()) - .withName(getName2(resource)) - .get() - == null) { - client - .configMaps() - .inNamespace(resource.getMetadata().getNamespace()) - .resource(configMap(getName2(resource), resource)) - .createOrReplace(); - } + context.resourceOperations().serverSideApply(configMap(getName1(resource), resource)); + context.resourceOperations().serverSideApply(configMap(getName2(resource), resource)); if (numberOfExecutions.get() >= 3) { if (context.getSecondaryResources(ConfigMap.class).size() != 2) { diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourceandstatusnossa/PatchResourceAndStatusNoSSAReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourceandstatusnossa/PatchResourceAndStatusNoSSAReconciler.java index e091896597..eb19f9e249 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourceandstatusnossa/PatchResourceAndStatusNoSSAReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourceandstatusnossa/PatchResourceAndStatusNoSSAReconciler.java @@ -45,7 +45,7 @@ public UpdateControl reconcile( Context context) { numberOfExecutions.addAndGet(1); - log.info("Value: " + resource.getSpec().getValue()); + log.info("Value: {}", resource.getSpec().getValue()); if (removeAnnotation) { resource.getMetadata().getAnnotations().remove(TEST_ANNOTATION); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchResourceWithSSAReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchResourceWithSSAReconciler.java index c241c4cd4f..a252115b80 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchResourceWithSSAReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchResourceWithSSAReconciler.java @@ -41,7 +41,8 @@ public UpdateControl reconcile( if (resource.getSpec().getControllerManagedValue() == null) { res.setSpec(new PatchResourceWithSSASpec()); res.getSpec().setControllerManagedValue(ADDED_VALUE); - return UpdateControl.patchResource(res); + // test assumes we will run this in the next reconciliation + return UpdateControl.patchResource(res).reschedule(); } else { res.setStatus(new PatchResourceWithSSAStatus()); res.getStatus().setSuccessfullyReconciled(true); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchWithSSAITBase.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchWithSSAITBase.java index 9f2ca81543..2a8314ecb9 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchWithSSAITBase.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchWithSSAITBase.java @@ -49,6 +49,7 @@ void reconcilerPatchesResourceWithSSA() { .isEqualTo(PatchResourceWithSSAReconciler.ADDED_VALUE); // finalizer is added to the SSA patch in the background by the framework assertThat(actualResource.getMetadata().getFinalizers()).isNotEmpty(); + assertThat(actualResource.getStatus()).isNotNull(); assertThat(actualResource.getStatus().isSuccessfullyReconciled()).isTrue(); // one for resource, one for subresource assertThat( diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/ReconcilerExecutorIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/ReconcilerExecutorIT.java index b6790e4085..54d639c05a 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/ReconcilerExecutorIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/ReconcilerExecutorIT.java @@ -52,7 +52,7 @@ void configMapGetsCreatedForTestCustomResource() { awaitResourcesCreatedOrUpdated(); awaitStatusUpdated(); - assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(2); + assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(1); } @Test diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestReconciler.java index b614b97f3a..974427ba43 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestReconciler.java @@ -16,6 +16,7 @@ package io.javaoperatorsdk.operator.baseapi.simple; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; @@ -25,8 +26,11 @@ import io.fabric8.kubernetes.api.model.ConfigMap; import io.fabric8.kubernetes.api.model.ConfigMapBuilder; import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; @ControllerConfiguration(generationAwareEventProcessing = false) @@ -38,7 +42,7 @@ public class TestReconciler private static final Logger log = LoggerFactory.getLogger(TestReconciler.class); public static final String FINALIZER_NAME = - ReconcilerUtils.getDefaultFinalizerName(TestCustomResource.class); + ReconcilerUtilsInternal.getDefaultFinalizerName(TestCustomResource.class); private final AtomicInteger numberOfExecutions = new AtomicInteger(0); private final AtomicInteger numberOfCleanupExecutions = new AtomicInteger(0); @@ -52,32 +56,6 @@ public void setUpdateStatus(boolean updateStatus) { this.updateStatus = updateStatus; } - @Override - public DeleteControl cleanup(TestCustomResource resource, Context context) { - numberOfCleanupExecutions.incrementAndGet(); - - var statusDetail = - context - .getClient() - .configMaps() - .inNamespace(resource.getMetadata().getNamespace()) - .withName(resource.getSpec().getConfigMapName()) - .delete(); - - if (statusDetail.size() == 1 && statusDetail.get(0).getCauses().isEmpty()) { - log.info( - "Deleted ConfigMap {} for resource: {}", - resource.getSpec().getConfigMapName(), - resource.getMetadata().getName()); - } else { - log.error( - "Failed to delete ConfigMap {} for resource: {}", - resource.getSpec().getConfigMapName(), - resource.getMetadata().getName()); - } - return DeleteControl.defaultDelete(); - } - @Override public UpdateControl reconcile( TestCustomResource resource, Context context) { @@ -85,22 +63,13 @@ public UpdateControl reconcile( if (!resource.getMetadata().getFinalizers().contains(FINALIZER_NAME)) { throw new IllegalStateException("Finalizer is not present."); } - final var kubernetesClient = context.getClient(); - ConfigMap existingConfigMap = - kubernetesClient - .configMaps() - .inNamespace(resource.getMetadata().getNamespace()) - .withName(resource.getSpec().getConfigMapName()) - .get(); + + var existingConfigMap = context.getSecondaryResource(ConfigMap.class).orElse(null); if (existingConfigMap != null) { existingConfigMap.setData(configMapData(resource)); - // existingConfigMap.getMetadata().setResourceVersion(null); - kubernetesClient - .configMaps() - .inNamespace(resource.getMetadata().getNamespace()) - .resource(existingConfigMap) - .createOrReplace(); + log.info("Updating config map"); + context.resourceOperations().serverSideApply(existingConfigMap); } else { Map labels = new HashMap<>(); labels.put("managedBy", TestReconciler.class.getSimpleName()); @@ -114,11 +83,8 @@ public UpdateControl reconcile( .build()) .withData(configMapData(resource)) .build(); - kubernetesClient - .configMaps() - .inNamespace(resource.getMetadata().getNamespace()) - .resource(newConfigMap) - .createOrReplace(); + log.info("Creating config map"); + context.resourceOperations().serverSideApply(newConfigMap); } if (updateStatus) { var statusUpdateResource = new TestCustomResource(); @@ -129,11 +95,49 @@ public UpdateControl reconcile( .build()); resource.setStatus(new TestCustomResourceStatus()); resource.getStatus().setConfigMapStatus("ConfigMap Ready"); + log.info("Patching status"); return UpdateControl.patchStatus(resource); } return UpdateControl.noUpdate(); } + @Override + public DeleteControl cleanup(TestCustomResource resource, Context context) { + numberOfCleanupExecutions.incrementAndGet(); + + var statusDetail = + context + .getClient() + .configMaps() + .inNamespace(resource.getMetadata().getNamespace()) + .withName(resource.getSpec().getConfigMapName()) + .delete(); + + if (statusDetail.size() == 1 && statusDetail.get(0).getCauses().isEmpty()) { + log.info( + "Deleted ConfigMap {} for resource: {}", + resource.getSpec().getConfigMapName(), + resource.getMetadata().getName()); + } else { + log.error( + "Failed to delete ConfigMap {} for resource: {}", + resource.getSpec().getConfigMapName(), + resource.getMetadata().getName()); + } + return DeleteControl.defaultDelete(); + } + + @Override + public List> prepareEventSources( + EventSourceContext context) { + InformerEventSource es = + new InformerEventSource<>( + InformerEventSourceConfiguration.from(ConfigMap.class, TestCustomResource.class) + .build(), + context); + return List.of(es); + } + private Map configMapData(TestCustomResource resource) { Map data = new HashMap<>(); data.put(resource.getSpec().getKey(), resource.getSpec().getValue()); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/specupdate/SSASpecUpdateReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/specupdate/SSASpecUpdateReconciler.java index c1dca492ca..ccdbfdd181 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/specupdate/SSASpecUpdateReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/specupdate/SSASpecUpdateReconciler.java @@ -15,6 +15,9 @@ */ package io.javaoperatorsdk.operator.baseapi.ssaissue.specupdate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; import io.javaoperatorsdk.operator.api.reconciler.Cleaner; import io.javaoperatorsdk.operator.api.reconciler.Context; @@ -27,18 +30,21 @@ public class SSASpecUpdateReconciler implements Reconciler, Cleaner { + private static final Logger log = LoggerFactory.getLogger(SSASpecUpdateReconciler.class); + @Override public UpdateControl reconcile( SSASpecUpdateCustomResource resource, Context context) { var copy = createFreshCopy(resource); copy.getSpec().setValue("value"); - context - .getClient() - .resource(copy) - .fieldManager(context.getControllerConfiguration().fieldManager()) - .serverSideApply(); - + var res = + context + .getClient() + .resource(copy) + .fieldManager(context.getControllerConfiguration().fieldManager()) + .serverSideApply(); + log.info("res: {}", res); return UpdateControl.noUpdate(); } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceUpdateIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceUpdateIT.java index 1ea9ca96ce..a86220439c 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceUpdateIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceUpdateIT.java @@ -59,7 +59,7 @@ void updatesSubResourceStatus() { // wait for sure, there are no more events waitXms(WAIT_AFTER_EXECUTION); // there is no event on status update processed - assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(2); + assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(1); } @Test @@ -73,7 +73,7 @@ void updatesSubResourceStatusNoFinalizer() { // wait for sure, there are no more events waitXms(WAIT_AFTER_EXECUTION); // there is no event on status update processed - assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(2); + assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(1); } /** Note that we check on controller impl if there is finalizer on execution. */ @@ -87,7 +87,7 @@ void ifNoFinalizerPresentFirstAddsTheFinalizerThenExecutesControllerAgain() { // wait for sure, there are no more events waitXms(WAIT_AFTER_EXECUTION); // there is no event on status update processed - assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(2); + assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(1); } /** diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventReconciler.java index 0b8c0ff1e6..f8804bd25d 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventReconciler.java @@ -22,7 +22,6 @@ import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; -import io.javaoperatorsdk.operator.api.reconciler.PrimaryUpdateAndCacheUtils; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; @@ -75,7 +74,7 @@ public UpdateControl reconcile( if (!primary.isMarkedForDeletion() && getUseFinalizer() && !primary.hasFinalizer(FINALIZER)) { log.info("Adding finalizer"); - PrimaryUpdateAndCacheUtils.addFinalizer(context, FINALIZER); + context.resourceOperations().addFinalizer(FINALIZER); return UpdateControl.noUpdate(); } @@ -98,7 +97,7 @@ public UpdateControl reconcile( setEventOnMarkedForDeletion(true); if (getUseFinalizer() && primary.hasFinalizer(FINALIZER)) { log.info("Removing finalizer"); - PrimaryUpdateAndCacheUtils.removeFinalizer(context, FINALIZER); + context.resourceOperations().removeFinalizer(FINALIZER); } } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/finalizerhandling/SelectiveFinalizerHandlingReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/finalizerhandling/SelectiveFinalizerHandlingReconciler.java index 9b3cd5683f..a7bf76a6e7 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/finalizerhandling/SelectiveFinalizerHandlingReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/finalizerhandling/SelectiveFinalizerHandlingReconciler.java @@ -17,7 +17,6 @@ import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; -import io.javaoperatorsdk.operator.api.reconciler.PrimaryUpdateAndCacheUtils; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; @@ -37,11 +36,11 @@ public UpdateControl reconci } if (resource.getSpec().getUseFinalizer()) { - PrimaryUpdateAndCacheUtils.addFinalizer(context, FINALIZER); + context.resourceOperations().addFinalizer(FINALIZER); } if (resource.isMarkedForDeletion()) { - PrimaryUpdateAndCacheUtils.removeFinalizer(context, FINALIZER); + context.resourceOperations().removeFinalizer(FINALIZER); } return UpdateControl.noUpdate(); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/BaseConfigurationServiceTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/BaseConfigurationServiceTest.java index 370f09509f..ffd0f6b904 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/BaseConfigurationServiceTest.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/BaseConfigurationServiceTest.java @@ -29,7 +29,7 @@ import io.fabric8.kubernetes.api.model.ConfigMap; import io.fabric8.kubernetes.api.model.HasMetadata; import io.fabric8.kubernetes.api.model.Service; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.config.AnnotationConfigurable; import io.javaoperatorsdk.operator.api.config.BaseConfigurationService; import io.javaoperatorsdk.operator.api.config.dependent.ConfigurationConverter; @@ -133,13 +133,13 @@ void missingAnnotationCreatesDefaultConfig() { final var reconciler = new MissingAnnotationReconciler(); var config = configFor(reconciler); - assertThat(config.getName()).isEqualTo(ReconcilerUtils.getNameFor(reconciler)); + assertThat(config.getName()).isEqualTo(ReconcilerUtilsInternal.getNameFor(reconciler)); assertThat(config.getRetry()).isInstanceOf(GenericRetry.class); assertThat(config.getRateLimiter()).isInstanceOf(LinearRateLimiter.class); assertThat(config.maxReconciliationInterval()).hasValue(Duration.ofHours(DEFAULT_INTERVAL)); assertThat(config.fieldManager()).isEqualTo(config.getName()); assertThat(config.getFinalizerName()) - .isEqualTo(ReconcilerUtils.getDefaultFinalizerName(config.getResourceClass())); + .isEqualTo(ReconcilerUtilsInternal.getDefaultFinalizerName(config.getResourceClass())); final var informerConfig = config.getInformerConfig(); assertThat(informerConfig.getLabelSelector()).isNull(); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigBindingTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigBindingTest.java new file mode 100644 index 0000000000..384ebb600c --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigBindingTest.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.config.loader; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ConfigBindingTest { + + @Test + void storesKeyTypeAndSetter() { + List calls = new ArrayList<>(); + ConfigBinding, String> binding = + new ConfigBinding<>("my.key", String.class, (list, v) -> list.add(v)); + + assertThat(binding.key()).isEqualTo("my.key"); + assertThat(binding.type()).isEqualTo(String.class); + + binding.setter().accept(calls, "hello"); + assertThat(calls).containsExactly("hello"); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java new file mode 100644 index 0000000000..1fc1ebe98f --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java @@ -0,0 +1,558 @@ +/* + * 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.config.loader; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; + +import io.javaoperatorsdk.operator.api.config.BaseConfigurationService; +import io.javaoperatorsdk.operator.api.config.ConfigurationService; +import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; +import io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +class ConfigLoaderTest { + + // A simple ConfigProvider backed by a plain map for test control. + private static ConfigProvider mapProvider(Map values) { + return new ConfigProvider() { + @Override + @SuppressWarnings("unchecked") + public Optional getValue(String key, Class type) { + return Optional.ofNullable((T) values.get(key)); + } + }; + } + + @Test + void applyConfigsReturnsNoOpWhenNothingConfigured() { + var loader = new ConfigLoader(mapProvider(Map.of())); + var base = new BaseConfigurationService(null); + // consumer must be non-null and must leave all defaults unchanged + var consumer = loader.applyConfigs(); + assertThat(consumer).isNotNull(); + var result = ConfigurationService.newOverriddenConfigurationService(base, consumer); + assertThat(result.concurrentReconciliationThreads()) + .isEqualTo(base.concurrentReconciliationThreads()); + assertThat(result.concurrentWorkflowExecutorThreads()) + .isEqualTo(base.concurrentWorkflowExecutorThreads()); + } + + @Test + void applyConfigsAppliesConcurrentReconciliationThreads() { + var loader = + new ConfigLoader(mapProvider(Map.of("josdk.reconciliation.concurrent-threads", 7))); + + var base = new BaseConfigurationService(null); + var result = + ConfigurationService.newOverriddenConfigurationService(base, loader.applyConfigs()); + + assertThat(result.concurrentReconciliationThreads()).isEqualTo(7); + } + + @Test + void applyConfigsAppliesConcurrentWorkflowExecutorThreads() { + var loader = new ConfigLoader(mapProvider(Map.of("josdk.workflow.executor-threads", 3))); + + var base = new BaseConfigurationService(null); + var result = + ConfigurationService.newOverriddenConfigurationService(base, loader.applyConfigs()); + + assertThat(result.concurrentWorkflowExecutorThreads()).isEqualTo(3); + } + + @Test + void applyConfigsAppliesBooleanFlags() { + var values = new HashMap(); + values.put("josdk.check-crd", true); + values.put("josdk.close-client-on-stop", false); + values.put("josdk.informer.stop-on-error-during-startup", false); + values.put("josdk.dependent-resources.ssa-based-create-update-match", false); + values.put("josdk.use-ssa-to-patch-primary-resource", false); + values.put("josdk.clone-secondary-resources-when-getting-from-cache", true); + var loader = new ConfigLoader(mapProvider(values)); + + var base = new BaseConfigurationService(null); + var result = + ConfigurationService.newOverriddenConfigurationService(base, loader.applyConfigs()); + + assertThat(result.checkCRDAndValidateLocalModel()).isTrue(); + assertThat(result.closeClientOnStop()).isFalse(); + assertThat(result.stopOnInformerErrorDuringStartup()).isFalse(); + assertThat(result.ssaBasedCreateUpdateMatchForDependentResources()).isFalse(); + assertThat(result.useSSAToPatchPrimaryResource()).isFalse(); + assertThat(result.cloneSecondaryResourcesWhenGettingFromCache()).isTrue(); + } + + @Test + void applyConfigsAppliesDurations() { + var values = new HashMap(); + values.put("josdk.informer.cache-sync-timeout", Duration.ofSeconds(10)); + values.put("josdk.reconciliation.termination-timeout", Duration.ofSeconds(5)); + var loader = new ConfigLoader(mapProvider(values)); + + var base = new BaseConfigurationService(null); + var result = + ConfigurationService.newOverriddenConfigurationService(base, loader.applyConfigs()); + + assertThat(result.cacheSyncTimeout()).isEqualTo(Duration.ofSeconds(10)); + assertThat(result.reconciliationTerminationTimeout()).isEqualTo(Duration.ofSeconds(5)); + } + + @Test + void applyConfigsOnlyAppliesPresentKeys() { + // Only one key present — other defaults must be unchanged. + var loader = + new ConfigLoader(mapProvider(Map.of("josdk.reconciliation.concurrent-threads", 12))); + + var base = new BaseConfigurationService(null); + var result = + ConfigurationService.newOverriddenConfigurationService(base, loader.applyConfigs()); + + assertThat(result.concurrentReconciliationThreads()).isEqualTo(12); + // Default unchanged + assertThat(result.concurrentWorkflowExecutorThreads()) + .isEqualTo(base.concurrentWorkflowExecutorThreads()); + } + + // -- applyControllerConfigs ------------------------------------------------- + + @Test + void applyControllerConfigsReturnsNoOpWhenNothingConfigured() { + var loader = new ConfigLoader(mapProvider(Map.of())); + assertThat(loader.applyControllerConfigs("my-controller")).isNotNull(); + } + + @Test + void applyControllerConfigsQueriesKeysPrefixedWithControllerName() { + // Record every key the loader asks for, regardless of whether a value exists. + var queriedKeys = new ArrayList(); + ConfigProvider recordingProvider = + new ConfigProvider() { + @Override + public Optional getValue(String key, Class type) { + queriedKeys.add(key); + return Optional.empty(); + } + }; + + new ConfigLoader(recordingProvider).applyControllerConfigs("my-ctrl"); + + assertThat(queriedKeys).allMatch(k -> k.startsWith("josdk.controller.my-ctrl.")); + } + + @Test + void applyControllerConfigsIsolatesControllersByName() { + // Two controllers configured in the same provider — only matching keys must be returned. + var values = new HashMap(); + values.put("josdk.controller.alpha.finalizer", "alpha-finalizer"); + values.put("josdk.controller.beta.finalizer", "beta-finalizer"); + var loader = new ConfigLoader(mapProvider(values)); + + // alpha gets a consumer (key found), beta gets a consumer (key found) + assertThat(loader.applyControllerConfigs("alpha")).isNotNull(); + assertThat(loader.applyControllerConfigs("beta")).isNotNull(); + // a controller with no configured keys still gets a non-null no-op consumer + assertThat(loader.applyControllerConfigs("gamma")).isNotNull(); + } + + @Test + void applyControllerConfigsQueriesAllExpectedPropertySuffixes() { + var queriedKeys = new ArrayList(); + ConfigProvider recordingProvider = + new ConfigProvider() { + @Override + public Optional getValue(String key, Class type) { + queriedKeys.add(key); + return Optional.empty(); + } + }; + + new ConfigLoader(recordingProvider).applyControllerConfigs("ctrl"); + + assertThat(queriedKeys) + .contains( + "josdk.controller.ctrl.finalizer", + "josdk.controller.ctrl.generation-aware", + "josdk.controller.ctrl.label-selector", + "josdk.controller.ctrl.max-reconciliation-interval", + "josdk.controller.ctrl.field-manager", + "josdk.controller.ctrl.trigger-reconciler-on-all-events", + "josdk.controller.ctrl.informer.label-selector", + "josdk.controller.ctrl.informer.list-limit", + "josdk.controller.ctrl.rate-limiter.refresh-period", + "josdk.controller.ctrl.rate-limiter.limit-for-period"); + } + + @Test + void operatorKeyPrefixIsJosdkDot() { + assertThat(ConfigLoader.DEFAULT_OPERATOR_KEY_PREFIX).isEqualTo("josdk."); + } + + @Test + void controllerKeyPrefixIsJosdkControllerDot() { + assertThat(ConfigLoader.DEFAULT_CONTROLLER_KEY_PREFIX).isEqualTo("josdk.controller."); + } + + // -- rate limiter ----------------------------------------------------------- + + @Test + void rateLimiterQueriesExpectedKeys() { + var queriedKeys = new ArrayList(); + ConfigProvider recordingProvider = + new ConfigProvider() { + @Override + public Optional getValue(String key, Class type) { + queriedKeys.add(key); + return Optional.empty(); + } + }; + new ConfigLoader(recordingProvider).applyControllerConfigs("ctrl"); + assertThat(queriedKeys) + .contains( + "josdk.controller.ctrl.rate-limiter.refresh-period", + "josdk.controller.ctrl.rate-limiter.limit-for-period"); + } + + // -- binding coverage ------------------------------------------------------- + + /** + * Supported scalar types that AgregatePriorityListConfigProvider can parse from a string. Every + * binding's type must be one of these. + */ + private static final Set> SUPPORTED_TYPES = + Set.of( + Boolean.class, + boolean.class, + Integer.class, + int.class, + Long.class, + long.class, + Double.class, + double.class, + Duration.class, + String.class); + + @Test + void operatorBindingsCoverAllSingleScalarSettersOnConfigurationServiceOverrider() { + Set expectedSetters = + Arrays.stream(ConfigurationServiceOverrider.class.getMethods()) + .filter(m -> m.getParameterCount() == 1) + .filter(m -> SUPPORTED_TYPES.contains(m.getParameterTypes()[0])) + .filter(m -> m.getReturnType() == ConfigurationServiceOverrider.class) + .map(java.lang.reflect.Method::getName) + .collect(Collectors.toSet()); + + Set boundMethodNames = + ConfigLoader.OPERATOR_BINDINGS.stream() + .flatMap( + b -> + Arrays.stream(ConfigurationServiceOverrider.class.getMethods()) + .filter(m -> m.getParameterCount() == 1) + .filter(m -> isTypeCompatible(m.getParameterTypes()[0], b.type())) + .filter(m -> m.getReturnType() == ConfigurationServiceOverrider.class) + .map(java.lang.reflect.Method::getName)) + .collect(Collectors.toSet()); + + assertThat(boundMethodNames) + .as("Every scalar setter on ConfigurationServiceOverrider must be covered by a binding") + .containsExactlyInAnyOrderElementsOf(expectedSetters); + } + + @Test + void controllerBindingsCoverAllSingleScalarSettersOnControllerConfigurationOverrider() { + Set expectedSetters = + Arrays.stream(ControllerConfigurationOverrider.class.getMethods()) + .filter(m -> m.getParameterCount() == 1) + .filter(m -> SUPPORTED_TYPES.contains(m.getParameterTypes()[0])) + .filter(m -> m.getReturnType() == ControllerConfigurationOverrider.class) + .filter(m -> m.getAnnotation(Deprecated.class) == null) + .map(java.lang.reflect.Method::getName) + .collect(Collectors.toSet()); + + Set boundMethodNames = + ConfigLoader.CONTROLLER_BINDINGS.stream() + .flatMap( + b -> + Arrays.stream(ControllerConfigurationOverrider.class.getMethods()) + .filter(m -> m.getParameterCount() == 1) + .filter(m -> isTypeCompatible(m.getParameterTypes()[0], b.type())) + .filter(m -> m.getReturnType() == ControllerConfigurationOverrider.class) + .filter(m -> m.getAnnotation(Deprecated.class) == null) + .map(java.lang.reflect.Method::getName)) + .collect(Collectors.toSet()); + + assertThat(boundMethodNames) + .as( + "Every scalar setter on ControllerConfigurationOverrider should be covered by a" + + " binding") + .containsExactlyInAnyOrderElementsOf(expectedSetters); + } + + // -- leader election -------------------------------------------------------- + + @Test + void leaderElectionIsNotConfiguredWhenNoPropertiesPresent() { + var loader = new ConfigLoader(mapProvider(Map.of())); + var base = new BaseConfigurationService(null); + var result = + ConfigurationService.newOverriddenConfigurationService(base, loader.applyConfigs()); + assertThat(result.getLeaderElectionConfiguration()).isEmpty(); + } + + @Test + void leaderElectionIsNotConfiguredWhenExplicitlyDisabled() { + var values = new HashMap(); + values.put("josdk.leader-election.enabled", false); + values.put("josdk.leader-election.lease-name", "my-lease"); + var loader = new ConfigLoader(mapProvider(values)); + var base = new BaseConfigurationService(null); + var result = + ConfigurationService.newOverriddenConfigurationService(base, loader.applyConfigs()); + assertThat(result.getLeaderElectionConfiguration()).isEmpty(); + } + + @Test + void leaderElectionConfiguredWithLeaseNameOnly() { + var loader = + new ConfigLoader(mapProvider(Map.of("josdk.leader-election.lease-name", "my-lease"))); + var base = new BaseConfigurationService(null); + var result = + ConfigurationService.newOverriddenConfigurationService(base, loader.applyConfigs()); + assertThat(result.getLeaderElectionConfiguration()) + .hasValueSatisfying( + le -> { + assertThat(le.getLeaseName()).isEqualTo("my-lease"); + assertThat(le.getLeaseNamespace()).isEmpty(); + assertThat(le.getIdentity()).isEmpty(); + }); + } + + @Test + void leaderElectionConfiguredWithAllProperties() { + var values = new HashMap(); + values.put("josdk.leader-election.enabled", true); + values.put("josdk.leader-election.lease-name", "my-lease"); + values.put("josdk.leader-election.lease-namespace", "my-ns"); + values.put("josdk.leader-election.identity", "pod-1"); + values.put("josdk.leader-election.lease-duration", Duration.ofSeconds(20)); + values.put("josdk.leader-election.renew-deadline", Duration.ofSeconds(15)); + values.put("josdk.leader-election.retry-period", Duration.ofSeconds(3)); + var loader = new ConfigLoader(mapProvider(values)); + + var base = new BaseConfigurationService(null); + var result = + ConfigurationService.newOverriddenConfigurationService(base, loader.applyConfigs()); + + assertThat(result.getLeaderElectionConfiguration()) + .hasValueSatisfying( + le -> { + assertThat(le.getLeaseName()).isEqualTo("my-lease"); + assertThat(le.getLeaseNamespace()).hasValue("my-ns"); + assertThat(le.getIdentity()).hasValue("pod-1"); + assertThat(le.getLeaseDuration()).isEqualTo(Duration.ofSeconds(20)); + assertThat(le.getRenewDeadline()).isEqualTo(Duration.ofSeconds(15)); + assertThat(le.getRetryPeriod()).isEqualTo(Duration.ofSeconds(3)); + }); + } + + @Test + void leaderElectionMissingLeaseNameThrowsWhenOtherPropertiesPresent() { + var loader = + new ConfigLoader(mapProvider(Map.of("josdk.leader-election.lease-namespace", "my-ns"))); + var base = new BaseConfigurationService(null); + var consumer = loader.applyConfigs(); + assertThatExceptionOfType(IllegalStateException.class) + .isThrownBy(() -> ConfigurationService.newOverriddenConfigurationService(base, consumer)) + .withMessageContaining("lease-name"); + } + + // -- retry ------------------------------------------------------------------ + + /** A minimal reconciler used to obtain a base ControllerConfiguration in retry tests. */ + @io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration + private static class DummyReconciler + implements io.javaoperatorsdk.operator.api.reconciler.Reconciler< + io.fabric8.kubernetes.api.model.ConfigMap> { + @Override + public io.javaoperatorsdk.operator.api.reconciler.UpdateControl< + io.fabric8.kubernetes.api.model.ConfigMap> + reconcile( + io.fabric8.kubernetes.api.model.ConfigMap r, + io.javaoperatorsdk.operator.api.reconciler.Context< + io.fabric8.kubernetes.api.model.ConfigMap> + ctx) { + return io.javaoperatorsdk.operator.api.reconciler.UpdateControl.noUpdate(); + } + } + + private static io.javaoperatorsdk.operator.api.config.ControllerConfiguration< + io.fabric8.kubernetes.api.model.ConfigMap> + baseControllerConfig() { + return new BaseConfigurationService().getConfigurationFor(new DummyReconciler()); + } + + private static io.javaoperatorsdk.operator.processing.retry.GenericRetry applyAndGetRetry( + java.util.function.Consumer< + io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider< + io.fabric8.kubernetes.api.model.ConfigMap>> + consumer) { + var overrider = + io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider.override( + baseControllerConfig()); + consumer.accept(overrider); + return (io.javaoperatorsdk.operator.processing.retry.GenericRetry) overrider.build().getRetry(); + } + + @Test + void retryIsNotConfiguredWhenNoRetryPropertiesPresent() { + var loader = new ConfigLoader(mapProvider(Map.of())); + var consumer = loader.applyControllerConfigs("ctrl"); + var overrider = + io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider.override( + baseControllerConfig()); + consumer.accept(overrider); + // no retry property set → retry stays at the controller's default (null or unchanged) + var result = overrider.build(); + // The consumer must not throw and the config is buildable + assertThat(result).isNotNull(); + } + + @Test + void retryQueriesExpectedKeys() { + var queriedKeys = new ArrayList(); + ConfigProvider recordingProvider = + new ConfigProvider() { + @Override + public Optional getValue(String key, Class type) { + queriedKeys.add(key); + return Optional.empty(); + } + }; + new ConfigLoader(recordingProvider).applyControllerConfigs("ctrl"); + assertThat(queriedKeys) + .contains( + "josdk.controller.ctrl.retry.max-attempts", + "josdk.controller.ctrl.retry.initial-interval", + "josdk.controller.ctrl.retry.interval-multiplier", + "josdk.controller.ctrl.retry.max-interval"); + } + + @Test + void retryMaxAttemptsIsApplied() { + var loader = + new ConfigLoader(mapProvider(Map.of("josdk.controller.ctrl.retry.max-attempts", 10))); + var retry = applyAndGetRetry(loader.applyControllerConfigs("ctrl")); + assertThat(retry.getMaxAttempts()).isEqualTo(10); + // other fields stay at their defaults + assertThat(retry.getInitialInterval()) + .isEqualTo( + io.javaoperatorsdk.operator.processing.retry.GenericRetry + .defaultLimitedExponentialRetry() + .getInitialInterval()); + } + + @Test + void retryInitialIntervalIsApplied() { + var loader = + new ConfigLoader(mapProvider(Map.of("josdk.controller.ctrl.retry.initial-interval", 500L))); + var retry = applyAndGetRetry(loader.applyControllerConfigs("ctrl")); + assertThat(retry.getInitialInterval()).isEqualTo(500L); + } + + @Test + void retryIntervalMultiplierIsApplied() { + var loader = + new ConfigLoader( + mapProvider(Map.of("josdk.controller.ctrl.retry.interval-multiplier", 2.0))); + var retry = applyAndGetRetry(loader.applyControllerConfigs("ctrl")); + assertThat(retry.getIntervalMultiplier()).isEqualTo(2.0); + } + + @Test + void retryMaxIntervalIsApplied() { + var loader = + new ConfigLoader(mapProvider(Map.of("josdk.controller.ctrl.retry.max-interval", 30000L))); + var retry = applyAndGetRetry(loader.applyControllerConfigs("ctrl")); + assertThat(retry.getMaxInterval()).isEqualTo(30000L); + } + + @Test + void retryAllPropertiesApplied() { + var values = new HashMap(); + values.put("josdk.controller.ctrl.retry.max-attempts", 7); + values.put("josdk.controller.ctrl.retry.initial-interval", 1000L); + values.put("josdk.controller.ctrl.retry.interval-multiplier", 3.0); + values.put("josdk.controller.ctrl.retry.max-interval", 60000L); + var loader = new ConfigLoader(mapProvider(values)); + var retry = applyAndGetRetry(loader.applyControllerConfigs("ctrl")); + assertThat(retry.getMaxAttempts()).isEqualTo(7); + assertThat(retry.getInitialInterval()).isEqualTo(1000L); + assertThat(retry.getIntervalMultiplier()).isEqualTo(3.0); + assertThat(retry.getMaxInterval()).isEqualTo(60000L); + } + + @Test + void retryStartsFromDefaultLimitedExponentialRetryDefaults() { + // Only max-attempts is overridden — other fields must still be the defaults. + var defaults = + io.javaoperatorsdk.operator.processing.retry.GenericRetry.defaultLimitedExponentialRetry(); + var loader = + new ConfigLoader(mapProvider(Map.of("josdk.controller.ctrl.retry.max-attempts", 3))); + var retry = applyAndGetRetry(loader.applyControllerConfigs("ctrl")); + assertThat(retry.getMaxAttempts()).isEqualTo(3); + assertThat(retry.getInitialInterval()).isEqualTo(defaults.getInitialInterval()); + assertThat(retry.getIntervalMultiplier()).isEqualTo(defaults.getIntervalMultiplier()); + assertThat(retry.getMaxInterval()).isEqualTo(defaults.getMaxInterval()); + } + + @Test + void retryIsIsolatedPerControllerName() { + var values = new HashMap(); + values.put("josdk.controller.alpha.retry.max-attempts", 4); + values.put("josdk.controller.beta.retry.max-attempts", 9); + var loader = new ConfigLoader(mapProvider(values)); + + var alphaRetry = applyAndGetRetry(loader.applyControllerConfigs("alpha")); + var betaRetry = applyAndGetRetry(loader.applyControllerConfigs("beta")); + + assertThat(alphaRetry.getMaxAttempts()).isEqualTo(4); + assertThat(betaRetry.getMaxAttempts()).isEqualTo(9); + } + + private static boolean isTypeCompatible(Class methodParam, Class bindingType) { + if (methodParam == bindingType) return true; + if (methodParam == boolean.class && bindingType == Boolean.class) return true; + if (methodParam == Boolean.class && bindingType == boolean.class) return true; + if (methodParam == int.class && bindingType == Integer.class) return true; + if (methodParam == Integer.class && bindingType == int.class) return true; + if (methodParam == long.class && bindingType == Long.class) return true; + if (methodParam == Long.class && bindingType == long.class) return true; + if (methodParam == double.class && bindingType == Double.class) return true; + if (methodParam == Double.class && bindingType == double.class) return true; + return false; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/EnvVarConfigProviderTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/EnvVarConfigProviderTest.java new file mode 100644 index 0000000000..3a4d07dd60 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/EnvVarConfigProviderTest.java @@ -0,0 +1,62 @@ +/* + * 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.config.loader.provider; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +class EnvVarConfigProviderTest { + + @Test + void returnsEmptyWhenEnvVariableAbsent() { + var provider = new EnvVarConfigProvider(k -> null); + assertThat(provider.getValue("josdk.no.such.key", String.class)).isEmpty(); + } + + @Test + void returnsEmptyForNullKey() { + var provider = new EnvVarConfigProvider(k -> "value"); + assertThat(provider.getValue(null, String.class)).isEmpty(); + } + + @Test + void readsStringFromEnvVariable() { + var provider = new EnvVarConfigProvider(k -> k.equals("JOSDK_TEST_STRING") ? "from-env" : null); + assertThat(provider.getValue("josdk.test.string", String.class)).hasValue("from-env"); + } + + @Test + void convertsDotsAndHyphensToUnderscoresAndUppercases() { + var provider = + new EnvVarConfigProvider(k -> k.equals("JOSDK_CACHE_SYNC_TIMEOUT") ? "PT10S" : null); + assertThat(provider.getValue("josdk.cache-sync.timeout", Duration.class)) + .hasValue(Duration.ofSeconds(10)); + } + + @Test + void throwsForUnsupportedType() { + var provider = + new EnvVarConfigProvider(k -> k.equals("JOSDK_TEST_UNSUPPORTED") ? "value" : null); + assertThatIllegalArgumentException() + .isThrownBy(() -> provider.getValue("josdk.test.unsupported", AtomicInteger.class)) + .withMessageContaining("Unsupported config type"); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/PriorityListConfigProviderTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/PriorityListConfigProviderTest.java new file mode 100644 index 0000000000..ad2a332868 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/PriorityListConfigProviderTest.java @@ -0,0 +1,72 @@ +/* + * 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.config.loader.provider; + +import java.util.List; +import java.util.Properties; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class PriorityListConfigProviderTest { + + private static PropertiesConfigProvider propsProvider(String key, String value) { + Properties props = new Properties(); + if (key != null) { + props.setProperty(key, value); + } + return new PropertiesConfigProvider(props); + } + + @Test + void returnsEmptyWhenAllProvidersReturnEmpty() { + var provider = + new AgregatePriorityListConfigProvider( + List.of(new EnvVarConfigProvider(k -> null), propsProvider(null, null))); + assertThat(provider.getValue("josdk.no.such.key", String.class)).isEmpty(); + } + + @Test + void firstProviderWins() { + var provider = + new AgregatePriorityListConfigProvider( + List.of( + new EnvVarConfigProvider(k -> k.equals("JOSDK_TEST_KEY") ? "first" : null), + propsProvider("josdk.test.key", "second"))); + assertThat(provider.getValue("josdk.test.key", String.class)).hasValue("first"); + } + + @Test + void fallsBackToLaterProviderWhenEarlierReturnsEmpty() { + var provider = + new AgregatePriorityListConfigProvider( + List.of( + new EnvVarConfigProvider(k -> null), + propsProvider("josdk.test.key", "from-second"))); + assertThat(provider.getValue("josdk.test.key", String.class)).hasValue("from-second"); + } + + @Test + void respectsOrderWithThreeProviders() { + var first = new EnvVarConfigProvider(k -> null); + var second = propsProvider("josdk.test.key", "from-second"); + var third = new EnvVarConfigProvider(k -> k.equals("JOSDK_TEST_KEY") ? "from-third" : null); + + var provider = new AgregatePriorityListConfigProvider(List.of(first, second, third)); + assertThat(provider.getValue("josdk.test.key", String.class)).hasValue("from-second"); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/PropertiesConfigProviderTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/PropertiesConfigProviderTest.java new file mode 100644 index 0000000000..c44534eb3a --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/PropertiesConfigProviderTest.java @@ -0,0 +1,129 @@ +/* + * 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.config.loader.provider; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Properties; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +class PropertiesConfigProviderTest { + + // -- Properties constructor ------------------------------------------------- + + @Test + void returnsEmptyWhenKeyAbsent() { + var provider = new PropertiesConfigProvider(new Properties()); + assertThat(provider.getValue("josdk.no.such.key", String.class)).isEmpty(); + } + + @Test + void returnsEmptyForNullKey() { + var props = new Properties(); + props.setProperty("josdk.test.key", "value"); + var provider = new PropertiesConfigProvider(props); + assertThat(provider.getValue(null, String.class)).isEmpty(); + } + + @Test + void readsString() { + var props = new Properties(); + props.setProperty("josdk.test.string", "hello"); + var provider = new PropertiesConfigProvider(props); + assertThat(provider.getValue("josdk.test.string", String.class)).hasValue("hello"); + } + + @Test + void readsBoolean() { + var props = new Properties(); + props.setProperty("josdk.test.bool", "true"); + var provider = new PropertiesConfigProvider(props); + assertThat(provider.getValue("josdk.test.bool", Boolean.class)).hasValue(true); + } + + @Test + void readsInteger() { + var props = new Properties(); + props.setProperty("josdk.test.integer", "42"); + var provider = new PropertiesConfigProvider(props); + assertThat(provider.getValue("josdk.test.integer", Integer.class)).hasValue(42); + } + + @Test + void readsLong() { + var props = new Properties(); + props.setProperty("josdk.test.long", "123456789"); + var provider = new PropertiesConfigProvider(props); + assertThat(provider.getValue("josdk.test.long", Long.class)).hasValue(123456789L); + } + + @Test + void readsDouble() { + var props = new Properties(); + props.setProperty("josdk.test.double", "3.14"); + var provider = new PropertiesConfigProvider(props); + assertThat(provider.getValue("josdk.test.double", Double.class)).hasValue(3.14); + } + + @Test + void readsDuration() { + var props = new Properties(); + props.setProperty("josdk.test.duration", "PT30S"); + var provider = new PropertiesConfigProvider(props); + assertThat(provider.getValue("josdk.test.duration", Duration.class)) + .hasValue(Duration.ofSeconds(30)); + } + + @Test + void throwsForUnsupportedType() { + var props = new Properties(); + props.setProperty("josdk.test.unsupported", "value"); + var provider = new PropertiesConfigProvider(props); + assertThatIllegalArgumentException() + .isThrownBy(() -> provider.getValue("josdk.test.unsupported", AtomicInteger.class)) + .withMessageContaining("Unsupported config type"); + } + + // -- Path constructor ------------------------------------------------------- + + @Test + void loadsFromFile(@TempDir Path dir) throws IOException { + Path file = dir.resolve("test.properties"); + Files.writeString(file, "josdk.test.string=from-file\njosdk.test.integer=7\n"); + + var provider = new PropertiesConfigProvider(file); + assertThat(provider.getValue("josdk.test.string", String.class)).hasValue("from-file"); + assertThat(provider.getValue("josdk.test.integer", Integer.class)).hasValue(7); + } + + @Test + void throwsUncheckedIOExceptionForMissingFile(@TempDir Path dir) { + Path missing = dir.resolve("does-not-exist.properties"); + assertThatExceptionOfType(UncheckedIOException.class) + .isThrownBy(() -> new PropertiesConfigProvider(missing)) + .withMessageContaining("does-not-exist.properties"); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/YamlConfigProviderTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/YamlConfigProviderTest.java new file mode 100644 index 0000000000..4f8c53ac38 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/YamlConfigProviderTest.java @@ -0,0 +1,144 @@ +/* + * 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.config.loader.provider; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +class YamlConfigProviderTest { + + // -- Map constructor -------------------------------------------------------- + + @Test + void returnsEmptyWhenKeyAbsent() { + var provider = new YamlConfigProvider(Map.of()); + assertThat(provider.getValue("josdk.no.such.key", String.class)).isEmpty(); + } + + @Test + void returnsEmptyForNullKey() { + var provider = new YamlConfigProvider(Map.of("josdk", Map.of("test", "value"))); + assertThat(provider.getValue(null, String.class)).isEmpty(); + } + + @Test + void readsTopLevelString() { + var provider = new YamlConfigProvider(Map.of("key", "hello")); + assertThat(provider.getValue("key", String.class)).hasValue("hello"); + } + + @Test + void readsNestedString() { + var provider = + new YamlConfigProvider(Map.of("josdk", Map.of("test", Map.of("string", "hello")))); + assertThat(provider.getValue("josdk.test.string", String.class)).hasValue("hello"); + } + + @Test + void readsBoolean() { + var provider = new YamlConfigProvider(Map.of("josdk", Map.of("test", Map.of("bool", "true")))); + assertThat(provider.getValue("josdk.test.bool", Boolean.class)).hasValue(true); + } + + @Test + void readsInteger() { + var provider = new YamlConfigProvider(Map.of("josdk", Map.of("test", Map.of("integer", 42)))); + assertThat(provider.getValue("josdk.test.integer", Integer.class)).hasValue(42); + } + + @Test + void readsLong() { + var provider = + new YamlConfigProvider(Map.of("josdk", Map.of("test", Map.of("long", 123456789L)))); + assertThat(provider.getValue("josdk.test.long", Long.class)).hasValue(123456789L); + } + + @Test + void readsDouble() { + var provider = + new YamlConfigProvider(Map.of("josdk", Map.of("test", Map.of("double", "3.14")))); + assertThat(provider.getValue("josdk.test.double", Double.class)).hasValue(3.14); + } + + @Test + void readsDuration() { + var provider = + new YamlConfigProvider(Map.of("josdk", Map.of("test", Map.of("duration", "PT30S")))); + assertThat(provider.getValue("josdk.test.duration", Duration.class)) + .hasValue(Duration.ofSeconds(30)); + } + + @Test + void returnsEmptyWhenIntermediateSegmentMissing() { + var provider = new YamlConfigProvider(Map.of("josdk", Map.of("other", "value"))); + assertThat(provider.getValue("josdk.test.key", String.class)).isEmpty(); + } + + @Test + void returnsEmptyWhenIntermediateSegmentIsLeaf() { + // "josdk.test" is a leaf – trying to drill further should return empty + var provider = new YamlConfigProvider(Map.of("josdk", Map.of("test", "leaf"))); + assertThat(provider.getValue("josdk.test.key", String.class)).isEmpty(); + } + + @Test + void throwsForUnsupportedType() { + var provider = + new YamlConfigProvider(Map.of("josdk", Map.of("test", Map.of("unsupported", "value")))); + assertThatIllegalArgumentException() + .isThrownBy(() -> provider.getValue("josdk.test.unsupported", AtomicInteger.class)) + .withMessageContaining("Unsupported config type"); + } + + // -- Path constructor ------------------------------------------------------- + + @Test + void loadsFromFile(@TempDir Path dir) throws IOException { + Path file = dir.resolve("test.yaml"); + Files.writeString( + file, + """ + josdk: + test: + string: from-file + integer: 7 + """); + + var provider = new YamlConfigProvider(file); + assertThat(provider.getValue("josdk.test.string", String.class)).hasValue("from-file"); + assertThat(provider.getValue("josdk.test.integer", Integer.class)).hasValue(7); + } + + @Test + void throwsUncheckedIOExceptionForMissingFile(@TempDir Path dir) { + Path missing = dir.resolve("does-not-exist.yaml"); + assertThatExceptionOfType(UncheckedIOException.class) + .isThrownBy(() -> new YamlConfigProvider(missing)) + .withMessageContaining("does-not-exist.yaml"); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/runtime/DefaultConfigurationServiceTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/runtime/DefaultConfigurationServiceTest.java index 1b328ccaf9..fa31575b9e 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/runtime/DefaultConfigurationServiceTest.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/runtime/DefaultConfigurationServiceTest.java @@ -20,7 +20,7 @@ import io.fabric8.kubernetes.client.CustomResource; import io.fabric8.kubernetes.model.annotation.Group; import io.fabric8.kubernetes.model.annotation.Version; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; @@ -40,7 +40,7 @@ void returnsValuesFromControllerAnnotationFinalizer() { assertEquals( CustomResource.getCRDName(TestCustomResource.class), configuration.getResourceTypeName()); assertEquals( - ReconcilerUtils.getDefaultFinalizerName(TestCustomResource.class), + ReconcilerUtilsInternal.getDefaultFinalizerName(TestCustomResource.class), configuration.getFinalizerName()); assertEquals(TestCustomResource.class, configuration.getResourceClass()); assertFalse(configuration.isGenerationAware()); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateReconciler.java index de485cfc4e..4f4cab80d7 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateReconciler.java @@ -104,13 +104,13 @@ private void createExternalResource( .withData(Map.of(ID_KEY, createdResource.getId())) .build(); configMap.addOwnerReference(resource); - context.getClient().configMaps().resource(configMap).create(); var primaryID = ResourceID.fromResource(resource); // Making sure that the created resources are in the cache for the next reconciliation. // This is critical in this case, since on next reconciliation if it would not be in the cache // it would be created again. - configMapEventSource.handleRecentResourceCreate(primaryID, configMap); + configMapEventSource.eventFilteringUpdateAndCacheResource( + configMap, toCreate -> context.resourceOperations().serverSideApply(toCreate)); externalResourceEventSource.handleRecentResourceCreate(primaryID, createdResource); } @@ -128,6 +128,7 @@ public DeleteControl cleanup( return DeleteControl.defaultDelete(); } + @Override public int getNumberOfExecutions() { return numberOfExecutions.get(); } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/InformerRelatedBehaviorITS.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/InformerRelatedBehaviorITS.java index 221d7363a3..ce98af58e0 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/InformerRelatedBehaviorITS.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/InformerRelatedBehaviorITS.java @@ -34,7 +34,7 @@ import io.fabric8.kubernetes.client.utils.KubernetesResourceUtil; import io.javaoperatorsdk.operator.Operator; import io.javaoperatorsdk.operator.OperatorException; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.health.InformerHealthIndicator; import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; import io.javaoperatorsdk.operator.processing.event.source.controller.ControllerEventSource; @@ -399,23 +399,25 @@ private void setFullResourcesAccess() { private void addRoleBindingsToTestNamespaces() { var role = - ReconcilerUtils.loadYaml(Role.class, this.getClass(), "rbac-test-only-main-ns-access.yaml"); + ReconcilerUtilsInternal.loadYaml( + Role.class, this.getClass(), "rbac-test-only-main-ns-access.yaml"); adminClient.resource(role).inNamespace(actualNamespace).createOrReplace(); var roleBinding = - ReconcilerUtils.loadYaml( + ReconcilerUtilsInternal.loadYaml( RoleBinding.class, this.getClass(), "rbac-test-only-main-ns-access-binding.yaml"); adminClient.resource(roleBinding).inNamespace(actualNamespace).createOrReplace(); } private void applyClusterRoleBinding() { var clusterRoleBinding = - ReconcilerUtils.loadYaml( + ReconcilerUtilsInternal.loadYaml( ClusterRoleBinding.class, this.getClass(), "rbac-test-role-binding.yaml"); adminClient.resource(clusterRoleBinding).createOrReplace(); } private void applyClusterRole(String filename) { - var clusterRole = ReconcilerUtils.loadYaml(ClusterRole.class, this.getClass(), filename); + var clusterRole = + ReconcilerUtilsInternal.loadYaml(ClusterRole.class, this.getClass(), filename); adminClient.resource(clusterRole).createOrReplace(); } @@ -431,7 +433,7 @@ private Namespace namespace(String name) { private void removeClusterRoleBinding() { var clusterRoleBinding = - ReconcilerUtils.loadYaml( + ReconcilerUtilsInternal.loadYaml( ClusterRoleBinding.class, this.getClass(), "rbac-test-role-binding.yaml"); adminClient.resource(clusterRoleBinding).delete(); } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/servicestrictmatcher/ServiceDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/servicestrictmatcher/ServiceDependentResource.java index 1bb34de16c..fb243251f3 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/servicestrictmatcher/ServiceDependentResource.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/servicestrictmatcher/ServiceDependentResource.java @@ -26,7 +26,7 @@ import io.javaoperatorsdk.operator.processing.dependent.kubernetes.GenericKubernetesResourceMatcher; import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; -import static io.javaoperatorsdk.operator.ReconcilerUtils.loadYaml; +import static io.javaoperatorsdk.operator.ReconcilerUtilsInternal.loadYaml; @KubernetesDependent public class ServiceDependentResource diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/ssalegacymatcher/ServiceDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/ssalegacymatcher/ServiceDependentResource.java index 7cd65bd7ef..6a998b3ea4 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/ssalegacymatcher/ServiceDependentResource.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/ssalegacymatcher/ServiceDependentResource.java @@ -25,7 +25,7 @@ import io.javaoperatorsdk.operator.processing.dependent.kubernetes.GenericKubernetesResourceMatcher; import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; -import static io.javaoperatorsdk.operator.ReconcilerUtils.loadYaml; +import static io.javaoperatorsdk.operator.ReconcilerUtilsInternal.loadYaml; @KubernetesDependent public class ServiceDependentResource diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/standalonedependent/StandaloneDependentTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/standalonedependent/StandaloneDependentTestReconciler.java index 6f97be1be7..92f033d681 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/standalonedependent/StandaloneDependentTestReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/standalonedependent/StandaloneDependentTestReconciler.java @@ -20,7 +20,7 @@ import io.fabric8.kubernetes.api.model.apps.Deployment; import io.fabric8.kubernetes.client.KubernetesClientException; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; import io.javaoperatorsdk.operator.api.reconciler.ErrorStatusUpdateControl; @@ -90,7 +90,7 @@ protected Deployment desired( StandaloneDependentTestCustomResource primary, Context context) { Deployment deployment = - ReconcilerUtils.loadYaml( + ReconcilerUtilsInternal.loadYaml( Deployment.class, StandaloneDependentResourceIT.class, "/io/javaoperatorsdk/operator/nginx-deployment.yaml"); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/statefulsetdesiredsanitizer/StatefulSetDesiredSanitizerDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/statefulsetdesiredsanitizer/StatefulSetDesiredSanitizerDependentResource.java index e86c772cda..e4bcaac460 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/statefulsetdesiredsanitizer/StatefulSetDesiredSanitizerDependentResource.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/statefulsetdesiredsanitizer/StatefulSetDesiredSanitizerDependentResource.java @@ -17,7 +17,7 @@ import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; import io.fabric8.kubernetes.api.model.apps.StatefulSet; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; @@ -32,7 +32,7 @@ protected StatefulSet desired( StatefulSetDesiredSanitizerCustomResource primary, Context context) { var template = - ReconcilerUtils.loadYaml( + ReconcilerUtilsInternal.loadYaml( StatefulSet.class, getClass(), "/io/javaoperatorsdk/operator/statefulset.yaml"); template.setMetadata( new ObjectMetaBuilder() diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/BaseService.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/BaseService.java index 06abcc0889..7a0d50debf 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/BaseService.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/BaseService.java @@ -19,7 +19,7 @@ import io.fabric8.kubernetes.api.model.Service; import io.fabric8.kubernetes.api.model.ServiceBuilder; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.workflow.complexdependent.ComplexWorkflowCustomResource; @@ -33,7 +33,7 @@ public BaseService(String component) { protected Service desired( ComplexWorkflowCustomResource primary, Context context) { var template = - ReconcilerUtils.loadYaml( + ReconcilerUtilsInternal.loadYaml( Service.class, getClass(), "/io/javaoperatorsdk/operator/workflow/complexdependent/service.yaml"); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/BaseStatefulSet.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/BaseStatefulSet.java index b0a7b60805..1e4aa73e80 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/BaseStatefulSet.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/BaseStatefulSet.java @@ -19,7 +19,7 @@ import io.fabric8.kubernetes.api.model.apps.StatefulSet; import io.fabric8.kubernetes.api.model.apps.StatefulSetBuilder; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.workflow.complexdependent.ComplexWorkflowCustomResource; @@ -32,7 +32,7 @@ public BaseStatefulSet(String component) { protected StatefulSet desired( ComplexWorkflowCustomResource primary, Context context) { var template = - ReconcilerUtils.loadYaml( + ReconcilerUtilsInternal.loadYaml( StatefulSet.class, getClass(), "/io/javaoperatorsdk/operator/workflow/complexdependent/statefulset.yaml"); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/DeploymentDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/DeploymentDependentResource.java index b9aa595b76..e5c7f726f5 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/DeploymentDependentResource.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/DeploymentDependentResource.java @@ -16,7 +16,7 @@ package io.javaoperatorsdk.operator.workflow.workflowallfeature; import io.fabric8.kubernetes.api.model.apps.Deployment; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDNoGCKubernetesDependentResource; @@ -27,7 +27,7 @@ public class DeploymentDependentResource protected Deployment desired( WorkflowAllFeatureCustomResource primary, Context context) { Deployment deployment = - ReconcilerUtils.loadYaml( + ReconcilerUtilsInternal.loadYaml( Deployment.class, WorkflowAllFeatureIT.class, "/io/javaoperatorsdk/operator/nginx-deployment.yaml"); diff --git a/operator-framework/src/test/resources/log4j2.xml b/operator-framework/src/test/resources/log4j2.xml index e922079cc8..3a6e259e31 100644 --- a/operator-framework/src/test/resources/log4j2.xml +++ b/operator-framework/src/test/resources/log4j2.xml @@ -19,7 +19,7 @@ - + diff --git a/pom.xml b/pom.xml index 4de376a738..77f76cb609 100644 --- a/pom.xml +++ b/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.2.4-SNAPSHOT + 999-SNAPSHOT pom Operator SDK for Java Java SDK for implementing Kubernetes operators @@ -47,7 +47,7 @@ operator-framework-bom operator-framework-core - operator-framework-junit5 + operator-framework-junit operator-framework micrometer-support sample-operators diff --git a/sample-operators/controller-namespace-deletion/pom.xml b/sample-operators/controller-namespace-deletion/pom.xml index 6af22e1ddf..4cdfe1f2bd 100644 --- a/sample-operators/controller-namespace-deletion/pom.xml +++ b/sample-operators/controller-namespace-deletion/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.2.4-SNAPSHOT + 999-SNAPSHOT sample-controller-namespace-deletion @@ -69,7 +69,7 @@ io.javaoperatorsdk - operator-framework-junit-5 + operator-framework-junit test diff --git a/sample-operators/controller-namespace-deletion/src/main/resources/log4j2.xml b/sample-operators/controller-namespace-deletion/src/main/resources/log4j2.xml index bb61366dcf..147f494c1d 100644 --- a/sample-operators/controller-namespace-deletion/src/main/resources/log4j2.xml +++ b/sample-operators/controller-namespace-deletion/src/main/resources/log4j2.xml @@ -19,7 +19,7 @@ - + diff --git a/sample-operators/leader-election/pom.xml b/sample-operators/leader-election/pom.xml index db71c9440c..8194b433fc 100644 --- a/sample-operators/leader-election/pom.xml +++ b/sample-operators/leader-election/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.2.4-SNAPSHOT + 999-SNAPSHOT sample-leader-election @@ -69,7 +69,7 @@ io.javaoperatorsdk - operator-framework-junit-5 + operator-framework-junit test diff --git a/sample-operators/leader-election/src/main/resources/log4j2.xml b/sample-operators/leader-election/src/main/resources/log4j2.xml index bb61366dcf..147f494c1d 100644 --- a/sample-operators/leader-election/src/main/resources/log4j2.xml +++ b/sample-operators/leader-election/src/main/resources/log4j2.xml @@ -19,7 +19,7 @@ - + diff --git a/sample-operators/metrics-processing/k8s/operator.yaml b/sample-operators/metrics-processing/k8s/operator.yaml new file mode 100644 index 0000000000..336d587a5b --- /dev/null +++ b/sample-operators/metrics-processing/k8s/operator.yaml @@ -0,0 +1,84 @@ +# +# 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. +# + +apiVersion: v1 +kind: ServiceAccount +metadata: + name: metrics-processing-operator + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: metrics-processing-operator +spec: + selector: + matchLabels: + app: metrics-processing-operator + replicas: 1 + template: + metadata: + labels: + app: metrics-processing-operator + spec: + serviceAccountName: metrics-processing-operator + containers: + - name: operator + image: metrics-processing-operator + imagePullPolicy: Never + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: metrics-processing-operator-admin +subjects: +- kind: ServiceAccount + name: metrics-processing-operator + namespace: default +roleRef: + kind: ClusterRole + name: metrics-processing-operator + apiGroup: rbac.authorization.k8s.io + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: metrics-processing-operator +rules: +- apiGroups: + - "" + resources: + - pods + verbs: + - '*' +- apiGroups: + - "apiextensions.k8s.io" + resources: + - customresourcedefinitions + verbs: + - '*' +- apiGroups: + - "sample.javaoperatorsdk" + resources: + - metricshandlingcustomresource1s + - metricshandlingcustomresource1s/status + - metricshandlingcustomresource2s + - metricshandlingcustomresource2s/status + verbs: + - '*' + diff --git a/sample-operators/metrics-processing/pom.xml b/sample-operators/metrics-processing/pom.xml new file mode 100644 index 0000000000..625a58a9de --- /dev/null +++ b/sample-operators/metrics-processing/pom.xml @@ -0,0 +1,125 @@ + + + + 4.0.0 + + + io.javaoperatorsdk + sample-operators + 999-SNAPSHOT + + + sample-metrics-processing + jar + Operator SDK - Samples - Metrics processing + Showcases to handle metrics setup and deploys related tooling and dashboards + + + + + io.javaoperatorsdk + operator-framework-bom + ${project.version} + pom + import + + + io.micrometer + micrometer-bom + ${micrometer-core.version} + pom + import + + + + + + + io.javaoperatorsdk + operator-framework + + + io.javaoperatorsdk + micrometer-support + + + io.micrometer + micrometer-registry-otlp + ${micrometer-core.version} + + + org.yaml + snakeyaml + 2.3 + + + org.apache.logging.log4j + log4j-slf4j2-impl + + + org.apache.logging.log4j + log4j-core + compile + + + org.awaitility + awaitility + compile + + + io.javaoperatorsdk + operator-framework-junit + test + + + + + + com.google.cloud.tools + jib-maven-plugin + ${jib-maven-plugin.version} + + + gcr.io/distroless/java17-debian11 + + + metrics-processing-operator + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + io.fabric8 + crd-generator-maven-plugin + ${fabric8-client.version} + + + + generate + + + + + + + + diff --git a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/AbstractMetricsHandlingReconciler.java b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/AbstractMetricsHandlingReconciler.java new file mode 100644 index 0000000000..df83afdd6b --- /dev/null +++ b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/AbstractMetricsHandlingReconciler.java @@ -0,0 +1,74 @@ +/* + * 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.sample.metrics; + +import java.util.concurrent.ThreadLocalRandom; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.client.CustomResource; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.sample.metrics.customresource.MetricsHandlingSpec; +import io.javaoperatorsdk.operator.sample.metrics.customresource.MetricsHandlingStatus; + +public abstract class AbstractMetricsHandlingReconciler< + R extends CustomResource> + implements Reconciler { + + private static final Logger log = + LoggerFactory.getLogger(AbstractMetricsHandlingReconciler.class); + + private final long sleepMillis; + + protected AbstractMetricsHandlingReconciler(long sleepMillis) { + this.sleepMillis = sleepMillis; + } + + @Override + public UpdateControl reconcile(R resource, Context context) { + String name = resource.getMetadata().getName(); + log.info("Reconciling resource: {}", name); + + try { + Thread.sleep(sleepMillis + ThreadLocalRandom.current().nextLong(sleepMillis)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted during reconciliation", e); + } + + if (name.toLowerCase().contains("fail")) { + log.error("Simulating failure for resource: {}", name); + throw new IllegalStateException("Simulated reconciliation failure for resource: " + name); + } + + var status = resource.getStatus(); + if (status == null) { + status = new MetricsHandlingStatus(); + resource.setStatus(status); + } + + var spec = resource.getSpec(); + if (spec != null) { + status.setObservedNumber(spec.getNumber()); + } + + log.info("Successfully reconciled resource: {}", name); + return UpdateControl.patchStatus(resource); + } +} diff --git a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingReconciler1.java b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingReconciler1.java new file mode 100644 index 0000000000..aee20abbad --- /dev/null +++ b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingReconciler1.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.sample.metrics; + +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.sample.metrics.customresource.MetricsHandlingCustomResource1; + +@ControllerConfiguration +public class MetricsHandlingReconciler1 + extends AbstractMetricsHandlingReconciler { + + public MetricsHandlingReconciler1() { + super(100); + } +} diff --git a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingReconciler2.java b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingReconciler2.java new file mode 100644 index 0000000000..2c77c7f5fb --- /dev/null +++ b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingReconciler2.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.sample.metrics; + +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.sample.metrics.customresource.MetricsHandlingCustomResource2; + +@ControllerConfiguration +public class MetricsHandlingReconciler2 + extends AbstractMetricsHandlingReconciler { + + public MetricsHandlingReconciler2() { + super(150); + } +} diff --git a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingSampleOperator.java b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingSampleOperator.java new file mode 100644 index 0000000000..e874cd82af --- /dev/null +++ b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingSampleOperator.java @@ -0,0 +1,151 @@ +/* + * 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.sample.metrics; + +import java.io.IOException; +import java.io.InputStream; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.yaml.snakeyaml.Yaml; + +import io.javaoperatorsdk.operator.Operator; +import io.javaoperatorsdk.operator.api.monitoring.Metrics; +import io.javaoperatorsdk.operator.monitoring.micrometer.MicrometerMetricsV2; +import io.micrometer.core.instrument.Clock; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.binder.jvm.ClassLoaderMetrics; +import io.micrometer.core.instrument.binder.jvm.JvmGcMetrics; +import io.micrometer.core.instrument.binder.jvm.JvmMemoryMetrics; +import io.micrometer.core.instrument.binder.jvm.JvmThreadMetrics; +import io.micrometer.core.instrument.binder.system.ProcessorMetrics; +import io.micrometer.core.instrument.binder.system.UptimeMetrics; +import io.micrometer.core.instrument.composite.CompositeMeterRegistry; +import io.micrometer.core.instrument.logging.LoggingMeterRegistry; +import io.micrometer.core.instrument.logging.LoggingRegistryConfig; +import io.micrometer.registry.otlp.OtlpConfig; +import io.micrometer.registry.otlp.OtlpMeterRegistry; + +public class MetricsHandlingSampleOperator { + + private static final Logger log = LoggerFactory.getLogger(MetricsHandlingSampleOperator.class); + + public static boolean isLocal() { + String deployment = System.getProperty("test.deployment"); + boolean remote = (deployment != null && deployment.equals("remote")); + log.info("Running the operator {} ", remote ? "remotely" : "locally"); + return !remote; + } + + /** + * Based on env variables a different flavor of Reconciler is used, showcasing how the same logic + * can be implemented using the low level and higher level APIs. + */ + public static void main(String[] args) { + log.info("Metrics Handling Sample Operator starting!"); + + Metrics metrics = initOTLPMetrics(isLocal()); + Operator operator = + new Operator(o -> o.withStopOnInformerErrorDuringStartup(false).withMetrics(metrics)); + operator.register(new MetricsHandlingReconciler1()); + operator.register(new MetricsHandlingReconciler2()); + operator.start(); + } + + public static @NonNull Metrics initOTLPMetrics(boolean localRun) { + CompositeMeterRegistry compositeRegistry = new CompositeMeterRegistry(); + + // Add OTLP registry + Map configProperties = loadConfigFromYaml(); + if (localRun) { + configProperties.put("otlp.url", "http://localhost:4318/v1/metrics"); + } + var otlpConfig = + new OtlpConfig() { + @Override + public @Nullable String get(String key) { + return configProperties.get(key); + } + + @Override + public Map resourceAttributes() { + return Map.of("service.name", "josdk", "operator", "metrics-processing"); + } + }; + + MeterRegistry otlpRegistry = new OtlpMeterRegistry(otlpConfig, Clock.SYSTEM); + compositeRegistry.add(otlpRegistry); + + // enable to easily see propagated metrics + String enableConsoleLogging = System.getenv("METRICS_CONSOLE_LOGGING"); + if ("true".equalsIgnoreCase(enableConsoleLogging)) { + log.info("Console metrics logging enabled"); + LoggingMeterRegistry loggingRegistry = + new LoggingMeterRegistry( + new LoggingRegistryConfig() { + @Override + public String get(String key) { + return null; + } + + @Override + public Duration step() { + return Duration.ofSeconds(10); + } + }, + Clock.SYSTEM); + compositeRegistry.add(loggingRegistry); + } + // Register JVM and system metrics + log.info("Registering JVM and system metrics..."); + new JvmMemoryMetrics().bindTo(compositeRegistry); + new JvmGcMetrics().bindTo(compositeRegistry); + new JvmThreadMetrics().bindTo(compositeRegistry); + new ClassLoaderMetrics().bindTo(compositeRegistry); + new ProcessorMetrics().bindTo(compositeRegistry); + new UptimeMetrics().bindTo(compositeRegistry); + + return MicrometerMetricsV2.newMicrometerMetricsV2Builder(compositeRegistry).build(); + } + + @SuppressWarnings("unchecked") + private static Map loadConfigFromYaml() { + Map configMap = new HashMap<>(); + try (InputStream inputStream = + MetricsHandlingSampleOperator.class.getResourceAsStream("/otlp-config.yaml")) { + if (inputStream == null) { + log.warn("otlp-config.yaml not found in resources, using default OTLP configuration"); + return configMap; + } + Yaml yaml = new Yaml(); + Map yamlData = yaml.load(inputStream); + // Navigate to otlp section and map properties directly + Map otlp = (Map) yamlData.get("otlp"); + if (otlp != null) { + otlp.forEach((key, value) -> configMap.put("otlp." + key, value.toString())); + } + log.info("Loaded OTLP configuration from otlp-config.yaml: {}", configMap); + } catch (IOException e) { + log.error("Error loading otlp-config.yaml", e); + } + return configMap; + } +} diff --git a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/MetricsHandlingCustomResource1.java b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/MetricsHandlingCustomResource1.java new file mode 100644 index 0000000000..892f663175 --- /dev/null +++ b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/MetricsHandlingCustomResource1.java @@ -0,0 +1,32 @@ +/* + * 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.sample.metrics.customresource; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk") +@Version("v1") +public class MetricsHandlingCustomResource1 + extends CustomResource implements Namespaced { + + @Override + public String toString() { + return "MetricsHandlingCustomResource1{" + "spec=" + spec + ", status=" + status + '}'; + } +} diff --git a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/MetricsHandlingCustomResource2.java b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/MetricsHandlingCustomResource2.java new file mode 100644 index 0000000000..38abf2a322 --- /dev/null +++ b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/MetricsHandlingCustomResource2.java @@ -0,0 +1,32 @@ +/* + * 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.sample.metrics.customresource; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk") +@Version("v1") +public class MetricsHandlingCustomResource2 + extends CustomResource implements Namespaced { + + @Override + public String toString() { + return "MetricsHandlingCustomResource2{" + "spec=" + spec + ", status=" + status + '}'; + } +} diff --git a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/MetricsHandlingSpec.java b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/MetricsHandlingSpec.java new file mode 100644 index 0000000000..50016f03e0 --- /dev/null +++ b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/MetricsHandlingSpec.java @@ -0,0 +1,29 @@ +/* + * 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.sample.metrics.customresource; + +public class MetricsHandlingSpec { + + private int number; + + public int getNumber() { + return number; + } + + public void setNumber(int number) { + this.number = number; + } +} diff --git a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/MetricsHandlingStatus.java b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/MetricsHandlingStatus.java new file mode 100644 index 0000000000..76c286cf80 --- /dev/null +++ b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/MetricsHandlingStatus.java @@ -0,0 +1,29 @@ +/* + * 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.sample.metrics.customresource; + +public class MetricsHandlingStatus { + + private int observedNumber; + + public int getObservedNumber() { + return observedNumber; + } + + public void setObservedNumber(int observedNumber) { + this.observedNumber = observedNumber; + } +} diff --git a/sample-operators/metrics-processing/src/main/resources/io/javaoperatorsdk/operator/sample/deployment.yaml b/sample-operators/metrics-processing/src/main/resources/io/javaoperatorsdk/operator/sample/deployment.yaml new file mode 100644 index 0000000000..89e175a9a6 --- /dev/null +++ b/sample-operators/metrics-processing/src/main/resources/io/javaoperatorsdk/operator/sample/deployment.yaml @@ -0,0 +1,42 @@ +# +# 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. +# + +apiVersion: apps/v1 # for versions before 1.9.0 use apps/v1beta2 +kind: Deployment +metadata: + name: "" +spec: + selector: + matchLabels: + app: "" + replicas: 1 + template: + metadata: + labels: + app: "" + spec: + containers: + - name: nginx + image: nginx:1.25.5 + ports: + - containerPort: 80 + volumeMounts: + - name: html-volume + mountPath: /usr/share/nginx/html + volumes: + - name: html-volume + configMap: + name: "" \ No newline at end of file diff --git a/sample-operators/metrics-processing/src/main/resources/log4j2.xml b/sample-operators/metrics-processing/src/main/resources/log4j2.xml new file mode 100644 index 0000000000..2979258355 --- /dev/null +++ b/sample-operators/metrics-processing/src/main/resources/log4j2.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sample-operators/metrics-processing/src/main/resources/otlp-config.yaml b/sample-operators/metrics-processing/src/main/resources/otlp-config.yaml new file mode 100644 index 0000000000..7a71cffdc4 --- /dev/null +++ b/sample-operators/metrics-processing/src/main/resources/otlp-config.yaml @@ -0,0 +1,23 @@ +# +# 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. +# + +otlp: + # OTLP Collector endpoint - see observability/install-observability.sh for setup +# url: "http://localhost:4318/v1/metrics" + url: "http://otel-collector-collector.observability.svc.cluster.local:4318/v1/metrics" + step: 5s + batchSize: 15000 + aggregationTemporality: "cumulative" diff --git a/sample-operators/metrics-processing/src/test/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingE2E.java b/sample-operators/metrics-processing/src/test/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingE2E.java new file mode 100644 index 0000000000..fa5bf42a14 --- /dev/null +++ b/sample-operators/metrics-processing/src/test/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingE2E.java @@ -0,0 +1,305 @@ +/* + * 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.sample.metrics; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.Deque; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientBuilder; +import io.fabric8.kubernetes.client.LocalPortForward; +import io.javaoperatorsdk.operator.junit.AbstractOperatorExtension; +import io.javaoperatorsdk.operator.junit.ClusterDeployedOperatorExtension; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.sample.metrics.customresource.MetricsHandlingCustomResource1; +import io.javaoperatorsdk.operator.sample.metrics.customresource.MetricsHandlingCustomResource2; +import io.javaoperatorsdk.operator.sample.metrics.customresource.MetricsHandlingSpec; + +import static io.javaoperatorsdk.operator.sample.metrics.MetricsHandlingSampleOperator.isLocal; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class MetricsHandlingE2E { + + static final Logger log = LoggerFactory.getLogger(MetricsHandlingE2E.class); + static final String OBSERVABILITY_NAMESPACE = "observability"; + static final int PROMETHEUS_PORT = 9090; + static final int OTEL_COLLECTOR_PORT = 4318; + public static final Duration TEST_DURATION = Duration.ofSeconds(60); + public static final String NAME_LABEL_KEY = "app.kubernetes.io/name"; + + private LocalPortForward prometheusPortForward; + private LocalPortForward otelCollectorPortForward; + + static final KubernetesClient client = new KubernetesClientBuilder().build(); + + MetricsHandlingE2E() throws FileNotFoundException {} + + @RegisterExtension + AbstractOperatorExtension operator = + isLocal() + ? LocallyRunOperatorExtension.builder() + .withReconciler(new MetricsHandlingReconciler1()) + .withReconciler(new MetricsHandlingReconciler2()) + .withConfigurationService( + c -> c.withMetrics(MetricsHandlingSampleOperator.initOTLPMetrics(true))) + .build() + : ClusterDeployedOperatorExtension.builder() + .withOperatorDeployment( + new KubernetesClientBuilder() + .build() + .load(new FileInputStream("k8s/operator.yaml")) + .items()) + .build(); + + @BeforeAll + void setupObservability() throws InterruptedException { + log.info("Setting up observability stack..."); + installObservabilityServices(); + prometheusPortForward = portForward(NAME_LABEL_KEY, "prometheus", PROMETHEUS_PORT); + if (isLocal()) { + otelCollectorPortForward = + portForward(NAME_LABEL_KEY, "otel-collector-collector", OTEL_COLLECTOR_PORT); + } + Thread.sleep(2000); + } + + @AfterAll + void cleanup() throws IOException { + closePortForward(prometheusPortForward); + closePortForward(otelCollectorPortForward); + } + + private LocalPortForward portForward(String labelKey, String labelValue, int port) { + return client + .pods() + .inNamespace(OBSERVABILITY_NAMESPACE) + .withLabel(labelKey, labelValue) + .list() + .getItems() + .stream() + .findFirst() + .map( + pod -> + client + .pods() + .inNamespace(OBSERVABILITY_NAMESPACE) + .withName(pod.getMetadata().getName()) + .portForward(port, port)) + .orElseThrow( + () -> + new IllegalStateException( + "Pod not found for label " + labelKey + "=" + labelValue)); + } + + private void closePortForward(LocalPortForward pf) throws IOException { + if (pf != null) { + pf.close(); + } + } + + // note that we just cover here cases that should be visible in metrics, + // including errors, delete events. + @Test + void testPropagatedMetrics() throws Exception { + log.info( + "Starting longevity metrics test (running for {} seconds)", TEST_DURATION.getSeconds()); + + // Create initial resources including ones that trigger failures + operator.create(createResource(MetricsHandlingCustomResource1.class, "test-success-1", 1)); + operator.create(createResource(MetricsHandlingCustomResource2.class, "test-success-2", 1)); + operator.create(createResource(MetricsHandlingCustomResource1.class, "test-fail-1", 1)); + operator.create(createResource(MetricsHandlingCustomResource2.class, "test-fail-2", 1)); + + // Continuously trigger reconciliations for ~50 seconds by alternating between + // creating new resources, updating specs of existing ones, and deleting older dynamic ones + long deadline = System.currentTimeMillis() + TEST_DURATION.toMillis(); + int counter = 0; + Deque createdResource1Names = new ArrayDeque<>(); + Deque createdResource2Names = new ArrayDeque<>(); + while (System.currentTimeMillis() < deadline) { + counter++; + switch (counter % 4) { + case 0 -> { + String name = "test-dynamic-1-" + counter; + operator.create(createResource(MetricsHandlingCustomResource1.class, name, counter * 3)); + createdResource1Names.addLast(name); + log.info("Iteration {}: created {}", counter, name); + } + case 1 -> { + var r1 = operator.get(MetricsHandlingCustomResource1.class, "test-success-1"); + r1.getSpec().setNumber(counter * 7); + operator.replace(r1); + log.info("Iteration {}: updated test-success-1 number to {}", counter, counter * 7); + } + case 2 -> { + String name = "test-dynamic-2-" + counter; + operator.create(createResource(MetricsHandlingCustomResource2.class, name, counter * 5)); + createdResource2Names.addLast(name); + log.info("Iteration {}: created {}", counter, name); + } + case 3 -> { + // Delete the oldest dynamic resource; prefer whichever type has more accumulated + if (!createdResource1Names.isEmpty() + && (createdResource2Names.isEmpty() + || createdResource1Names.size() >= createdResource2Names.size())) { + String name = createdResource1Names.pollFirst(); + var r = operator.get(MetricsHandlingCustomResource1.class, name); + if (r != null) { + operator.delete(r); + log.info("Iteration {}: deleted {} ", counter, name); + } + } else if (!createdResource2Names.isEmpty()) { + String name = createdResource2Names.pollFirst(); + var r = operator.get(MetricsHandlingCustomResource2.class, name); + if (r != null) { + operator.delete(r); + log.info("Iteration {}: deleted {}", counter, name); + } + } + } + } + Thread.sleep(1000); + } + + log.info("Longevity phase completed ({} iterations), verifying metrics", counter); + verifyPrometheusMetrics(); + } + + private void verifyPrometheusMetrics() { + log.info("Verifying metrics in Prometheus..."); + String prometheusUrl = "http://localhost:" + PROMETHEUS_PORT; + + assertMetricPresent(prometheusUrl, "reconciliations_started_total", Duration.ofSeconds(60)); + assertMetricPresent(prometheusUrl, "reconciliations_success_total", Duration.ofSeconds(30)); + assertMetricPresent(prometheusUrl, "reconciliations_failure_total", Duration.ofSeconds(30)); + assertMetricPresent( + prometheusUrl, + "reconciliations_execution_duration_milliseconds_count", + Duration.ofSeconds(30)); + + log.info("All metrics verified successfully in Prometheus"); + } + + private void assertMetricPresent(String prometheusUrl, String metricName, Duration timeout) { + await() + .atMost(timeout) + .pollInterval(Duration.ofSeconds(5)) + .untilAsserted( + () -> { + String result = queryPrometheus(prometheusUrl, metricName); + log.info("{}: {}", metricName, result); + assertThat(result).contains("\"status\":\"success\""); + assertThat(result).contains(metricName); + }); + } + + private String queryPrometheus(String prometheusUrl, String query) throws IOException { + String urlString = prometheusUrl + "/api/v1/query?query=" + query; + URL url = new URL(urlString); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(5000); + connection.setReadTimeout(5000); + + int responseCode = connection.getResponseCode(); + if (responseCode != 200) { + throw new IOException("Prometheus query failed with response code: " + responseCode); + } + + try (BufferedReader in = + new BufferedReader( + new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { + StringBuilder response = new StringBuilder(); + String line; + while ((line = in.readLine()) != null) { + response.append(line); + } + return response.toString(); + } + } + + private > R createResource( + Class type, String name, int number) { + try { + R resource = type.getDeclaredConstructor().newInstance(); + resource.getMetadata().setName(name); + MetricsHandlingSpec spec = new MetricsHandlingSpec(); + spec.setNumber(number); + resource.setSpec(spec); + return resource; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private void installObservabilityServices() { + try { + // Find the observability script relative to project root + File projectRoot = new File(".").getCanonicalFile(); + while (projectRoot != null && !new File(projectRoot, "observability").exists()) { + projectRoot = projectRoot.getParentFile(); + } + + if (projectRoot == null) { + throw new IllegalStateException("Could not find observability directory"); + } + + File scriptFile = new File(projectRoot, "observability/install-observability.sh"); + if (!scriptFile.exists()) { + throw new IllegalStateException( + "Observability script not found at: " + scriptFile.getAbsolutePath()); + } + log.info("Running observability setup script: {}", scriptFile.getAbsolutePath()); + + // Run the install-observability.sh script + ProcessBuilder processBuilder = new ProcessBuilder("/bin/sh", scriptFile.getAbsolutePath()); + processBuilder.redirectErrorStream(true); + + processBuilder.environment().putAll(System.getenv()); + Process process = processBuilder.start(); + BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); + String line; + while ((line = reader.readLine()) != null) { + log.info("Observability setup: {}", line); + } + + int exitCode = process.waitFor(); + if (exitCode != 0) { + log.warn("Observability setup script returned exit code: {}", exitCode); + } + log.info("Observability stack is ready"); + } catch (Exception e) { + log.error("Failed to setup observability stack", e); + throw new RuntimeException(e); + } + } +} diff --git a/sample-operators/mysql-schema/k8s/operator.yaml b/sample-operators/mysql-schema/k8s/operator.yaml index a6f1214e34..10543900e9 100644 --- a/sample-operators/mysql-schema/k8s/operator.yaml +++ b/sample-operators/mysql-schema/k8s/operator.yaml @@ -39,7 +39,7 @@ spec: serviceAccountName: mysql-schema-operator # specify the ServiceAccount under which's RBAC persmissions the operator will be executed under containers: - name: operator - image: mysql-schema-operator # TODO Change this to point to your pushed mysql-schema-operator image + image: mysql-schema-operator # Change this to point to your pushed mysql-schema-operator image imagePullPolicy: IfNotPresent ports: - containerPort: 80 diff --git a/sample-operators/mysql-schema/pom.xml b/sample-operators/mysql-schema/pom.xml index 2fe292759b..2055a9933d 100644 --- a/sample-operators/mysql-schema/pom.xml +++ b/sample-operators/mysql-schema/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.2.4-SNAPSHOT + 999-SNAPSHOT sample-mysql-schema-operator @@ -87,7 +87,12 @@ io.javaoperatorsdk - operator-framework-junit-5 + operator-framework-junit + test + + + org.assertj + assertj-core test diff --git a/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/MySQLSchemaOperator.java b/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/MySQLSchemaOperator.java index 20dafac5be..3e8a9df13f 100644 --- a/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/MySQLSchemaOperator.java +++ b/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/MySQLSchemaOperator.java @@ -26,7 +26,7 @@ import org.takes.http.FtBasic; import io.javaoperatorsdk.operator.Operator; -import io.javaoperatorsdk.operator.monitoring.micrometer.MicrometerMetrics; +import io.javaoperatorsdk.operator.monitoring.micrometer.MicrometerMetricsV2; import io.javaoperatorsdk.operator.sample.dependent.ResourcePollerConfig; import io.javaoperatorsdk.operator.sample.dependent.SchemaDependentResource; import io.micrometer.core.instrument.logging.LoggingMeterRegistry; @@ -42,7 +42,8 @@ public static void main(String[] args) throws IOException { new Operator( overrider -> overrider.withMetrics( - MicrometerMetrics.withoutPerResourceMetrics(new LoggingMeterRegistry()))); + new MicrometerMetricsV2.MicrometerMetricsV2Builder(new LoggingMeterRegistry()) + .build())); MySQLSchemaReconciler schemaReconciler = new MySQLSchemaReconciler(); diff --git a/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/dependent/SchemaDependentResource.java b/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/dependent/SchemaDependentResource.java index 6400d312c7..a34cab5384 100644 --- a/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/dependent/SchemaDependentResource.java +++ b/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/dependent/SchemaDependentResource.java @@ -110,6 +110,7 @@ private Connection getConnection() throws SQLException { @Override public void delete(MySQLSchema primary, Context context) { + log.debug("Deleting schema"); try (Connection connection = getConnection()) { var userName = primary.getStatus() != null ? primary.getStatus().getUserName() : null; SchemaService.deleteSchemaAndRelatedUser( diff --git a/sample-operators/mysql-schema/src/main/resources/log4j2.xml b/sample-operators/mysql-schema/src/main/resources/log4j2.xml index 054261c13f..593f120e0b 100644 --- a/sample-operators/mysql-schema/src/main/resources/log4j2.xml +++ b/sample-operators/mysql-schema/src/main/resources/log4j2.xml @@ -19,10 +19,13 @@ - + + + + diff --git a/sample-operators/mysql-schema/src/test/java/io/javaoperatorsdk/operator/sample/MySQLSchemaOperatorE2E.java b/sample-operators/mysql-schema/src/test/java/io/javaoperatorsdk/operator/sample/MySQLSchemaOperatorE2E.java index 1fe4364250..80d07333f6 100644 --- a/sample-operators/mysql-schema/src/test/java/io/javaoperatorsdk/operator/sample/MySQLSchemaOperatorE2E.java +++ b/sample-operators/mysql-schema/src/test/java/io/javaoperatorsdk/operator/sample/MySQLSchemaOperatorE2E.java @@ -36,12 +36,8 @@ import io.javaoperatorsdk.operator.sample.dependent.SchemaDependentResource; import static java.util.concurrent.TimeUnit.MINUTES; +import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; -import static org.hamcrest.CoreMatchers.equalTo; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.notNullValue; -import static org.hamcrest.Matchers.nullValue; class MySQLSchemaOperatorE2E { @@ -114,10 +110,10 @@ void test() { .inNamespace(operator.getNamespace()) .withName(testSchema.getMetadata().getName()) .get(); - assertThat(updatedSchema.getStatus(), is(notNullValue())); - assertThat(updatedSchema.getStatus().getStatus(), equalTo("CREATED")); - assertThat(updatedSchema.getStatus().getSecretName(), is(notNullValue())); - assertThat(updatedSchema.getStatus().getUserName(), is(notNullValue())); + assertThat(updatedSchema.getStatus()).isNotNull(); + assertThat(updatedSchema.getStatus().getStatus()).isEqualTo("CREATED"); + assertThat(updatedSchema.getStatus().getSecretName()).isNotNull(); + assertThat(updatedSchema.getStatus().getUserName()).isNotNull(); }); client @@ -127,7 +123,7 @@ void test() { .delete(); await() - .atMost(2, MINUTES) + .atMost(4, MINUTES) .ignoreExceptions() .untilAsserted( () -> { @@ -137,7 +133,7 @@ void test() { .inNamespace(operator.getNamespace()) .withName(testSchema.getMetadata().getName()) .get(); - assertThat(updatedSchema, is(nullValue())); + assertThat(updatedSchema).isNull(); }); } } diff --git a/sample-operators/pom.xml b/sample-operators/pom.xml index 46d70eaf7b..c8a70b2701 100644 --- a/sample-operators/pom.xml +++ b/sample-operators/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk java-operator-sdk - 5.2.4-SNAPSHOT + 999-SNAPSHOT sample-operators @@ -35,5 +35,6 @@ mysql-schema leader-election controller-namespace-deletion + metrics-processing diff --git a/sample-operators/tomcat-operator/pom.xml b/sample-operators/tomcat-operator/pom.xml index 14a4d96c29..b7c3b05c98 100644 --- a/sample-operators/tomcat-operator/pom.xml +++ b/sample-operators/tomcat-operator/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.2.4-SNAPSHOT + 999-SNAPSHOT sample-tomcat-operator @@ -89,7 +89,7 @@ io.javaoperatorsdk - operator-framework-junit-5 + operator-framework-junit test diff --git a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/DeploymentDependentResource.java b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/DeploymentDependentResource.java index 0347b726ac..c4a47069e2 100644 --- a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/DeploymentDependentResource.java +++ b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/DeploymentDependentResource.java @@ -18,7 +18,7 @@ import io.fabric8.kubernetes.api.model.ObjectMeta; import io.fabric8.kubernetes.api.model.apps.Deployment; import io.fabric8.kubernetes.api.model.apps.DeploymentBuilder; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.config.informer.Informer; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; @@ -36,7 +36,7 @@ private static String tomcatImage(Tomcat tomcat) { @Override protected Deployment desired(Tomcat tomcat, Context context) { Deployment deployment = - ReconcilerUtils.loadYaml(Deployment.class, getClass(), "deployment.yaml"); + ReconcilerUtilsInternal.loadYaml(Deployment.class, getClass(), "deployment.yaml"); final ObjectMeta tomcatMetadata = tomcat.getMetadata(); final String tomcatName = tomcatMetadata.getName(); deployment = diff --git a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/ServiceDependentResource.java b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/ServiceDependentResource.java index 72f430528e..bcb0e80026 100644 --- a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/ServiceDependentResource.java +++ b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/ServiceDependentResource.java @@ -18,7 +18,7 @@ import io.fabric8.kubernetes.api.model.ObjectMeta; import io.fabric8.kubernetes.api.model.Service; import io.fabric8.kubernetes.api.model.ServiceBuilder; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.config.informer.Informer; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; @@ -31,7 +31,8 @@ public class ServiceDependentResource extends CRUDKubernetesDependentResource context) { final ObjectMeta tomcatMetadata = tomcat.getMetadata(); - return new ServiceBuilder(ReconcilerUtils.loadYaml(Service.class, getClass(), "service.yaml")) + return new ServiceBuilder( + ReconcilerUtilsInternal.loadYaml(Service.class, getClass(), "service.yaml")) .editMetadata() .withName(tomcatMetadata.getName()) .withNamespace(tomcatMetadata.getNamespace()) diff --git a/sample-operators/tomcat-operator/src/main/resources/log4j2.xml b/sample-operators/tomcat-operator/src/main/resources/log4j2.xml index 21b0ee5480..147f494c1d 100644 --- a/sample-operators/tomcat-operator/src/main/resources/log4j2.xml +++ b/sample-operators/tomcat-operator/src/main/resources/log4j2.xml @@ -19,11 +19,11 @@ - + - + diff --git a/sample-operators/webpage/README.md b/sample-operators/webpage/README.md index 7718d0f2f3..96329d18a9 100644 --- a/sample-operators/webpage/README.md +++ b/sample-operators/webpage/README.md @@ -76,3 +76,6 @@ of your choice. The JAR file is built using your local Maven and JDK and then co 1. Deploy the CRD: `kubectl apply -f target/classes/META-INF/fabric8/webpages.sample.javaoperatorsdk-v1.yml` 2. Deploy the operator: `kubectl apply -f k8s/operator.yaml` + +To install observability components - such as Prometheus, Open Telemetry, Grafana use - execute: +[install-observability.sh](../../observability/install-observability.sh) diff --git a/sample-operators/webpage/pom.xml b/sample-operators/webpage/pom.xml index 357863110c..c50366e37e 100644 --- a/sample-operators/webpage/pom.xml +++ b/sample-operators/webpage/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.2.4-SNAPSHOT + 999-SNAPSHOT sample-webpage-operator @@ -68,7 +68,7 @@ io.javaoperatorsdk - operator-framework-junit-5 + operator-framework-junit test diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/Utils.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/Utils.java index ab4ed8a337..ecfe66d329 100644 --- a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/Utils.java +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/Utils.java @@ -21,7 +21,7 @@ import io.javaoperatorsdk.operator.sample.customresource.WebPage; import io.javaoperatorsdk.operator.sample.customresource.WebPageStatus; -import static io.javaoperatorsdk.operator.ReconcilerUtils.loadYaml; +import static io.javaoperatorsdk.operator.ReconcilerUtilsInternal.loadYaml; public class Utils { diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java index 94b460474f..eba68d9381 100644 --- a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java @@ -19,15 +19,15 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.function.BiFunction; -import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import io.fabric8.kubernetes.api.model.*; import io.fabric8.kubernetes.api.model.apps.Deployment; import io.fabric8.kubernetes.api.model.networking.v1.Ingress; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; import io.javaoperatorsdk.operator.api.reconciler.*; import io.javaoperatorsdk.operator.api.reconciler.Context; @@ -90,68 +90,30 @@ public UpdateControl reconcile(WebPage webPage, Context contex return UpdateControl.patchStatus(setInvalidHtmlErrorMessage(webPage)); } - String ns = webPage.getMetadata().getNamespace(); - String configMapName = configMapName(webPage); - String deploymentName = deploymentName(webPage); + ConfigMap desiredHtmlConfigMap = makeDesiredHtmlConfigMap(webPage); + Deployment desiredDeployment = makeDesiredDeployment(webPage); + Service desiredService = makeDesiredService(webPage, desiredDeployment); - ConfigMap desiredHtmlConfigMap = makeDesiredHtmlConfigMap(ns, configMapName, webPage); - Deployment desiredDeployment = - makeDesiredDeployment(webPage, deploymentName, ns, configMapName); - Service desiredService = makeDesiredService(webPage, ns, desiredDeployment); - - var previousConfigMap = context.getSecondaryResource(ConfigMap.class).orElse(null); - if (!match(desiredHtmlConfigMap, previousConfigMap)) { - log.info( - "Creating or updating ConfigMap {} in {}", - desiredHtmlConfigMap.getMetadata().getName(), - ns); - context - .getClient() - .configMaps() - .inNamespace(ns) - .resource(desiredHtmlConfigMap) - .serverSideApply(); - } - - var existingDeployment = context.getSecondaryResource(Deployment.class).orElse(null); - if (!match(desiredDeployment, existingDeployment)) { - log.info( - "Creating or updating Deployment {} in {}", - desiredDeployment.getMetadata().getName(), - ns); - context - .getClient() - .apps() - .deployments() - .inNamespace(ns) - .resource(desiredDeployment) - .serverSideApply(); - } - - var existingService = context.getSecondaryResource(Service.class).orElse(null); - if (!match(desiredService, existingService)) { - log.info( - "Creating or updating Deployment {} in {}", - desiredDeployment.getMetadata().getName(), - ns); - context.getClient().services().inNamespace(ns).resource(desiredService).serverSideApply(); - } + final var previousConfigMap = createOrUpdate(context, desiredHtmlConfigMap, this::match); + createOrUpdate(context, desiredDeployment, this::match); + createOrUpdate(context, desiredService, this::match); var existingIngress = context.getSecondaryResource(Ingress.class); if (Boolean.TRUE.equals(webPage.getSpec().getExposed())) { var desiredIngress = makeDesiredIngress(webPage); if (existingIngress.isEmpty() || !match(desiredIngress, existingIngress.get())) { - context.getClient().resource(desiredIngress).inNamespace(ns).serverSideApply(); + context.resourceOperations().serverSideApply(desiredIngress); } } else existingIngress.ifPresent(ingress -> context.getClient().resource(ingress).delete()); // not that this is not necessary, eventually mounted config map would be updated, just this way - // is much faster; what is handy for demo purposes. + // is much faster; this is handy for demo purposes. // https://kubernetes.io/docs/tasks/configure-pod-container/configure-pod-configmap/#mounted-configmaps-are-updated-automatically if (previousConfigMap != null - && !StringUtils.equals( + && !Objects.equals( previousConfigMap.getData().get(INDEX_HTML), desiredHtmlConfigMap.getData().get(INDEX_HTML))) { + final var ns = webPage.getMetadata().getNamespace(); log.info("Restarting pods because HTML has changed in {}", ns); context.getClient().pods().inNamespace(ns).withLabel("app", deploymentName(webPage)).delete(); } @@ -160,6 +122,21 @@ public UpdateControl reconcile(WebPage webPage, Context contex createWebPageForStatusUpdate(webPage, desiredHtmlConfigMap.getMetadata().getName())); } + private T createOrUpdate( + Context context, T desired, BiFunction matcher) { + @SuppressWarnings("unchecked") + final T previous = (T) context.getSecondaryResource(desired.getClass()).orElse(null); + if (!matcher.apply(desired, previous)) { + log.info( + "Creating or updating {} {} in {}", + desired.getKind(), + desired.getMetadata().getName(), + desired.getMetadata().getNamespace()); + context.resourceOperations().serverSideApply(desired); + } + return previous; + } + private boolean match(Ingress desiredIngress, Ingress existingIngress) { String desiredServiceName = desiredIngress @@ -218,8 +195,10 @@ private boolean match(ConfigMap desiredHtmlConfigMap, ConfigMap existingConfigMa } } - private Service makeDesiredService(WebPage webPage, String ns, Deployment desiredDeployment) { - Service desiredService = ReconcilerUtils.loadYaml(Service.class, getClass(), "service.yaml"); + private Service makeDesiredService(WebPage webPage, Deployment desiredDeployment) { + Service desiredService = + ReconcilerUtilsInternal.loadYaml(Service.class, getClass(), "service.yaml"); + final var ns = webPage.getMetadata().getNamespace(); desiredService.getMetadata().setName(serviceName(webPage)); desiredService.getMetadata().setNamespace(ns); desiredService.getMetadata().setLabels(lowLevelLabel()); @@ -230,15 +209,18 @@ private Service makeDesiredService(WebPage webPage, String ns, Deployment desire return desiredService; } - private Deployment makeDesiredDeployment( - WebPage webPage, String deploymentName, String ns, String configMapName) { + private Deployment makeDesiredDeployment(WebPage webPage) { Deployment desiredDeployment = - ReconcilerUtils.loadYaml(Deployment.class, getClass(), "deployment.yaml"); + ReconcilerUtilsInternal.loadYaml(Deployment.class, getClass(), "deployment.yaml"); + final var ns = webPage.getMetadata().getNamespace(); + final var deploymentName = deploymentName(webPage); desiredDeployment.getMetadata().setName(deploymentName); desiredDeployment.getMetadata().setNamespace(ns); desiredDeployment.getMetadata().setLabels(lowLevelLabel()); desiredDeployment.getSpec().getSelector().getMatchLabels().put("app", deploymentName); desiredDeployment.getSpec().getTemplate().getMetadata().getLabels().put("app", deploymentName); + + final var configMapName = configMapName(webPage); desiredDeployment .getSpec() .getTemplate() @@ -250,7 +232,9 @@ private Deployment makeDesiredDeployment( return desiredDeployment; } - private ConfigMap makeDesiredHtmlConfigMap(String ns, String configMapName, WebPage webPage) { + private ConfigMap makeDesiredHtmlConfigMap(WebPage webPage) { + final var ns = webPage.getMetadata().getNamespace(); + final var configMapName = configMapName(webPage); Map data = new HashMap<>(); data.put(INDEX_HTML, webPage.getSpec().getHtml()); ConfigMap configMap = diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/dependentresource/DeploymentDependentResource.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/dependentresource/DeploymentDependentResource.java index 6d1f7cc911..e383633ab1 100644 --- a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/dependentresource/DeploymentDependentResource.java +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/dependentresource/DeploymentDependentResource.java @@ -27,7 +27,7 @@ import io.javaoperatorsdk.operator.sample.Utils; import io.javaoperatorsdk.operator.sample.customresource.WebPage; -import static io.javaoperatorsdk.operator.ReconcilerUtils.loadYaml; +import static io.javaoperatorsdk.operator.ReconcilerUtilsInternal.loadYaml; import static io.javaoperatorsdk.operator.sample.Utils.configMapName; import static io.javaoperatorsdk.operator.sample.Utils.deploymentName; import static io.javaoperatorsdk.operator.sample.WebPageManagedDependentsReconciler.SELECTOR; diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/dependentresource/ServiceDependentResource.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/dependentresource/ServiceDependentResource.java index 3dbc784887..02204d415a 100644 --- a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/dependentresource/ServiceDependentResource.java +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/dependentresource/ServiceDependentResource.java @@ -25,7 +25,7 @@ import io.javaoperatorsdk.operator.sample.Utils; import io.javaoperatorsdk.operator.sample.customresource.WebPage; -import static io.javaoperatorsdk.operator.ReconcilerUtils.loadYaml; +import static io.javaoperatorsdk.operator.ReconcilerUtilsInternal.loadYaml; import static io.javaoperatorsdk.operator.sample.Utils.deploymentName; import static io.javaoperatorsdk.operator.sample.Utils.serviceName; import static io.javaoperatorsdk.operator.sample.WebPageManagedDependentsReconciler.SELECTOR; diff --git a/sample-operators/webpage/src/main/resources/log4j2.xml b/sample-operators/webpage/src/main/resources/log4j2.xml index 0bf270c7e6..c8c007835f 100644 --- a/sample-operators/webpage/src/main/resources/log4j2.xml +++ b/sample-operators/webpage/src/main/resources/log4j2.xml @@ -19,11 +19,11 @@ - + - + diff --git a/test-index-processor/pom.xml b/test-index-processor/pom.xml index acfddc276a..2ae7c5f454 100644 --- a/test-index-processor/pom.xml +++ b/test-index-processor/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk java-operator-sdk - 5.2.4-SNAPSHOT + 999-SNAPSHOT test-index-processor