diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5006fb4de9..6441364993 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,7 +14,7 @@ jobs: # Use the latest versions supported by minikube, otherwise GitHub it will # end up in a throttling requests from minikube and workflow will fail. # Minikube does such requests only if a version is not officially supported. - kubernetes: [ 'v1.32.13', 'v1.33.9', 'v1.34.5', 'v1.35.2' ] + kubernetes: [ 'v1.34.11', 'v1.35.8', 'v1.36.4', 'v1.37.0' ] uses: ./.github/workflows/integration-tests.yml with: java-version: ${{ matrix.java }} @@ -27,7 +27,7 @@ jobs: uses: ./.github/workflows/integration-tests.yml with: java-version: 25 - kube-version: 'v1.35.2' + kube-version: 'v1.37.0' http-client: ${{ matrix.httpclient }} experimental: true diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 2a0129d5ae..d5e2b76cc8 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -27,18 +27,44 @@ jobs: - "sample-operators/operations" - "sample-operators/kotlin-operator" runs-on: ubuntu-latest + env: + MINIKUBE_VERSION: v1.39.0 + # Use the latest versions supported by minikube, otherwise GitHub it will + # end up in a throttling requests from minikube and workflow will fail. + # Minikube does such requests only if a version is not officially supported. + KUBERNETES_VERSION: v1.37.0 steps: - name: Checkout uses: actions/checkout@v7 + # minikube downloads its base image and the preloaded Kubernetes images on every start, + # which regularly times out when many matrix jobs start at once + - name: Cache Minikube downloads + uses: actions/cache@v6 + with: + path: ${{ runner.temp }}/.minikube/cache + key: minikube-${{ runner.os }}-${{ env.MINIKUBE_VERSION }}-${{ env.KUBERNETES_VERSION }}-docker + - name: Setup Minikube-Kubernetes - uses: manusa/actions-setup-minikube@v2.18.0 + id: minikube + continue-on-error: true + uses: manusa/actions-setup-minikube@v2.19.0 + with: + minikube version: ${{ env.MINIKUBE_VERSION }} + kubernetes version: ${{ env.KUBERNETES_VERSION }} + github token: ${{ secrets.GITHUB_TOKEN }} + driver: docker + + - name: Clean up failed Minikube setup + if: steps.minikube.outcome == 'failure' + run: '"$MINIKUBE_HOME/minikube" delete || true' + + - name: Setup Minikube-Kubernetes (retry) + if: steps.minikube.outcome == 'failure' + uses: manusa/actions-setup-minikube@v2.19.0 with: - minikube version: 'v1.38.1' - # Use the latest versions supported by minikube, otherwise GitHub it will - # end up in a throttling requests from minikube and workflow will fail. - # Minikube does such requests only if a version is not officially supported. - kubernetes version: 'v1.35.2' + minikube version: ${{ env.MINIKUBE_VERSION }} + kubernetes version: ${{ env.KUBERNETES_VERSION }} github token: ${{ secrets.GITHUB_TOKEN }} driver: docker diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 95537f33ee..3f1353efea 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -28,6 +28,8 @@ jobs: runs-on: ubuntu-latest continue-on-error: ${{ inputs.experimental }} timeout-minutes: 120 + env: + MINIKUBE_VERSION: v1.39.0 steps: - uses: actions/checkout@v7 with: @@ -38,10 +40,29 @@ jobs: distribution: temurin java-version: ${{ inputs.java-version }} cache: 'maven' + # minikube downloads kubelet, kubeadm and kubectl from dl.k8s.io on every start, which + # regularly times out when many matrix jobs start at once + - name: Cache Minikube downloads + uses: actions/cache@v6 + with: + path: ${{ runner.temp }}/.minikube/cache + key: minikube-${{ runner.os }}-${{ env.MINIKUBE_VERSION }}-${{ inputs.kube-version }}-none - name: Set up Minikube - uses: manusa/actions-setup-minikube@v2.18.0 + id: minikube + continue-on-error: true + uses: manusa/actions-setup-minikube@v2.19.0 + with: + minikube version: ${{ env.MINIKUBE_VERSION }} + kubernetes version: '${{ inputs.kube-version }}' + github token: ${{ github.token }} + - name: Clean up failed Minikube setup + if: steps.minikube.outcome == 'failure' + run: sudo -E --preserve-env=HOME,MINIKUBE_HOME "$MINIKUBE_HOME/minikube" delete || true + - name: Set up Minikube (retry) + if: steps.minikube.outcome == 'failure' + uses: manusa/actions-setup-minikube@v2.19.0 with: - minikube version: 'v1.38.1' + minikube version: ${{ env.MINIKUBE_VERSION }} kubernetes version: '${{ inputs.kube-version }}' github token: ${{ github.token }} diff --git a/.github/workflows/release-project-in-dir.yml b/.github/workflows/release-project-in-dir.yml index 54c01f20a5..ea375c3d93 100644 --- a/.github/workflows/release-project-in-dir.yml +++ b/.github/workflows/release-project-in-dir.yml @@ -17,6 +17,22 @@ env: # set the target pom to use the input directory as root MAVEN_ARGS: -V -ntp -e -f ${{ inputs.project_dir }}/pom.xml ROOT_POM: ${{ inputs.project_dir }}/pom.xml + # Prints the version of the pom given as argument, falling back to the + # parent's version. Used instead of `help:evaluate`, whose banner and log + # output would have to be filtered off stdout first. Kept inline rather than + # as a script in the repo because the jobs check out the release branch, and + # older maintenance branches would not have such a script. + POM_VERSION_PY: | + import sys + import xml.etree.ElementTree as ET + NS = "{http://maven.apache.org/POM/4.0.0}" + root = ET.parse(sys.argv[1]).getroot() + version = root.findtext(NS + "version") + if version is None: + version = root.findtext(NS + "parent/" + NS + "version") + if version is None: + raise SystemExit("no version and no parent version in " + sys.argv[1]) + print(version.strip()) jobs: publish: @@ -29,11 +45,43 @@ jobs: uses: actions/checkout@v7 with: ref: "${{ inputs.version_branch }}" + # Full history and tags are needed to check where the release tag + # currently points relative to the branch. + fetch-depth: 0 + fetch-tags: true - name: Resolve checked-out commit id: resolve-sha run: echo "commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - name: Check the release tag can be moved onto this branch + env: + RELEASE_TAG: ${{ inputs.release_tag }} + TARGET_BRANCH: ${{ inputs.version_branch }} + run: | + set -euo pipefail + + # finalize-release moves the tag onto a commit built on top of this + # one, which is only legitimate if the tag already points into this + # branch's history. Checking it here rather than after the deploy + # means a release cut from an unrelated commit fails while it can + # still be retried - once artifacts are in Maven Central they are + # immutable. + # + # Peel the tag in a separate step from testing its existence: a tag + # that resolves to something other than a commit must fail loudly + # rather than look like an absent tag and skip the check. + if git rev-parse -q --verify "refs/tags/${RELEASE_TAG}" >/dev/null; then + if ! CURRENT_TAGGED="$(git rev-parse -q --verify "refs/tags/${RELEASE_TAG}^{commit}")"; then + echo "Tag ${RELEASE_TAG} does not resolve to a commit" + exit 1 + fi + if ! git merge-base --is-ancestor "${CURRENT_TAGGED}" HEAD; then + echo "Tag ${RELEASE_TAG} points at ${CURRENT_TAGGED}, which is not an ancestor of ${TARGET_BRANCH}" + exit 1 + fi + fi + - name: Set up Java and Maven uses: actions/setup-java@v6 with: @@ -49,6 +97,7 @@ jobs: - name: Change version to release version env: RELEASE_TAG: ${{ inputs.release_tag }} + TARGET_BRANCH: ${{ inputs.version_branch }} run: | set -euo pipefail RELEASE_VERSION="${RELEASE_TAG#v}" @@ -63,10 +112,24 @@ jobs: exit 1 fi + # finalize-release derives the next development version from the + # released one, so releasing a version older than the branch already + # develops would set the branch back onto versions that are already + # released. That means the wrong branch was selected for this tag. + # Checked here because the deploy that follows cannot be undone. + DEVELOPMENT_VERSION="$(python3 -c "${POM_VERSION_PY}" "${ROOT_POM}")" + DEVELOPMENT_BASE="${DEVELOPMENT_VERSION%-SNAPSHOT}" + if [ "${RELEASE_VERSION}" != "${DEVELOPMENT_BASE}" ] && \ + [ "$(printf '%s\n%s\n' "${RELEASE_VERSION}" "${DEVELOPMENT_BASE}" | sort -V | head -1)" = "${RELEASE_VERSION}" ]; then + echo "${TARGET_BRANCH} develops ${DEVELOPMENT_VERSION}, which is newer than ${RELEASE_VERSION}" + echo "Is ${RELEASE_TAG} being released from the right branch?" + exit 1 + fi + ./mvnw ${MAVEN_ARGS} versions:set -DnewVersion="${RELEASE_VERSION}" versions:commit -DprocessAllModules - name: Publish to Apache Maven Central - run: ./mvnw package deploy -Prelease + run: ./mvnw package deploy -Prelease -Dmaven.repository.credentialScope=id env: MAVEN_USERNAME: ${{ secrets.NEXUS_USERNAME }} MAVEN_CENTRAL_TOKEN: ${{ secrets.NEXUS_PASSWORD }} @@ -106,17 +169,16 @@ jobs: id: commits env: RELEASE_TAG: ${{ inputs.release_tag }} + TARGET_BRANCH: ${{ inputs.version_branch }} run: | set -euo pipefail - # Reads the version of the root pom directly, rather than through - # help:evaluate, whose banner and log output would have to be filtered - # out of stdout first. pom_version() { - python3 -c 'import sys, xml.etree.ElementTree as ET; ns = "{http://maven.apache.org/POM/4.0.0}"; root = ET.parse(sys.argv[1]).getroot(); version = root.findtext(ns + "version") or root.findtext(ns + "parent/" + ns + "version"); print(version.strip())' "${ROOT_POM}" + python3 -c "${POM_VERSION_PY}" "${ROOT_POM}" } RELEASE_VERSION="${RELEASE_TAG#v}" + DEVELOPMENT_VERSION="$(pom_version)" git config --local user.email "action@github.com" git config --local user.name "GitHub Action" @@ -160,6 +222,17 @@ jobs: ;; esac + # The bump is derived from the released version, so releasing a tag + # older than the branch's own development version would move the + # branch backwards - onto versions that have already been released. + # That means the wrong branch was selected for the tag, so stop. + if [ "${NEXT_VERSION}" != "${DEVELOPMENT_VERSION}" ] && \ + [ "$(printf '%s\n%s\n' "${NEXT_VERSION}" "${DEVELOPMENT_VERSION}" | sort -V | head -1)" = "${NEXT_VERSION}" ]; then + echo "Next development version ${NEXT_VERSION} would move ${TARGET_BRANCH} back from ${DEVELOPMENT_VERSION}" + echo "Is ${RELEASE_TAG} being released from the right branch?" + exit 1 + fi + if git diff --quiet; then echo "Branch would be left on release version ${RELEASE_VERSION}" exit 1 @@ -168,6 +241,7 @@ jobs: echo "Next development version: ${NEXT_VERSION}" - name: Move release tag onto the release commit + id: tag env: RELEASE_TAG: ${{ inputs.release_tag }} RELEASE_COMMIT: ${{ steps.commits.outputs.release_commit }} @@ -176,16 +250,25 @@ jobs: # GitHub created the tag on whatever the branch tip was when the # release was published, so it is expected to move - but only forward, - # onto a descendant. Anything else means the release was cut from a - # commit this workflow did not build, and silently discarding it would - # lose the tagged state. - if git rev-parse -q --verify "refs/tags/${RELEASE_TAG}^{commit}" >/dev/null; then + # onto a descendant. publish already checked this against the branch; + # repeat it here because the release commit is what actually gets + # tagged. + # + # The OID recorded here is the tag ref itself, not the commit it peels + # to, because that is what the push has to lease against. An empty + # value means the tag did not exist, which --force-with-lease reads as + # "must still not exist". + if git rev-parse -q --verify "refs/tags/${RELEASE_TAG}" >/dev/null; then + EXPECTED_TAG_OID="$(git rev-parse "refs/tags/${RELEASE_TAG}")" CURRENT_TAGGED="$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}")" if ! git merge-base --is-ancestor "${CURRENT_TAGGED}" "${RELEASE_COMMIT}"; then echo "Tag ${RELEASE_TAG} points at ${CURRENT_TAGGED}, which is not an ancestor of ${RELEASE_COMMIT}" exit 1 fi + else + EXPECTED_TAG_OID="" fi + echo "expected_tag_oid=${EXPECTED_TAG_OID}" >> "$GITHUB_OUTPUT" git tag -f -a "${RELEASE_TAG}" "${RELEASE_COMMIT}" -m "Release ${RELEASE_TAG}" @@ -193,13 +276,35 @@ jobs: env: RELEASE_TAG: ${{ inputs.release_tag }} TARGET_BRANCH: ${{ inputs.version_branch }} + EXPECTED_TAG_OID: ${{ steps.tag.outputs.expected_tag_oid }} run: | set -euo pipefail # One atomic push so the branch is never left holding the release - # commit without the SNAPSHOT commit that follows it. The branch - # refspec is not forced: if something landed on the branch while the - # release was being deployed, this fails instead of clobbering it. - git push --atomic origin \ + # commit without the SNAPSHOT commit that follows it. + # + # Neither ref can be clobbered: the branch refspec is not forced, so a + # commit landing during the deploy fails the push, and the tag is + # leased against the OID observed during the ancestry check, so a tag + # moved by anyone else since then fails it too. --atomic means either + # both refs update or neither does. + # + # Recovering from a rejected push: by now the artifacts are in Maven + # Central, so re-running the workflow fails in publish, and re-running + # just this job rebuilds on the same stale commit and is rejected + # again. Both refs are still in a safe state - the branch on a + # SNAPSHOT version, the tag where GitHub created it - so finish the + # release by hand: + # 1. Check out the commit this job built on (the one shown by its + # checkout step, i.e. what was deployed), set the released + # version with versions:set, commit "Release ", and move + # the tag onto that commit with an annotated tag. Push the tag + # with --force-with-lease against its current remote value. + # 2. Check out the current branch tip, set the next -SNAPSHOT + # version with versions:set, and push that as an ordinary + # commit. + git push --atomic \ + --force-with-lease="refs/tags/${RELEASE_TAG}:${EXPECTED_TAG_OID}" \ + origin \ "HEAD:refs/heads/${TARGET_BRANCH}" \ - "+refs/tags/${RELEASE_TAG}" + "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b42dd02233..3b144d6cc7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,8 +5,10 @@ on: release: types: [ released ] -# Releases push commits to the branch they are cut from, so run them one at a -# time rather than letting two overlap on the same branch. +# Two releases cut from the same branch must not overlap: both would deploy to +# Maven Central, then one would lose the branch push and be left with immutable +# artifacts and no release commit. Serializing means the worst case is instead a +# release that does not start, which can simply be re-run. concurrency: group: ${{ github.workflow }} cancel-in-progress: false diff --git a/.github/workflows/snapshot-releases.yml b/.github/workflows/snapshot-releases.yml index 418437614c..04cc3d4249 100644 --- a/.github/workflows/snapshot-releases.yml +++ b/.github/workflows/snapshot-releases.yml @@ -43,7 +43,7 @@ jobs: gpg-passphrase: MAVEN_GPG_PASSPHRASE - name: Publish to Apache Maven Central - run: ./mvnw package deploy -Prelease + run: ./mvnw package deploy -Prelease -Dmaven.repository.credentialScope=id env: MAVEN_USERNAME: ${{ secrets.NEXUS_USERNAME }} MAVEN_CENTRAL_TOKEN: ${{ secrets.NEXUS_PASSWORD }} diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties index 216df05897..bb58b3c263 100644 --- a/.mvn/wrapper/maven-wrapper.properties +++ b/.mvn/wrapper/maven-wrapper.properties @@ -1,3 +1,3 @@ wrapperVersion=3.3.4 distributionType=only-script -distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.10.0/apache-maven-3.10.0-bin.zip diff --git a/bootstrapper-maven-plugin/pom.xml b/bootstrapper-maven-plugin/pom.xml index 99fb569606..8f5a7c5684 100644 --- a/bootstrapper-maven-plugin/pom.xml +++ b/bootstrapper-maven-plugin/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk java-operator-sdk - 5.6.2 + 5.6.4-SNAPSHOT bootstrapper @@ -32,7 +32,7 @@ 3.16.0 - 3.9.16 + 3.10.0 3.1.1 3.16.0 diff --git a/caffeine-bounded-cache-support/pom.xml b/caffeine-bounded-cache-support/pom.xml index 12c57f1a4b..d3d26a03fd 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.6.2 + 5.6.4-SNAPSHOT caffeine-bounded-cache-support diff --git a/docs/content/en/docs/documentation/error-handling-retries.md b/docs/content/en/docs/documentation/error-handling-retries.md index 428c35b9ae..fe60334fd3 100644 --- a/docs/content/en/docs/documentation/error-handling-retries.md +++ b/docs/content/en/docs/documentation/error-handling-retries.md @@ -12,12 +12,18 @@ JOSDK automatically schedules retries whenever your `Reconciler` throws an excep The default retry implementation covers most typical use cases with exponential backoff: ```java -GenericRetry.defaultLimitedExponentialRetry() - .setInitialInterval(5000) // Start with 5-second delay - .setIntervalMultiplier(1.5D) // Increase delay by 1.5x each retry - .setMaxAttempts(5); // Maximum 5 attempts +GenericRetry.builder() + .withInitialInterval(Duration.ofSeconds(5)) // Start with 5-second delay + .withIntervalMultiplier(1.5D) // Increase delay by 1.5x each retry + .withMaxAttempts(5) // Maximum 5 attempts + .withMaxInterval(Duration.ofMinutes(1)) // Cap the delay at 1 minute + .build(); ``` +Prefer the builder for creating `GenericRetry` instances, and use `toBuilder()` to derive a variant +of an existing configuration. The `set*` methods are deprecated and will be removed in a future +major version, at which point `GenericRetry` becomes immutable. + ### Configuration Options **Using the `@GradualRetry` annotation:** diff --git a/docs/content/en/docs/documentation/reconciler.md b/docs/content/en/docs/documentation/reconciler.md index 1fa183633f..86e701d801 100644 --- a/docs/content/en/docs/documentation/reconciler.md +++ b/docs/content/en/docs/documentation/reconciler.md @@ -157,6 +157,8 @@ You can specify the name of the finalizer to use for your `Reconciler` using the annotation. If you do not specify a finalizer name, one will be automatically generated for you. From v5, by default, the finalizer is added using Server Side Apply. See also `UpdateControl` in docs. +The apply carries the resource's UID, so a resource deleted before the finalizer was added is not +re-created: the API server rejects the apply, and the framework skips adding the finalizer. ### Read-cache-after-write consistency and event filtering diff --git a/docs/content/en/docs/documentation/testing.md b/docs/content/en/docs/documentation/testing.md index 271a2d1dc7..9a74a5376f 100644 --- a/docs/content/en/docs/documentation/testing.md +++ b/docs/content/en/docs/documentation/testing.md @@ -117,7 +117,7 @@ LocallyRunOperatorExtension.builder() // Override controller configuration .withReconciler(new MyReconciler(), config -> config .settingNamespace("specific-namespace") - .withRetry(new GenericRetry().withLinearRetry())) + .withRetry(GenericRetry.builder().withLinearRetry().build())) // Pre-deploy infrastructure resources before operator starts .withInfrastructure(configMap, secret) // Register CRDs for resources not managed by a reconciler diff --git a/micrometer-support/pom.xml b/micrometer-support/pom.xml index cdab82d873..f79444b248 100644 --- a/micrometer-support/pom.xml +++ b/micrometer-support/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.6.2 + 5.6.4-SNAPSHOT micrometer-support diff --git a/migration/pom.xml b/migration/pom.xml index 2a081e7f77..56a1bd82f0 100644 --- a/migration/pom.xml +++ b/migration/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.6.2 + 5.6.4-SNAPSHOT migration diff --git a/operator-framework-bom/pom.xml b/operator-framework-bom/pom.xml index b603baebe2..2cd8d16139 100644 --- a/operator-framework-bom/pom.xml +++ b/operator-framework-bom/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk operator-framework-bom - 5.6.2 + 5.6.4-SNAPSHOT pom Operator SDK - Bill of Materials Java SDK for implementing Kubernetes operators @@ -55,7 +55,7 @@ 3.4.0 3.12.0 3.9.0 - 0.11.0 + 0.12.0 diff --git a/operator-framework-core/pom.xml b/operator-framework-core/pom.xml index 7d4f0780ed..6f5d63cc53 100644 --- a/operator-framework-core/pom.xml +++ b/operator-framework-core/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.6.2 + 5.6.4-SNAPSHOT ../pom.xml @@ -32,7 +32,7 @@ - 2.4.20 + 2.4.21 diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index eb0b0cf144..93bbe7fa7b 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -16,7 +16,9 @@ package io.javaoperatorsdk.operator.api.reconciler; import java.lang.reflect.InvocationTargetException; +import java.net.HttpURLConnection; import java.util.Optional; +import java.util.function.IntPredicate; import java.util.function.Predicate; import java.util.function.UnaryOperator; @@ -1170,9 +1172,34 @@ public P conflictRetryingPatchPrimary( * @return updated resource from the server or unchanged if the precondition does not hold * @throws OperatorException if the maximum number of retry attempts is exceeded */ - @SuppressWarnings("unchecked") public P conflictRetryingPatchPrimary( UnaryOperator

