diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index e146009885..86f527e727 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: 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/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..0a629ab0e7 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 diff --git a/docs/content/en/docs/migration/v5-3-migration.md b/docs/content/en/docs/migration/v5-3-migration.md new file mode 100644 index 0000000000..54007751f9 --- /dev/null +++ b/docs/content/en/docs/migration/v5-3-migration.md @@ -0,0 +1,29 @@ +--- +title: Migrating from v5.2 to v5.3 +description: Migrating from v5.2 to v5.3 +--- + + +## Renamed JUnit Module + +If you use JUnit extension in your test just rename it from: + +``` + + io.javaoperatorsdk + operator-framework-junit-5 + 5.2.x + test + +``` + +to + +``` + + io.javaoperatorsdk + operator-framework-junit + 5.3.0 + test + +``` \ No newline at end of file diff --git a/helm/operator/Chart.yaml b/helm/operator/Chart.yaml new file mode 100644 index 0000000000..9ba424eb54 --- /dev/null +++ b/helm/operator/Chart.yaml @@ -0,0 +1,18 @@ +apiVersion: v2 +name: josdk-operator +description: > + Generic Helm chart template for deploying a Java Operator SDK based operator. + Copy and customise this chart for your own operator: adjust the values, extend + the ClusterRole rules with your CRD API groups, and optionally add your own + templates. +type: application +version: 0.1.0 +# Set to the version of your operator image. +appVersion: "latest" +keywords: + - operator + - kubernetes + - java-operator-sdk +home: https://javaoperatorsdk.io +sources: + - https://github.com/operator-framework/java-operator-sdk diff --git a/helm/operator/templates/NOTES.txt b/helm/operator/templates/NOTES.txt new file mode 100644 index 0000000000..319f42fe6e --- /dev/null +++ b/helm/operator/templates/NOTES.txt @@ -0,0 +1,45 @@ +Thank you for installing {{ .Chart.Name }} ({{ .Chart.AppVersion }}). + +Release: {{ .Release.Name }} +Namespace: {{ include "josdk-operator.namespace" . }} + +Operator deployment: {{ include "josdk-operator.fullname" . }} + +{{- if .Values.josdkConfig.enabled }} + +ConfigLoader properties are mounted from ConfigMap + {{ include "josdk-operator.configMapName" . }} +at {{ .Values.josdkConfig.mountPath }}/josdk.properties. + +Wire it up in your operator main class: + + ConfigLoader loader = new ConfigLoader( + PropertiesConfigProvider.fromFile( + Path.of("{{ .Values.josdkConfig.mountPath }}/josdk.properties"))); +{{- end }} + +{{- if .Values.log4j2.enabled }} + +Log4j2 configuration is mounted from ConfigMap + {{ include "josdk-operator.log4j2ConfigMapName" . }} +at {{ .Values.log4j2.mountPath }}/log4j2.xml. + +Root log level: {{ .Values.log4j2.rootLevel }} +{{- if .Values.log4j2.loggers }} +Per-logger overrides: +{{- range $logger, $level := .Values.log4j2.loggers }} + {{ $logger }} -> {{ $level }} +{{- end }} +{{- end }} + +The JVM flag -Dlog4j2.configurationFile={{ .Values.log4j2.mountPath }}/log4j2.xml +has been added to JAVA_TOOL_OPTIONS automatically. +{{- end }} + +To change the log level at runtime without redeploying, update the ConfigMap: + + kubectl edit configmap {{ include "josdk-operator.log4j2ConfigMapName" . }} \ + -n {{ include "josdk-operator.namespace" . }} + +Log4j2 will pick up the change within 30 seconds (monitorInterval="30" in the +default configuration). diff --git a/helm/operator/templates/_helpers.tpl b/helm/operator/templates/_helpers.tpl new file mode 100644 index 0000000000..6adb60ebe7 --- /dev/null +++ b/helm/operator/templates/_helpers.tpl @@ -0,0 +1,93 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "josdk-operator.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "josdk-operator.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Chart label. +*/}} +{{- define "josdk-operator.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels applied to every resource. +*/}} +{{- define "josdk-operator.labels" -}} +helm.sh/chart: {{ include "josdk-operator.chart" . }} +{{ include "josdk-operator.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels used in Deployment and Service selectors. +*/}} +{{- define "josdk-operator.selectorLabels" -}} +app.kubernetes.io/name: {{ include "josdk-operator.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +ServiceAccount name. +*/}} +{{- define "josdk-operator.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "josdk-operator.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Deployment namespace. +*/}} +{{- define "josdk-operator.namespace" -}} +{{- default .Release.Namespace .Values.namespace }} +{{- end }} + +{{/* +Name of the JOSDK config ConfigMap. +*/}} +{{- define "josdk-operator.configMapName" -}} +{{- default (printf "%s-config" (include "josdk-operator.fullname" .)) .Values.josdkConfig.configMapName }} +{{- end }} + +{{/* +Name of the log4j2 ConfigMap. +*/}} +{{- define "josdk-operator.log4j2ConfigMapName" -}} +{{- default (printf "%s-log4j2" (include "josdk-operator.fullname" .)) .Values.log4j2.configMapName }} +{{- end }} + +{{/* +JAVA_TOOL_OPTIONS / JVM args value. +Appends the log4j2 config file system property automatically when log4j2 is enabled. +*/}} +{{- define "josdk-operator.jvmArgs" -}} +{{- $args := .Values.jvmArgs | default "" }} +{{- if .Values.log4j2.enabled }} +{{- $args = printf "%s -Dlog4j2.configurationFile=%s/log4j2.xml" $args .Values.log4j2.mountPath | trim }} +{{- end }} +{{- $args }} +{{- end }} diff --git a/helm/operator/templates/clusterrole.yaml b/helm/operator/templates/clusterrole.yaml new file mode 100644 index 0000000000..92000cb618 --- /dev/null +++ b/helm/operator/templates/clusterrole.yaml @@ -0,0 +1,44 @@ +{{- if .Values.rbac.create }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "josdk-operator.fullname" . }} + labels: + {{- include "josdk-operator.labels" . | nindent 4 }} +rules: + # Required for JOSDK to install / validate CRDs on startup. + - apiGroups: ["apiextensions.k8s.io"] + resources: ["customresourcedefinitions"] + verbs: ["get", "list", "watch", "create", "update", "patch"] + # Required for leader-election (if used). + - apiGroups: ["coordination.k8s.io"] + resources: ["leases"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + # Required for JOSDK event-source on ConfigMaps / Secrets (optional; remove if not needed). + - apiGroups: [""] + resources: ["events"] + verbs: ["create", "patch"] + # Add your operator's custom resource rules here via values.rbac.additionalRules, e.g.: + # additionalRules: + # - apiGroups: ["mygroup.example.com"] + # resources: ["myresources", "myresources/status"] + # verbs: ["*"] + {{- with .Values.rbac.additionalRules }} + {{- toYaml . | nindent 2 }} + {{- end }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "josdk-operator.fullname" . }} + labels: + {{- include "josdk-operator.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "josdk-operator.fullname" . }} +subjects: + - kind: ServiceAccount + name: {{ include "josdk-operator.serviceAccountName" . }} + namespace: {{ include "josdk-operator.namespace" . }} +{{- end }} diff --git a/helm/operator/templates/configmap-josdk.yaml b/helm/operator/templates/configmap-josdk.yaml new file mode 100644 index 0000000000..cd09277b2b --- /dev/null +++ b/helm/operator/templates/configmap-josdk.yaml @@ -0,0 +1,31 @@ +{{- if .Values.josdkConfig.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "josdk-operator.configMapName" . }} + namespace: {{ include "josdk-operator.namespace" . }} + labels: + {{- include "josdk-operator.labels" . | nindent 4 }} +data: + # All key-value pairs are written into josdk.properties. + # The operator reads this file via a file-backed ConfigProvider and passes it + # to ConfigLoader. See the ConfigLoader javadoc for supported property keys. + # + # Example operator-level keys: + # josdk.reconciliation.concurrent-threads=4 + # josdk.informer.stop-on-error-during-startup=false + # josdk.informer.cache-sync-timeout=PT30S + # + # Example controller-level keys (replace "my-controller" with the lower-cased + # reconciler class name): + # josdk.controller.my-controller.retry.max-attempts=5 + # josdk.controller.my-controller.retry.initial-interval=1000 + # josdk.controller.my-controller.retry.interval-multiplier=1.5 + # josdk.controller.my-controller.retry.max-interval=60000 + # josdk.controller.my-controller.rate-limiter.limit-for-period=10 + # josdk.controller.my-controller.rate-limiter.refresh-period=PT1S + josdk.properties: | + {{- range $key, $value := .Values.josdkConfig.properties }} + {{ $key }}={{ $value }} + {{- end }} +{{- end }} diff --git a/helm/operator/templates/configmap-log4j2.yaml b/helm/operator/templates/configmap-log4j2.yaml new file mode 100644 index 0000000000..2c630ac0bf --- /dev/null +++ b/helm/operator/templates/configmap-log4j2.yaml @@ -0,0 +1,37 @@ +{{- if .Values.log4j2.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "josdk-operator.log4j2ConfigMapName" . }} + namespace: {{ include "josdk-operator.namespace" . }} + labels: + {{- include "josdk-operator.labels" . | nindent 4 }} +data: + log4j2.xml: | + {{- if .Values.log4j2.xmlOverride }} + {{- .Values.log4j2.xmlOverride | nindent 4 }} + {{- else }} + + + + + + + + + + {{- range $logger, $level := .Values.log4j2.loggers }} + + + + {{- end }} + + + + + + {{- end }} +{{- end }} diff --git a/helm/operator/templates/deployment.yaml b/helm/operator/templates/deployment.yaml new file mode 100644 index 0000000000..efbcca02d5 --- /dev/null +++ b/helm/operator/templates/deployment.yaml @@ -0,0 +1,130 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "josdk-operator.fullname" . }} + namespace: {{ include "josdk-operator.namespace" . }} + labels: + {{- include "josdk-operator.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "josdk-operator.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "josdk-operator.selectorLabels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "josdk-operator.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: operator + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + # ------------------------------------------------------------------ + # Environment variables. + # JAVA_TOOL_OPTIONS is set when jvmArgs is non-empty or log4j2 is + # enabled (the helper appends -Dlog4j2.configurationFile). + # extraEnv entries are appended after. + # ------------------------------------------------------------------ + {{- $jvmArgs := include "josdk-operator.jvmArgs" . | trim }} + {{- if or $jvmArgs .Values.extraEnv }} + env: + {{- if $jvmArgs }} + - name: JAVA_TOOL_OPTIONS + value: {{ $jvmArgs | quote }} + {{- end }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- end }} + {{- if .Values.healthProbes.enabled }} + ports: + - name: health + containerPort: {{ .Values.healthProbes.port }} + protocol: TCP + startupProbe: + httpGet: + path: {{ .Values.healthProbes.startupProbe.path }} + port: {{ .Values.healthProbes.port }} + initialDelaySeconds: {{ .Values.healthProbes.startupProbe.initialDelaySeconds }} + periodSeconds: {{ .Values.healthProbes.startupProbe.periodSeconds }} + failureThreshold: {{ .Values.healthProbes.startupProbe.failureThreshold }} + livenessProbe: + httpGet: + path: {{ .Values.healthProbes.livenessProbe.path }} + port: {{ .Values.healthProbes.port }} + initialDelaySeconds: {{ .Values.healthProbes.livenessProbe.initialDelaySeconds }} + periodSeconds: {{ .Values.healthProbes.livenessProbe.periodSeconds }} + failureThreshold: {{ .Values.healthProbes.livenessProbe.failureThreshold }} + {{- end }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumeMounts: + # ---------------------------------------------------------------- + # JOSDK ConfigLoader properties file + # Mounted at /josdk.properties. + # Wire it up in your operator main class, e.g.: + # + # ConfigLoader loader = new ConfigLoader( + # PropertiesConfigProvider.fromFile( + # Path.of("/config/josdk.properties"))); + # ---------------------------------------------------------------- + {{- if .Values.josdkConfig.enabled }} + - name: josdk-config + mountPath: {{ .Values.josdkConfig.mountPath }} + readOnly: true + {{- end }} + # ---------------------------------------------------------------- + # Log4j2 configuration file + # Mounted at /log4j2.xml. + # Picked up automatically via JAVA_TOOL_OPTIONS when log4j2.enabled=true. + # ---------------------------------------------------------------- + {{- if .Values.log4j2.enabled }} + - name: log4j2-config + mountPath: {{ .Values.log4j2.mountPath }} + readOnly: true + {{- end }} + {{- with .Values.extraVolumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + volumes: + {{- if .Values.josdkConfig.enabled }} + - name: josdk-config + configMap: + name: {{ include "josdk-operator.configMapName" . }} + {{- end }} + {{- if .Values.log4j2.enabled }} + - name: log4j2-config + configMap: + name: {{ include "josdk-operator.log4j2ConfigMapName" . }} + {{- end }} + {{- with .Values.extraVolumes }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/helm/operator/templates/serviceaccount.yaml b/helm/operator/templates/serviceaccount.yaml new file mode 100644 index 0000000000..c31888988b --- /dev/null +++ b/helm/operator/templates/serviceaccount.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "josdk-operator.serviceAccountName" . }} + namespace: {{ include "josdk-operator.namespace" . }} + labels: + {{- include "josdk-operator.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} diff --git a/helm/operator/values.yaml b/helm/operator/values.yaml new file mode 100644 index 0000000000..bdf493dcdd --- /dev/null +++ b/helm/operator/values.yaml @@ -0,0 +1,153 @@ +# ----------------------------------------------------------------------- +# Generic JOSDK Operator Helm Chart – default values +# Override any of these in your own values.yaml or with --set on the CLI. +# ----------------------------------------------------------------------- + +# -- Operator identity ------------------------------------------------------- +nameOverride: "" +fullnameOverride: "" + +# -- Image ------------------------------------------------------------------- +image: + repository: my-operator + tag: latest + pullPolicy: IfNotPresent + +imagePullSecrets: [] + +# -- Replicas ---------------------------------------------------------------- +replicaCount: 1 + +# -- Namespace the operator is deployed into. +# Defaults to the Helm release namespace (.Release.Namespace). +namespace: "" + +# -- Service account --------------------------------------------------------- +serviceAccount: + # Create a dedicated ServiceAccount for the operator. + create: true + # Annotations to add (e.g. for IRSA / Workload Identity). + annotations: {} + # Override the auto-generated name. + name: "" + +# -- Pod settings ------------------------------------------------------------ +podAnnotations: {} +podLabels: {} + +podSecurityContext: + runAsNonRoot: true + runAsUser: 1000 + fsGroup: 2000 + +securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + +resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + +# -- Health probes ----------------------------------------------------------- +# Requires the operator to expose an HTTP health endpoint. +# The JOSDK sample operators expose /startup and /healthz on port 8080 by +# default; adjust if your operator uses different paths or ports. +healthProbes: + enabled: false + port: 8080 + startupProbe: + path: /startup + initialDelaySeconds: 5 + periodSeconds: 2 + failureThreshold: 15 + livenessProbe: + path: /healthz + initialDelaySeconds: 10 + periodSeconds: 10 + failureThreshold: 3 + +# -- RBAC -------------------------------------------------------------------- +rbac: + # Create ClusterRole + ClusterRoleBinding. + create: true + # Additional rules appended to the ClusterRole. + # Add entries for your operator's custom resources here, e.g.: + # - apiGroups: ["mygroup.example.com"] + # resources: ["myresources", "myresources/status"] + # verbs: ["*"] + additionalRules: [] + +# -- ConfigLoader configuration ConfigMap ------------------------------------ +# When enabled, a ConfigMap is created and mounted into the operator pod at +# /config/josdk.properties (or josdk.yaml). The operator must be coded to +# load it via ConfigLoader / ConfigProvider (or Spring, etc.). +# All key-value pairs under `config.properties` are written verbatim into the +# ConfigMap data entry `josdk.properties`. Use the JOSDK property keys +# documented in ConfigLoader (e.g. josdk.reconciliation.concurrent-threads). +josdkConfig: + enabled: false + # Name of the ConfigMap; defaults to -config. + configMapName: "" + # Mount path inside the container. + mountPath: /config + # Properties written into josdk.properties inside the ConfigMap. + # Example: + # properties: + # josdk.reconciliation.concurrent-threads: "4" + # josdk.workflow.executor-threads: "2" + # josdk.informer.stop-on-error-during-startup: "false" + # josdk.controller.my-controller.retry.max-attempts: "5" + # josdk.controller.my-controller.retry.initial-interval: "1000" + properties: {} + +# -- Log4j2 configuration ---------------------------------------------------- +# When enabled, a ConfigMap containing a log4j2.xml is created and mounted +# into the operator pod at /config/log4j2.xml. The operator must be launched +# with -Dlog4j2.configurationFile=/config/log4j2.xml (set via `jvmArgs` below) +# so that Log4j2 picks up the external file. +log4j2: + enabled: false + # Name of the ConfigMap; defaults to -log4j2. + configMapName: "" + # Mount path for the log4j2.xml file. + mountPath: /config + # Root log level (TRACE | DEBUG | INFO | WARN | ERROR | OFF). + rootLevel: INFO + # Per-logger overrides – map of logger-name → level. + # Example: + # loggers: + # io.javaoperatorsdk: DEBUG + # io.fabric8.kubernetes.client: WARN + loggers: {} + # Full override of the log4j2 XML content. When set, rootLevel and loggers + # are ignored and this raw XML is used instead. + xmlOverride: "" + +# -- Extra environment variables injected into the operator container -------- +# Example: +# extraEnv: +# - name: METRICS_CONSOLE_LOGGING +# value: "true" +extraEnv: [] + +# -- Extra volumes / mounts (user-defined, independent of the above) --------- +extraVolumes: [] +extraVolumeMounts: [] + +# -- JVM arguments passed to the operator process --------------------------- +# The log4j2 config file path is appended automatically when log4j2.enabled=true. +# Example: +# jvmArgs: "-Xmx256m -Xms128m" +jvmArgs: "" + +# -- Node scheduling --------------------------------------------------------- +nodeSelector: {} +tolerations: [] +affinity: {} 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/operator-framework-bom/pom.xml b/operator-framework-bom/pom.xml index 04d0f9273d..ccfb2f6266 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/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/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/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..6c9aee67c1 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); @@ -187,9 +185,8 @@ private void submitReconciliationExecution(ResourceState state) { 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)); @@ -334,7 +328,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 +341,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); }); } @@ -375,7 +365,7 @@ private void handleRetryOnException(ExecutionScope