resourceChangesOperator, Predicate

preCondition, boolean cacheOnly) { + return conflictRetrying( + preCondition, + r -> + jsonPatchPrimary( + r, + resourceChangesOperator, + cacheOnly + ? Options.cacheOnly() + : Options.matchAndFilterWithDefaultMatcher(UpdateType.JSON_PATCH)), + // also retry on 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. + code -> code == HttpURLConnection.HTTP_CONFLICT || code == 422); + } + + /** + * Runs the given write against the primary resource, re-fetching the resource and retrying when + * the server responds with one of the retried HTTP codes, up to {@link + * ResourceOperations#DEFAULT_MAX_RETRY} times. The pre-condition is evaluated against the + * (possibly re-fetched) resource before each attempt; a re-fetched resource is {@code null} if it + * no longer exists. + */ + @SuppressWarnings("unchecked") + private P conflictRetrying( + Predicate

preCondition, UnaryOperator

write, IntPredicate retriedCode) { var resource = context.getPrimaryResource(); var client = context.getClient(); if (log.isDebugEnabled()) { @@ -1184,22 +1211,11 @@ public P conflictRetryingPatchPrimary( if (!preCondition.test(resource)) { return resource; } - return jsonPatchPrimary( - resource, - resourceChangesOperator, - cacheOnly - ? Options.cacheOnly() - : Options.matchAndFilterWithDefaultMatcher(UpdateType.JSON_PATCH)); + return write.apply(resource); } 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) { + if (!retriedCode.test(e.getCode())) { throw e; } if (retryIndex >= DEFAULT_MAX_RETRY) { @@ -1235,7 +1251,8 @@ public P conflictRetryingPatchPrimary( * 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 + * @return the patched resource from the server response, the resource unchanged if the finalizer + * does not need to be added, or {@code null} if the resource no longer exists * @see #addFinalizerWithSSA(String) */ public P addFinalizerWithSSA() { @@ -1244,28 +1261,51 @@ public P addFinalizerWithSSA() { /** * 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. + * the target resource, setting only name, namespace, UID and finalizer. It does not try to add + * the finalizer if there is already a finalizer or the resource is marked for deletion. Note that + * explicitly adding finalizer is required only if "Trigger reconciliation on all event" mode is + * on. + * + *

A Server-Side Apply creates the resource if it does not exist, so without further + * precautions the finalizer add would re-create a resource deleted after it was cached. The UID + * makes the API server reject the apply instead: with a conflict (HTTP 409) if the resource no + * longer exists, and with unprocessable content (HTTP 422) if it was re-created under the same + * name. In both cases the resource is re-fetched and the apply retried, up to {@link + * ResourceOperations#DEFAULT_MAX_RETRY} times; if the resource no longer exists, nothing is + * applied and {@code null} is returned. No optimistic locking is used. * * @param finalizerName name of the finalizer to add - * @return the patched resource from the server response + * @return the patched resource from the server response, the resource unchanged if the finalizer + * does not need to be added, or {@code null} if the resource no longer exists */ public P addFinalizerWithSSA(String finalizerName) { - var originalResource = context.getPrimaryResource(); if (log.isDebugEnabled()) { + var originalResource = context.getPrimaryResource(); log.debug( "Adding finalizer (using SSA) for resource: {} version: {}", getUID(originalResource), getVersion(originalResource)); } + return conflictRetrying( + r -> { + if (r == null) { + log.warn("Cannot add finalizer since resource no longer exists."); + return false; + } + return !r.isMarkedForDeletion() && !r.hasFinalizer(finalizerName); + }, + r -> serverSideApplyPrimary(finalizerApplyFor(r, finalizerName), Options.cacheOnly()), + code -> code == HttpURLConnection.HTTP_CONFLICT || code == 422); + } + + private P finalizerApplyFor(P actualResource, String finalizerName) { try { @SuppressWarnings("unchecked") - P resource = (P) originalResource.getClass().getConstructor().newInstance(); - resource.initNameAndNamespaceFrom(originalResource); + P resource = (P) actualResource.getClass().getConstructor().newInstance(); + resource.initNameAndNamespaceFrom(actualResource); + resource.getMetadata().setUid(actualResource.getMetadata().getUid()); resource.addFinalizer(finalizerName); - - return serverSideApplyPrimary(resource, Options.cacheOnly()); + return resource; } catch (InstantiationException | IllegalAccessException | InvocationTargetException @@ -1273,7 +1313,7 @@ public P addFinalizerWithSSA(String finalizerName) { throw new RuntimeException( "Issue with creating custom resource instance with reflection." + " Custom Resources must provide a no-arg constructor. Class: " - + originalResource.getClass().getName(), + + actualResource.getClass().getName(), e); } } 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 6caf39ccd9..d53b65920e 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 @@ -120,19 +120,31 @@ public void changeNamespaces(Set namespaces) { return; } + // Registering the handler on an already populated shared informer replays its cache to the + // handler synchronously, on the registering thread. The event source holds its monitor while + // waiting for the informers below to start, so the handler has to be registered on this thread: + // a replay from an informer starter thread would block on that monitor and deadlock. + var newSources = newNamespaces.stream().map(this::getEventSourceForNamespace).toList(); controllerConfiguration .getConfigurationService() .getExecutorServiceManager() .boundedExecuteAndWaitForAllToComplete( - newNamespaces.stream(), - ns -> { - final var source = getEventSourceForNamespace(ns); + newSources.stream(), + source -> { // block until the informer's cache is synced (or the sync timeout elapses) start(source); - log.debug("Registered new {} -> {} for namespace: {}", this, source, ns); + log.debug( + "Registered new {} -> {} for namespace: {}", + this, + source, + source.getTargetNamespace()); return null; }, - ns -> "InformerStarter-" + ns + "-" + configuration.getResourceClass().getSimpleName()); + source -> + "InformerStarter-" + + source.getTargetNamespace() + + "-" + + configuration.getResourceClass().getSimpleName()); } private void start(InformerWrapper informerWrapper) { diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/GenericRetry.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/GenericRetry.java index 5ee016b34f..4c98e5a714 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/GenericRetry.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/GenericRetry.java @@ -32,16 +32,42 @@ public class GenericRetry implements Retry, AnnotationConfigurable @Deprecated(forRemoval = true) public static final Retry DEFAULT = new GenericRetry(); + /** + * Creates an instance with the default values. Prefer {@link #builder()} or {@link + * #defaultLimitedExponentialRetry()}; this constructor is kept public so that the class can be + * instantiated and configured from the {@link GradualRetry} annotation. + */ + public GenericRetry() {} + + private GenericRetry(Builder builder) { + this.maxAttempts = builder.maxAttempts; + this.initialInterval = builder.initialInterval; + this.intervalMultiplier = builder.intervalMultiplier; + this.maxInterval = builder.maxInterval; + } + + public static Builder builder() { + return new Builder(); + } + public static GenericRetry defaultLimitedExponentialRetry() { - return new GenericRetry(); + return builder().build(); } public static GenericRetry noRetry() { - return new GenericRetry().setMaxAttempts(0); + return builder().withMaxAttempts(0).build(); } public static GenericRetry every10second10TimesRetry() { - return new GenericRetry().withLinearRetry().setMaxAttempts(10).setInitialInterval(10000); + return builder() + .withLinearRetry() + .withMaxAttempts(10) + .withInitialInterval(Duration.ofSeconds(10)) + .build(); + } + + public Builder toBuilder() { + return new Builder(this); } @Override @@ -53,6 +79,10 @@ public int getMaxAttempts() { return maxAttempts; } + /** + * @deprecated use {@link Builder#withMaxAttempts(int)} instead + */ + @Deprecated(forRemoval = true) public GenericRetry setMaxAttempts(int maxRetryAttempts) { this.maxAttempts = maxRetryAttempts; return this; @@ -62,11 +92,19 @@ public long getInitialInterval() { return initialInterval; } + /** + * @deprecated use {@link Builder#withInitialInterval(Duration)} instead + */ + @Deprecated(forRemoval = true) public GenericRetry setInitialInterval(Duration initialInterval) { - setInitialInterval(initialInterval.toMillis()); + this.initialInterval = initialInterval.toMillis(); return this; } + /** + * @deprecated use {@link Builder#withInitialInterval(long)} instead + */ + @Deprecated(forRemoval = true) public GenericRetry setInitialInterval(long initialInterval) { this.initialInterval = initialInterval; return this; @@ -76,6 +114,10 @@ public double getIntervalMultiplier() { return intervalMultiplier; } + /** + * @deprecated use {@link Builder#withIntervalMultiplier(double)} instead + */ + @Deprecated(forRemoval = true) public GenericRetry setIntervalMultiplier(double intervalMultiplier) { this.intervalMultiplier = intervalMultiplier; return this; @@ -85,24 +127,46 @@ public long getMaxInterval() { return maxInterval; } + /** + * @deprecated use {@link Builder#withMaxInterval(long)} instead + */ + @Deprecated(forRemoval = true) public GenericRetry setMaxInterval(long maxInterval) { this.maxInterval = maxInterval; return this; } + /** + * @deprecated use {@link Builder#withMaxInterval(Duration)} instead + */ + @Deprecated(forRemoval = true) public GenericRetry setMaxInterval(Duration maxInterval) { - return setMaxInterval(maxInterval.toMillis()); + this.maxInterval = maxInterval.toMillis(); + return this; } + /** + * @deprecated use {@link Builder#withoutMaxInterval()} instead + */ + @Deprecated(forRemoval = true) public GenericRetry withoutMaxInterval() { this.maxInterval = -1; return this; } + /** + * @deprecated use {@link Builder#withoutMaxAttempts()} instead + */ + @Deprecated(forRemoval = true) public GenericRetry withoutMaxAttempts() { - return this.setMaxAttempts(-1); + this.maxAttempts = -1; + return this; } + /** + * @deprecated use {@link Builder#withLinearRetry()} instead + */ + @Deprecated(forRemoval = true) public GenericRetry withLinearRetry() { this.intervalMultiplier = 1; return this; @@ -118,4 +182,77 @@ public void initFrom(GradualRetry configuration) { ? GradualRetry.DEFAULT_MAX_INTERVAL : configuration.maxInterval(); } + + @Override + public String toString() { + return "GenericRetry{maxAttempts=" + + maxAttempts + + ", initialInterval=" + + initialInterval + + ", intervalMultiplier=" + + intervalMultiplier + + ", maxInterval=" + + maxInterval + + '}'; + } + + public static final class Builder { + private int maxAttempts = GradualRetry.DEFAULT_MAX_ATTEMPTS; + private long initialInterval = GradualRetry.DEFAULT_INITIAL_INTERVAL; + private double intervalMultiplier = GradualRetry.DEFAULT_MULTIPLIER; + private long maxInterval = GradualRetry.DEFAULT_MAX_INTERVAL; + + private Builder() {} + + private Builder(GenericRetry retry) { + this.maxAttempts = retry.maxAttempts; + this.initialInterval = retry.initialInterval; + this.intervalMultiplier = retry.intervalMultiplier; + this.maxInterval = retry.maxInterval; + } + + public Builder withMaxAttempts(int maxAttempts) { + this.maxAttempts = maxAttempts; + return this; + } + + public Builder withoutMaxAttempts() { + return withMaxAttempts(-1); + } + + public Builder withInitialInterval(long initialIntervalMillis) { + this.initialInterval = initialIntervalMillis; + return this; + } + + public Builder withInitialInterval(Duration initialInterval) { + return withInitialInterval(initialInterval.toMillis()); + } + + public Builder withIntervalMultiplier(double intervalMultiplier) { + this.intervalMultiplier = intervalMultiplier; + return this; + } + + public Builder withLinearRetry() { + return withIntervalMultiplier(1); + } + + public Builder withMaxInterval(long maxIntervalMillis) { + this.maxInterval = maxIntervalMillis; + return this; + } + + public Builder withMaxInterval(Duration maxInterval) { + return withMaxInterval(maxInterval.toMillis()); + } + + public Builder withoutMaxInterval() { + return withMaxInterval(-1); + } + + public GenericRetry build() { + return new GenericRetry(this); + } + } } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/AddFinalizerWithSSAIntegrationIT.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/AddFinalizerWithSSAIntegrationIT.java new file mode 100644 index 0000000000..09db57fe45 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/AddFinalizerWithSSAIntegrationIT.java @@ -0,0 +1,151 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.api.reconciler; + +import java.util.Map; +import java.util.function.UnaryOperator; + +import org.junit.jupiter.api.Test; + +import io.fabric8.kubeapitest.junit.EnableKubeAPIServer; +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.processing.event.EventSourceRetriever; +import io.javaoperatorsdk.operator.processing.event.source.controller.ControllerEventSource; + +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; + +/** + * Adds the finalizer with Server-Side Apply against a real API server, which is what decides + * whether the apply creates a missing resource or rejects it. + */ +@EnableKubeAPIServer +class AddFinalizerWithSSAIntegrationIT { + + private static final String NAMESPACE = "default"; + private static final String FINALIZER = "int.test/finalizer"; + + static KubernetesClient client; + + @Test + void addsFinalizerToExistingResource() { + var cached = createConfigMap("existing"); + + var result = resourceOperationsFor(cached).addFinalizerWithSSA(FINALIZER); + + var actual = get("existing"); + assertThat(result.getMetadata().getFinalizers()).containsExactly(FINALIZER); + assertThat(actual.getMetadata().getFinalizers()).containsExactly(FINALIZER); + assertThat(actual.getData()).containsEntry("key", "value"); + removeFinalizerAndDelete(actual); + } + + @Test + void doesNotRecreateDeletedResource() { + var cached = createConfigMap("deleted"); + client.resource(cached).delete(); + + var result = resourceOperationsFor(cached).addFinalizerWithSSA(FINALIZER); + + assertThat(result).isNull(); + assertThat(get("deleted")).isNull(); + } + + @Test + void addsFinalizerToResourceRecreatedWithSameName() { + var cached = createConfigMap("recreated"); + client.resource(cached).delete(); + var recreated = createConfigMap("recreated"); + + var result = resourceOperationsFor(cached).addFinalizerWithSSA(FINALIZER); + + var actual = get("recreated"); + assertThat(result.getMetadata().getUid()).isEqualTo(recreated.getMetadata().getUid()); + assertThat(actual.getMetadata().getUid()).isEqualTo(recreated.getMetadata().getUid()); + assertThat(actual.getMetadata().getFinalizers()).containsExactly(FINALIZER); + removeFinalizerAndDelete(actual); + } + + @Test + void addsFinalizerUsingStaleCachedResource() { + var cached = createConfigMap("stale"); + var updated = get("stale"); + updated.setData(Map.of("key", "updated")); + client.resource(updated).update(); + + resourceOperationsFor(cached).addFinalizerWithSSA(FINALIZER); + + var actual = get("stale"); + assertThat(actual.getMetadata().getFinalizers()).containsExactly(FINALIZER); + assertThat(actual.getData()).containsEntry("key", "updated"); + removeFinalizerAndDelete(actual); + } + + /** + * Only the framework plumbing around the API call is mocked: the controller event source just + * runs the write it is given, so the apply and the re-fetch on retry hit the real API server. + */ + @SuppressWarnings("unchecked") + private static ResourceOperations resourceOperationsFor(ConfigMap primary) { + Context context = mock(Context.class); + ControllerConfiguration controllerConfiguration = + mock(ControllerConfiguration.class); + var eventSourceRetriever = mock(EventSourceRetriever.class); + ControllerEventSource controllerEventSource = mock(ControllerEventSource.class); + + when(context.getClient()).thenReturn(client); + when(context.getPrimaryResource()).thenReturn(primary); + when(context.getControllerConfiguration()).thenReturn(controllerConfiguration); + when(controllerConfiguration.fieldManager()).thenReturn("finalizer-test"); + when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); + when(eventSourceRetriever.getControllerEventSource()).thenReturn(controllerEventSource); + when(controllerEventSource.updateAndCacheResource(any(), any(UnaryOperator.class))) + .thenAnswer( + invocation -> { + UnaryOperator write = invocation.getArgument(1); + return write.apply(invocation.getArgument(0)); + }); + return new ResourceOperations<>(context); + } + + private static ConfigMap createConfigMap(String name) { + return client + .resource( + new ConfigMapBuilder() + .withNewMetadata() + .withName(name) + .withNamespace(NAMESPACE) + .endMetadata() + .withData(Map.of("key", "value")) + .build()) + .create(); + } + + private static ConfigMap get(String name) { + return client.configMaps().inNamespace(NAMESPACE).withName(name).get(); + } + + private static void removeFinalizerAndDelete(ConfigMap configMap) { + configMap.getMetadata().getFinalizers().clear(); + client.resource(configMap).update(); + client.resource(configMap).delete(); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java index 95c1b1815e..657df5f81a 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java @@ -156,6 +156,78 @@ void addsFinalizerWithSSA() { verify(controllerEventSource, times(1)).updateAndCacheResource(any(), any(UnaryOperator.class)); } + @Test + void ssaFinalizerAddSendsUidWithoutResourceVersion() { + var resource = TestUtils.testCustomResource1(); + resource.getMetadata().setUid("uid-1"); + resource.getMetadata().setResourceVersion("1"); + when(context.getPrimaryResource()).thenReturn(resource); + var applied = ArgumentCaptor.forClass(TestCustomResource.class); + when(controllerEventSource.updateAndCacheResource(applied.capture(), any(UnaryOperator.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + resourceOperations.addFinalizerWithSSA(FINALIZER_NAME); + + var metadata = applied.getValue().getMetadata(); + assertThat(metadata.getName()).isEqualTo(resource.getMetadata().getName()); + assertThat(metadata.getUid()).isEqualTo("uid-1"); + assertThat(metadata.getResourceVersion()).isNull(); + assertThat(metadata.getFinalizers()).containsExactly(FINALIZER_NAME); + } + + @Test + void ssaFinalizerAddDoesNotRecreateDeletedResource() { + var resource = TestUtils.testCustomResource1(); + resource.getMetadata().setUid("uid-1"); + when(context.getPrimaryResource()).thenReturn(resource); + // with the UID set the API server rejects the apply of a missing resource with a conflict + when(controllerEventSource.updateAndCacheResource(any(), any(UnaryOperator.class))) + .thenThrow(new KubernetesClientException("Conflict", 409, null)); + when(resourceOp.get()).thenReturn(null); + + var result = resourceOperations.addFinalizerWithSSA(FINALIZER_NAME); + + assertThat(result).isNull(); + verify(controllerEventSource, times(1)).updateAndCacheResource(any(), any(UnaryOperator.class)); + verify(resourceOp, times(1)).get(); + } + + @Test + void ssaFinalizerAddRetriesWithUidOfRecreatedResource() { + var resource = TestUtils.testCustomResource1(); + resource.getMetadata().setUid("uid-1"); + when(context.getPrimaryResource()).thenReturn(resource); + var recreatedResource = TestUtils.testCustomResource1(); + recreatedResource.getMetadata().setUid("uid-2"); + when(resourceOp.get()).thenReturn(recreatedResource); + var applied = ArgumentCaptor.forClass(TestCustomResource.class); + // a UID differing from the existing resource's is rejected as an immutable field change + when(controllerEventSource.updateAndCacheResource(applied.capture(), any(UnaryOperator.class))) + .thenThrow(new KubernetesClientException("Unprocessable", 422, null)) + .thenAnswer(invocation -> invocation.getArgument(0)); + + var result = resourceOperations.addFinalizerWithSSA(FINALIZER_NAME); + + assertThat(result.hasFinalizer(FINALIZER_NAME)).isTrue(); + assertThat(applied.getAllValues()) + .extracting(r -> r.getMetadata().getUid()) + .containsExactly("uid-1", "uid-2"); + verify(resourceOp, times(1)).get(); + } + + @Test + void ssaFinalizerAddSkipsResourceThatAlreadyHasFinalizer() { + var resource = TestUtils.testCustomResource1(); + resource.getMetadata().setResourceVersion("1"); + resource.addFinalizer(FINALIZER_NAME); + when(context.getPrimaryResource()).thenReturn(resource); + + var result = resourceOperations.addFinalizerWithSSA(FINALIZER_NAME); + + assertThat(result).isSameAs(resource); + verify(controllerEventSource, never()).updateAndCacheResource(any(), any(UnaryOperator.class)); + } + @Test void removesFinalizer() { var resource = TestUtils.testCustomResource1(); 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 941da8be4c..4f0d4fb028 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 @@ -765,9 +765,7 @@ void processesAdditionalEventWhileInDeleteModeRetry() { spy( new EventProcessor( controllerConfigTriggerAllEvent( - GenericRetry.defaultLimitedExponentialRetry() - .setInitialInterval(1000) - .setMaxAttempts(-1), + GenericRetry.builder().withInitialInterval(1000).withMaxAttempts(-1).build(), rateLimiterMock), reconciliationDispatcherMock, eventSourceManagerMock, @@ -796,10 +794,11 @@ void afterRetryExhaustedAdditionalEventTriggerReconciliationWhenDeleteEventPrese spy( new EventProcessor( controllerConfigTriggerAllEvent( - GenericRetry.defaultLimitedExponentialRetry() - .setInitialInterval(100) - .setIntervalMultiplier(1) - .setMaxAttempts(1), + GenericRetry.builder() + .withInitialInterval(100) + .withIntervalMultiplier(1) + .withMaxAttempts(1) + .build(), rateLimiterMock), reconciliationDispatcherMock, eventSourceManagerMock, @@ -831,10 +830,11 @@ void rateLimitsDeleteEventInAllEventMode() { spy( new EventProcessor( controllerConfigTriggerAllEvent( - GenericRetry.defaultLimitedExponentialRetry() - .setInitialInterval(100) - .setIntervalMultiplier(1) - .setMaxAttempts(1), + GenericRetry.builder() + .withInitialInterval(100) + .withIntervalMultiplier(1) + .withMaxAttempts(1) + .build(), rateLimiterMock), reconciliationDispatcherMock, eventSourceManagerMock, 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 191836a0dc..ec97054ed4 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 @@ -595,7 +595,7 @@ void ifRetryLimitedToZeroMaxAttemptsErrorHandlerGetsCorrectLastAttempt() { var configuration = MockControllerConfiguration.forResource( (Class) testCustomResource.getClass()); - when(configuration.getRetry()).thenReturn(new GenericRetry().setMaxAttempts(0)); + when(configuration.getRetry()).thenReturn(GenericRetry.builder().withMaxAttempts(0).build()); reconciliationDispatcher = init(testCustomResource, reconciler, configuration, customResourceFacade, false); diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSourceNamespaceChangeTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSourceNamespaceChangeTest.java index a1ee558036..7a2ee4ee16 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSourceNamespaceChangeTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSourceNamespaceChangeTest.java @@ -15,13 +15,16 @@ */ package io.javaoperatorsdk.operator.processing.event.source.informer; +import java.time.Duration; import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; import io.fabric8.kubernetes.api.model.apps.Deployment; import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.informers.ResourceEventHandler; import io.fabric8.kubernetes.client.informers.SharedIndexInformer; import io.fabric8.kubernetes.client.informers.cache.Cache; import io.javaoperatorsdk.operator.MockKubernetesClient; @@ -37,8 +40,11 @@ import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** @@ -100,22 +106,71 @@ void namespaceChangeIsStillAppliedWhileRunning() { assertThat(pool.numberOfInformersForResource(Deployment.class)).isZero(); } + /** + * Fabric8 replays the cache of an already populated informer to a newly added handler + * synchronously, on the thread registering it. That replay enters the event source's monitor, + * which the namespace change holds while waiting for the informers to start, so the handler must + * be registered on the thread holding the monitor rather than on an informer starter thread. + */ + @Test + void namespaceChangeJoiningAPopulatedInformerDoesNotDeadlock() { + var eventHandler = mock(EventHandler.class); + var eventSource = buildEventSource(eventHandler); + eventSource.start(); + replayCacheOnHandlerRegistration(); + + assertTimeoutPreemptively( + Duration.ofSeconds(10), () -> eventSource.changeNamespaces(Set.of("ns1", "ns2"))); + + assertThat(eventSource.manager().isWatchingNamespace("ns2")).isTrue(); + verify(eventHandler).handleEvent(any()); + eventSource.stop(); + } + + private void replayCacheOnHandlerRegistration() { + var informer = informer(); + var existing = new Deployment(); + existing.setMetadata( + new ObjectMetaBuilder() + .withName("existing") + .withNamespace("ns2") + .withResourceVersion("1") + .build()); + doAnswer( + invocation -> { + ResourceEventHandler handler = invocation.getArgument(0); + handler.onAdd(existing); + return null; + }) + .when(informer) + .addEventHandler(any()); + } + /** * A successfully started {@link InformerEventSource} lists its cache to populate the * primary-to-secondary index, and the mock client leaves the informer's store unstubbed. */ private void stubInformerStore() { - SharedIndexInformer informer = - clientMock - .resources(Deployment.class) - .inNamespace("ns1") - .withLabelSelector((String) null) - .withShardSelector(null) - .runnableInformer(0); + var informer = informer(); when(informer.getStore()).thenReturn(mock(Cache.class)); } + /** The mock client hands out the very same informer for every namespace. */ + private SharedIndexInformer informer() { + return clientMock + .resources(Deployment.class) + .inNamespace("ns1") + .withLabelSelector((String) null) + .withShardSelector(null) + .runnableInformer(0); + } + private InformerEventSource buildEventSource() { + return buildEventSource(mock(EventHandler.class)); + } + + private InformerEventSource buildEventSource( + EventHandler eventHandler) { ConfigurationService configurationService = ConfigurationService.newOverriddenConfigurationService( new BaseConfigurationService(), @@ -125,7 +180,7 @@ private InformerEventSource buildEventSource() { when(controllerConfiguration.getName()).thenReturn("controller"); var eventSource = new InformerEventSource(configuration); - eventSource.setEventHandler(mock(EventHandler.class)); + eventSource.setEventHandler(eventHandler); eventSource.setControllerConfiguration(controllerConfiguration); return eventSource; } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/retry/GenericRetryExecutionTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/retry/GenericRetryExecutionTest.java index 8d7ec55e37..f04ab52abb 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/retry/GenericRetryExecutionTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/retry/GenericRetryExecutionTest.java @@ -26,7 +26,7 @@ class GenericRetryExecutionTest { @Test void noNextDelayIfMaxAttemptLimitReached() { RetryExecution retryExecution = - GenericRetry.defaultLimitedExponentialRetry().setMaxAttempts(3).initExecution(); + GenericRetry.builder().withMaxAttempts(3).build().initExecution(); Optional res = callNextDelayNTimes(retryExecution, 2); assertThat(res).isNotEmpty(); @@ -37,10 +37,11 @@ void noNextDelayIfMaxAttemptLimitReached() { @Test void canLimitMaxIntervalLength() { RetryExecution retryExecution = - GenericRetry.defaultLimitedExponentialRetry() - .setInitialInterval(2000) - .setMaxInterval(4500) - .setIntervalMultiplier(2) + GenericRetry.builder() + .withInitialInterval(2000) + .withMaxInterval(4500) + .withIntervalMultiplier(2) + .build() .initExecution(); Optional res = callNextDelayNTimes(retryExecution, 4); @@ -56,7 +57,8 @@ void supportsNoRetry() { @Test void supportsIsLastExecution() { - GenericRetryExecution execution = new GenericRetry().setMaxAttempts(2).initExecution(); + GenericRetryExecution execution = + GenericRetry.builder().withMaxAttempts(2).build().initExecution(); assertThat(execution.isLastAttempt()).isFalse(); execution.nextDelay(); @@ -83,7 +85,8 @@ void remainingDurationEmptyBeforeFirstNextDelay() { @Test void remainingDurationPresentAfterNextDelay() { long interval = 60_000L; - RetryExecution retryExecution = new GenericRetry().setInitialInterval(interval).initExecution(); + RetryExecution retryExecution = + GenericRetry.builder().withInitialInterval(interval).build().initExecution(); retryExecution.nextDelay(); @@ -94,7 +97,8 @@ void remainingDurationPresentAfterNextDelay() { @Test void remainingDurationEmptyAfterIntervalElapsed() throws InterruptedException { - RetryExecution retryExecution = new GenericRetry().setInitialInterval(20).initExecution(); + RetryExecution retryExecution = + GenericRetry.builder().withInitialInterval(20).build().initExecution(); retryExecution.nextDelay(); Thread.sleep(60); @@ -107,9 +111,10 @@ void remainingDurationReflectsUpdatedIntervalAfterSubsequentNextDelay() { long initialInterval = 1000L; double multiplier = 2.0; RetryExecution retryExecution = - new GenericRetry() - .setInitialInterval(initialInterval) - .setIntervalMultiplier(multiplier) + GenericRetry.builder() + .withInitialInterval(initialInterval) + .withIntervalMultiplier(multiplier) + .build() .initExecution(); // first two calls keep the initial interval (multiplier only kicks in after attempt 1) diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/retry/GenericRetryTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/retry/GenericRetryTest.java new file mode 100644 index 0000000000..f30b372ac7 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/retry/GenericRetryTest.java @@ -0,0 +1,104 @@ +/* + * 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.retry; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class GenericRetryTest { + + @Test + void builderUsesDefaults() { + var retry = GenericRetry.builder().build(); + + assertThat(retry.getMaxAttempts()).isEqualTo(GradualRetry.DEFAULT_MAX_ATTEMPTS); + assertThat(retry.getInitialInterval()).isEqualTo(GradualRetry.DEFAULT_INITIAL_INTERVAL); + assertThat(retry.getIntervalMultiplier()).isEqualTo(GradualRetry.DEFAULT_MULTIPLIER); + assertThat(retry.getMaxInterval()).isEqualTo(GradualRetry.DEFAULT_MAX_INTERVAL); + } + + @Test + void builderSetsAllValues() { + var retry = + GenericRetry.builder() + .withMaxAttempts(7) + .withInitialInterval(Duration.ofSeconds(3)) + .withIntervalMultiplier(2.5) + .withMaxInterval(Duration.ofMinutes(1)) + .build(); + + assertThat(retry.getMaxAttempts()).isEqualTo(7); + assertThat(retry.getInitialInterval()).isEqualTo(3000); + assertThat(retry.getIntervalMultiplier()).isEqualTo(2.5); + assertThat(retry.getMaxInterval()).isEqualTo(60_000); + } + + @Test + void builderSupportsUnlimitedAndLinearRetry() { + var retry = + GenericRetry.builder() + .withIntervalMultiplier(3) + .withoutMaxAttempts() + .withoutMaxInterval() + .withLinearRetry() + .build(); + + assertThat(retry.getMaxAttempts()).isEqualTo(-1); + assertThat(retry.getMaxInterval()).isEqualTo(-1); + assertThat(retry.getIntervalMultiplier()).isEqualTo(1); + } + + @Test + void builderCreatesIndependentInstances() { + var builder = GenericRetry.builder().withMaxAttempts(3); + var first = builder.build(); + var second = builder.withMaxAttempts(10).build(); + + assertThat(first.getMaxAttempts()).isEqualTo(3); + assertThat(second.getMaxAttempts()).isEqualTo(10); + } + + @Test + void toBuilderCopiesValuesWithoutModifyingOriginal() { + var original = + GenericRetry.builder() + .withMaxAttempts(4) + .withInitialInterval(100) + .withMaxInterval(500) + .build(); + + var derived = original.toBuilder().withMaxAttempts(8).build(); + + assertThat(original.getMaxAttempts()).isEqualTo(4); + assertThat(derived.getMaxAttempts()).isEqualTo(8); + assertThat(derived.getInitialInterval()).isEqualTo(100); + assertThat(derived.getMaxInterval()).isEqualTo(500); + assertThat(derived.getIntervalMultiplier()).isEqualTo(original.getIntervalMultiplier()); + } + + @Test + void factoryMethods() { + assertThat(GenericRetry.noRetry().getMaxAttempts()).isZero(); + + var every10s = GenericRetry.every10second10TimesRetry(); + assertThat(every10s.getMaxAttempts()).isEqualTo(10); + assertThat(every10s.getInitialInterval()).isEqualTo(10_000); + assertThat(every10s.getIntervalMultiplier()).isEqualTo(1); + } +} diff --git a/operator-framework-junit/pom.xml b/operator-framework-junit/pom.xml index f1d74a46a4..a46d4f6512 100644 --- a/operator-framework-junit/pom.xml +++ b/operator-framework-junit/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.6.2 + 5.6.4-SNAPSHOT operator-framework-junit diff --git a/operator-framework/pom.xml b/operator-framework/pom.xml index cb5075723d..c0e8952668 100644 --- a/operator-framework/pom.xml +++ b/operator-framework/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.6.2 + 5.6.4-SNAPSHOT operator-framework 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 index 8aed14da0d..df9e2e7a6c 100644 --- 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 @@ -286,12 +286,12 @@ private Consumer> bu } 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); + var retry = GenericRetry.builder(); + maxAttempts.ifPresent(retry::withMaxAttempts); + initialInterval.ifPresent(retry::withInitialInterval); + intervalMultiplier.ifPresent(retry::withIntervalMultiplier); + maxInterval.ifPresent(retry::withMaxInterval); + overrider.withRetry(retry.build()); }; } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/errorstatushandler/ErrorStatusHandlerIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/errorstatushandler/ErrorStatusHandlerIT.java index 32e36c43a8..2fb9d9a3f9 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/errorstatushandler/ErrorStatusHandlerIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/errorstatushandler/ErrorStatusHandlerIT.java @@ -46,7 +46,8 @@ class ErrorStatusHandlerIT { LocallyRunOperatorExtension operator = LocallyRunOperatorExtension.builder() .withReconciler( - reconciler, new GenericRetry().setMaxAttempts(MAX_RETRY_ATTEMPTS).withLinearRetry()) + reconciler, + GenericRetry.builder().withMaxAttempts(MAX_RETRY_ATTEMPTS).withLinearRetry().build()) .build(); @Test diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryIT.java index f3005e0991..53b540c246 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryIT.java @@ -49,10 +49,11 @@ class RetryIT { LocallyRunOperatorExtension.builder() .withReconciler( new RetryTestCustomReconciler(NUMBER_FAILED_EXECUTIONS), - new GenericRetry() - .setInitialInterval(RETRY_INTERVAL) + GenericRetry.builder() + .withInitialInterval(RETRY_INTERVAL) .withLinearRetry() - .setMaxAttempts(MAX_RETRY_ATTEMPTS)) + .withMaxAttempts(MAX_RETRY_ATTEMPTS) + .build()) .build(); @Test diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryIntervalHonoredOnFrequentEventsIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryIntervalHonoredOnFrequentEventsIT.java index df525e8056..7582511323 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryIntervalHonoredOnFrequentEventsIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryIntervalHonoredOnFrequentEventsIT.java @@ -58,10 +58,11 @@ class RetryIntervalHonoredOnFrequentEventsIT { LocallyRunOperatorExtension.builder() .withReconciler( reconciler, - new GenericRetry() - .setInitialInterval(RETRY_INTERVAL_MILLIS) + GenericRetry.builder() + .withInitialInterval(RETRY_INTERVAL_MILLIS) .withLinearRetry() - .setMaxAttempts(MAX_RETRY_ATTEMPTS)) + .withMaxAttempts(MAX_RETRY_ATTEMPTS) + .build()) .build(); @Test diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryMaxAttemptIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryMaxAttemptIT.java index 5ca6df223f..9f2b41523e 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryMaxAttemptIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryMaxAttemptIT.java @@ -47,10 +47,11 @@ class RetryMaxAttemptIT { LocallyRunOperatorExtension.builder() .withReconciler( reconciler, - new GenericRetry() - .setInitialInterval(RETRY_INTERVAL) + GenericRetry.builder() + .withInitialInterval(RETRY_INTERVAL) .withLinearRetry() - .setMaxAttempts(MAX_RETRY_ATTEMPTS)) + .withMaxAttempts(MAX_RETRY_ATTEMPTS) + .build()) .build(); @Test diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventIT.java index a8b022b642..caeb46f769 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventIT.java @@ -47,10 +47,11 @@ public class TriggerReconcilerOnAllEventIT { new TriggerReconcilerOnAllEventReconciler(), o -> o.withRetry( - new GenericRetry() - .setInitialInterval(800) - .setMaxAttempts(MAX_RETRY_ATTEMPTS) - .setIntervalMultiplier(1))) + GenericRetry.builder() + .withInitialInterval(800) + .withMaxAttempts(MAX_RETRY_ATTEMPTS) + .withIntervalMultiplier(1) + .build())) .build(); @Test @@ -181,19 +182,16 @@ void additionalEventDuringRetryOnDeleteEvent() { extension.create(res); extension.delete(getResource()); - await() - .pollDelay(Duration.ofMillis(30)) - .untilAsserted( - () -> { - assertThat(reconciler.getEventCount()).isGreaterThan(2); - }); - var eventCount = reconciler.getEventCount(); - + // Don't assume a fixed number of reconciliations before the first retry: if the delete event + // arrives before the first reconciliation reads the resource, the finalizer-adding + // reconciliation is skipped. The reconciler increments the counter before it starts waiting, + // and nothing else reconciles while it waits, so the count read after this is stable. await() .untilAsserted( () -> { assertThat(reconciler.isWaiting()).isTrue(); }); + var eventCount = reconciler.getEventCount(); // trigger reconciliation while waiting in reconciler res = getResource(); diff --git a/pom.xml b/pom.xml index 5ad5452677..e9277efe34 100644 --- a/pom.xml +++ b/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.6.2 + 5.6.4-SNAPSHOT pom Operator SDK for Java Java SDK for implementing Kubernetes operators @@ -72,25 +72,25 @@ jdk 6.1.3 7.9.0 - 2.0.19 + 2.0.20 2.26.1 - 5.23.0 - 3.20.0 - 0.23.0 + 5.24.0 + 3.21.0 + 0.24.0 1.13.0 3.27.7 4.3.0 2.7.3 - 1.17.1 - 3.2.4 + 1.17.2 + 3.3.1 0.9.14 2.22.0 - 4.17 + 4.18 2.11 3.16.0 - 3.6.0 - 0.11.0 + 3.6.1 + 0.12.0 3.12.0 3.5.0 3.4.0 @@ -103,7 +103,7 @@ 10.0.1 3.5.2 3.8.0 - 4.10.4.1 + 4.10.5.0 @@ -299,7 +299,7 @@ me.fabriciorby maven-surefire-junit5-tree-reporter - 1.5.1 + 2.1.0 diff --git a/sample-operators/controller-namespace-deletion/pom.xml b/sample-operators/controller-namespace-deletion/pom.xml index 6d9ed7480c..cb685c82df 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.6.2 + 5.6.4-SNAPSHOT sample-controller-namespace-deletion diff --git a/sample-operators/kotlin-operator/pom.xml b/sample-operators/kotlin-operator/pom.xml index 4f92755d85..62057fbbfc 100644 --- a/sample-operators/kotlin-operator/pom.xml +++ b/sample-operators/kotlin-operator/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.6.2 + 5.6.4-SNAPSHOT sample-kotlin-operator @@ -33,7 +33,7 @@ Kotlin - 2.4.20 + 2.4.21 diff --git a/sample-operators/leader-election/pom.xml b/sample-operators/leader-election/pom.xml index ca6c3f3385..a07c12034a 100644 --- a/sample-operators/leader-election/pom.xml +++ b/sample-operators/leader-election/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.6.2 + 5.6.4-SNAPSHOT sample-leader-election diff --git a/sample-operators/mysql-schema/pom.xml b/sample-operators/mysql-schema/pom.xml index 611925abe4..5f5cf70b4c 100644 --- a/sample-operators/mysql-schema/pom.xml +++ b/sample-operators/mysql-schema/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.6.2 + 5.6.4-SNAPSHOT sample-mysql-schema-operator diff --git a/sample-operators/operations/pom.xml b/sample-operators/operations/pom.xml index d102757677..17ad0db462 100644 --- a/sample-operators/operations/pom.xml +++ b/sample-operators/operations/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.6.2 + 5.6.4-SNAPSHOT sample-operations @@ -85,7 +85,7 @@ org.eclipse.jetty jetty-server - 12.1.13 + 12.1.14 io.javaoperatorsdk diff --git a/sample-operators/pom.xml b/sample-operators/pom.xml index bfe00acf09..e658955aef 100644 --- a/sample-operators/pom.xml +++ b/sample-operators/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk java-operator-sdk - 5.6.2 + 5.6.4-SNAPSHOT sample-operators diff --git a/sample-operators/tomcat-operator/pom.xml b/sample-operators/tomcat-operator/pom.xml index 2aa7533705..dc6c95c662 100644 --- a/sample-operators/tomcat-operator/pom.xml +++ b/sample-operators/tomcat-operator/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.6.2 + 5.6.4-SNAPSHOT sample-tomcat-operator diff --git a/sample-operators/webpage/pom.xml b/sample-operators/webpage/pom.xml index 704f79c57b..734c5b7f6a 100644 --- a/sample-operators/webpage/pom.xml +++ b/sample-operators/webpage/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.6.2 + 5.6.4-SNAPSHOT sample-webpage-operator diff --git a/test-index-processor/pom.xml b/test-index-processor/pom.xml index b06bdaabaa..58cec3a8e7 100644 --- a/test-index-processor/pom.xml +++ b/test-index-processor/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk java-operator-sdk - 5.6.2 + 5.6.4-SNAPSHOT test-index-processor