executionScope, Exception retryAwareErrorLogging(state.getRetry(), eventPresent, exception, executionScope); if (eventPresent) { - log.debug("New events exists for for resource id: {}", resourceID); + log.debug("New events exist for resource id"); submitReconciliationExecution(state); return; } @@ -383,8 +373,7 @@ private void handleRetryOnException(ExecutionScope

executionScope, Exception nextDelay.ifPresentOrElse( delay -> { - log.debug( - "Scheduling timer event for retry with delay:{} for resource: {}", delay, resourceID); + log.debug("Scheduling timer event for retry with delay:{}", delay); metrics.failedReconciliation(executionScope.getResource(), exception, metricsMetadata); retryEventSource().scheduleOnce(resourceID, delay); }, @@ -425,8 +414,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,7 +432,7 @@ 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); } @@ -509,6 +497,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 +507,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,7 +525,7 @@ 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; } } 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..38c93d03ae 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,30 +173,32 @@ 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) { + var res = cache.get(resourceID); Optional resource = temporaryResourceCache.getResourceFromCache(resourceID); - if (resource.isPresent()) { - log.debug("Resource found in temporary cache for Resource ID: {}", 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; } @SuppressWarnings("unused") @@ -212,4 +265,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/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..bff9ef3dbd 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; 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..c3a6f8e91e 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 = @@ -73,11 +85,12 @@ void setup() { 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()); @@ -90,37 +103,27 @@ public synchronized void start() {} 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 +131,8 @@ void processEventPropagationWithoutAnnotation() { @Test void processEventPropagationWithIncorrectAnnotation() { + when(temporaryResourceCache.onAddOrUpdateEvent(any(), any(), any())) + .thenReturn(EventHandling.NEW); informerEventSource.onAdd( new DeploymentBuilder(testDeployment()) .editMetadata() @@ -140,21 +145,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 +172,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 +182,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 +192,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/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/PreviousAnnotationDisabledIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/ComparableResourceVersionsDisabledIT.java similarity index 94% rename from operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/PreviousAnnotationDisabledIT.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/ComparableResourceVersionsDisabledIT.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/ComparableResourceVersionsDisabledIT.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/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/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 393ea4a311..9e52d87be1 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/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..ea4f50256e 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,7 @@ io.javaoperatorsdk - operator-framework-junit-5 + operator-framework-junit test diff --git a/sample-operators/mysql-schema/src/main/resources/log4j2.xml b/sample-operators/mysql-schema/src/main/resources/log4j2.xml index 054261c13f..2979258355 100644 --- a/sample-operators/mysql-schema/src/main/resources/log4j2.xml +++ b/sample-operators/mysql-schema/src/main/resources/log4j2.xml @@ -19,11 +19,11 @@ - + - + diff --git a/sample-operators/pom.xml b/sample-operators/pom.xml index 46d70eaf7b..374fb30a73 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 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/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..2979258355 100644 --- a/sample-operators/webpage/src/main/resources/log4j2.xml +++ b/sample-operators/webpage/src/main/resources/log4j2.xml @@ -19,7 +19,7 @@ - + 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